diff --git a/.github/scripts/wait-for-mockserver.sh b/.github/scripts/wait-for-mockserver.sh new file mode 100755 index 00000000..1f80c6cc --- /dev/null +++ b/.github/scripts/wait-for-mockserver.sh @@ -0,0 +1,28 @@ +#!/bin/bash + +wait_for_server() { + local url=$1 + echo "Waiting for $url to be ready..." + + MAX_RETRIES="${MAX_RETRIES:-30}" + SLEEP_SECONDS="${SLEEP_SECONDS:-2}" + + for ((i = 1; i <= MAX_RETRIES; i++)); do + if curl -sSf "$url" > /dev/null; then + echo "$url is ready!" + return 0 + fi + echo "Attempt $i/$MAX_RETRIES: Still waiting for $url..." + sleep "$SLEEP_SECONDS" + done + + echo "Error: $url was not available after $((MAX_RETRIES * SLEEP_SECONDS)) seconds" + exit 1 +} + +# Wait for auth mock servers +wait_for_server "http://localhost:3011/health" +# Wait for numbers mock servers +wait_for_server "http://localhost:3013/health" + +echo "All mock servers are ready!" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..0534969d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,105 @@ +name: Test Python SDK +on: [ push ] + +env: + AUTH_ORIGIN: ${{ secrets.AUTH_ORIGIN }} + CONVERSATION_ORIGIN: ${{ secrets.CONVERSATION_ORIGIN }} + DISABLE_SSL: ${{ secrets.DISABLE_SSL }} + KEY_ID: ${{ secrets.KEY_ID }} + KEY_SECRET: ${{ secrets.KEY_SECRET }} + NUMBERS_ORIGIN: ${{ secrets.NUMBERS_ORIGIN }} + PROJECT_ID: ${{ secrets.PROJECT_ID }} + SERVICE_PLAN_ID: ${{ secrets.SERVICE_PLAN_ID }} + SMS_ORIGIN: ${{ secrets.SMS_ORIGIN }} + TEMPLATES_ORIGIN: ${{ secrets.TEMPLATES_ORIGIN }} + +jobs: + build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e . + pip install -r requirements-dev.txt + + - name: Compile all examples + run: | + for file in $(find examples -name "*.py"); do + echo "Compiling $file..." + python -m py_compile "$file" || exit 1 + done + echo "All examples compiled successfully." + + - name: Check snippet coverage + run: | + pip install python-dotenv + python scripts/check_snippet_coverage.py + + - name: Lint and format check with Ruff + run: | + ruff check sinch/domains/numbers --statistics + ruff format sinch/domains/numbers --check --diff + ruff check sinch/domains/sms --statistics + ruff format sinch/domains/sms --check --diff + ruff check sinch/domains/number_lookup --statistics + ruff format sinch/domains/number_lookup --check --diff + ruff check sinch/domains/conversation --statistics + ruff format sinch/domains/conversation --check --diff + + - name: Test with Pytest + run: | + coverage run --source=. -m pytest + + - name: Coverage Test Report + run: | + python -m coverage report --skip-empty + + - name: Checkout sinch-sdk-mockserver repository + uses: actions/checkout@v4 + with: + repository: sinch/sinch-sdk-mockserver + token: ${{ secrets.PAT_CI }} + fetch-depth: 0 + path: sinch-sdk-mockserver + + - name: Start mock servers with Docker Compose + run: | + cd sinch-sdk-mockserver + docker compose up -d + + - name: Copy feature files + run: | + cp sinch-sdk-mockserver/features/numbers/available-regions.feature ./tests/e2e/numbers/features/ + cp sinch-sdk-mockserver/features/numbers/callback-configuration.feature ./tests/e2e/numbers/features/ + cp sinch-sdk-mockserver/features/numbers/numbers.feature ./tests/e2e/numbers/features/ + cp sinch-sdk-mockserver/features/numbers/webhooks.feature ./tests/e2e/numbers/features/ + cp sinch-sdk-mockserver/features/sms/delivery-reports.feature ./tests/e2e/sms/features/ + cp sinch-sdk-mockserver/features/sms/delivery-reports_servicePlanId.feature ./tests/e2e/sms/features/ + cp sinch-sdk-mockserver/features/sms/batches.feature ./tests/e2e/sms/features/ + cp sinch-sdk-mockserver/features/sms/batches_servicePlanId.feature ./tests/e2e/sms/features/ + 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 + shell: bash + + - name: Run e2e tests sync + run: | + python -m behave tests/e2e/numbers/features + python -m behave tests/e2e/sms/features + python -m behave tests/e2e/conversation/features + python -m behave tests/e2e/number-lookup/features diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml deleted file mode 100644 index 828691a7..00000000 --- a/.github/workflows/run-tests.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Test Python SDK -on: [ push ] - -env: - APPLICATION_KEY: ${{ secrets.APPLICATION_KEY }} - APPLICATION_SECRET: ${{ secrets.APPLICATION_SECRET }} - AUTH_ORIGIN: ${{ secrets.AUTH_ORIGIN }} - CONVERSATION_ORIGIN: ${{ secrets.CONVERSATION_ORIGIN }} - DISABLE_SSL: ${{ secrets.DISABLE_SSL }} - KEY_ID: ${{ secrets.KEY_ID }} - KEY_SECRET: ${{ secrets.KEY_SECRET }} - NUMBERS_ORIGIN: ${{ secrets.NUMBERS_ORIGIN }} - PROJECT_ID: ${{ secrets.PROJECT_ID }} - SERVICE_PLAN_ID: ${{ secrets.SERVICE_PLAN_ID }} - SMS_ORIGIN: ${{ secrets.SMS_ORIGIN }} - TEMPLATES_ORIGIN: ${{ secrets.TEMPLATES_ORIGIN }} - VERIFICATION_ORIGIN: ${{ secrets.VERIFICATION_ORIGIN }} - VOICE_CALL_ID: ${{ secrets.VOICE_CALL_ID }} - VOICE_ORIGIN: ${{ secrets.VOICE_ORIGIN }} - -jobs: - build: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] - - steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements-dev.txt - - name: Lint with flake8 - run: | - flake8 sinch --count --max-complexity=10 --max-line-length=120 --statistics - - name: Test with Pytest - run: | - coverage run --source=. -m pytest - - name: Coverage Test Report - run: | - python -m coverage report --skip-empty diff --git a/.gitignore b/.gitignore index e79cdc6f..f17ea8a2 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ +tox.ini .nox/ .coverage .coverage.* @@ -51,6 +52,9 @@ coverage.xml .pytest_cache/ cover/ +# E2E features +*.feature + # Translations *.mo *.pot @@ -129,4 +133,12 @@ cython_debug/ .idea/ # Poetry -poetry.lock \ No newline at end of file +poetry.lock + +# .DS_Store files +.DS_Store + +qodana.yaml + +# AI stuff +.claude \ No newline at end of file diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md new file mode 100644 index 00000000..19148549 --- /dev/null +++ b/MIGRATION_GUIDE.md @@ -0,0 +1,322 @@ +# Sinch Python SDK Migration Guide + +## 2.0.0 + +This release removes legacy SDK support. + +This guide lists all removed classes and interfaces from V1 and how to migrate to their V2 equivalents. + +> **Note:** Voice and Verification are not yet covered by the new V2 APIs. Support will be added in future releases. + +--- + +## Client Initialization + +### Overview + +In V2, region parameters are required for domain-specific APIs (SMS and Conversation). These parameters must be set explicitly when initializing `SinchClient`, otherwise API calls will fail at runtime. The parameters are exposed directly on `SinchClient` to ensure they are provided. + +### SMS Region + +**In V1:** +```python +from sinch import SinchClient + +# Using Project auth +sinch_client = SinchClient( + project_id="your-project-id", + key_id="your-key-id", + key_secret="your-key-secret", +) +sinch_client.configuration.sms_region = "eu" + +# Or using SMS token auth +token_client = SinchClient( + service_plan_id='your-service-plan-id', + sms_api_token='your-sms-api-token' +) +token_client.configuration.sms_region_with_service_plan_id = "eu" +``` + +**In V2:** +- The `sms_region` no longer defaults to `us`. Set it explicitly before using the SMS API, otherwise calls will fail at runtime. The parameter is now exposed on `SinchClient` (not just the configuration object) to ensure the region is provided. Note that `sms_region` is only required when using the SMS API endpoints. + +```python +from sinch import SinchClient + +# Using Project auth +sinch_client = SinchClient( + project_id="your-project-id", + key_id="your-key-id", + key_secret="your-key-secret", + sms_region="eu", +) + +# Or using SMS token auth +token_client = SinchClient( + service_plan_id="your-service-plan-id", + sms_api_token="your-sms-api-token", + sms_region="us", +) + +# Note: The code is backward compatible. The sms_region can still be set through the configuration object, +# but you must ensure this setting is done BEFORE any SMS API call: +sinch_client.configuration.sms_region = "eu" +``` + +--- + +### Conversation Region + +**In V1:** +```python +from sinch import SinchClient + +sinch_client = SinchClient( + project_id="your-project-id", + key_id="your-key-id", + key_secret="your-key-secret", +) + +sinch_client.configuration.conversation_region = "eu" +``` + +**In V2:** +- The `conversation_region` no longer defaults to `eu`. This parameter is required now when using the Conversation API endpoints. Set it explicitly when initializing `SinchClient`, otherwise calls will fail at runtime. The parameter is exposed on `SinchClient` to ensure the region is provided. + +```python +from sinch import SinchClient + +sinch_client = SinchClient( + project_id="your-project-id", + key_id="your-key-id", + key_secret="your-key-secret", + conversation_region="eu", +) + +# Note: The conversation_region can also be set through the configuration object, +# but you must ensure this setting is done BEFORE any Conversation API call: +sinch_client.configuration.conversation_region = "eu" +``` + +--- + +### [`Conversation`](https://github.com/sinch/sinch-sdk-python/tree/main/sinch/domains/conversation) + +#### Replacement models + +##### Messages (send, get, delete, list) + +| Old class | New class | +|-----------|-----------| +| `sinch.domains.conversation.models.message.requests.SendConversationMessageRequest` | `send()`: pass `app_id`, `message` (dict or [`SendMessageRequestBodyDict`](sinch/domains/conversation/models/v1/messages/types/send_message_request_body_dict.py)), and either `contact_id` or `recipient_identities`. Internally uses [`SendMessageRequest`](sinch/domains/conversation/models/v1/messages/internal/request/send_message_request.py), [`SendMessageRequestBody`](sinch/domains/conversation/models/v1/messages/internal/request/send_message_request_body.py). For typed payloads use `send_text_message()`, `send_card_message()`, etc. +| `sinch.domains.conversation.models.message.responses.SendConversationMessageResponse` | [`SendMessageResponse`](sinch/domains/conversation/models/v1/messages/response/send_message_response.py) (`message_id`, optional `accepted_time` as `datetime`) | +| `sinch.domains.conversation.models.message.requests.GetConversationMessageRequest` | `get(message_id, messages_source=None, **kwargs)`. Internally uses [`MessageIdRequest`](sinch/domains/conversation/models/v1/messages/internal/request/message_id_request.py). | +| `sinch.domains.conversation.models.message.responses.GetConversationMessageResponse` | [`ConversationMessageResponse`](sinch/domains/conversation/models/v1/messages/response/types/__init__.py) (Union of app/contact message response types) | +| `sinch.domains.conversation.models.message.requests.DeleteConversationMessageRequest` | `delete(message_id, messages_source=None, **kwargs)`. Internally uses [`MessageIdRequest`](sinch/domains/conversation/models/v1/messages/internal/request/message_id_request.py). | +| `sinch.domains.conversation.models.message.responses.DeleteConversationMessageResponse` | `None` (method returns `None`) | +| `sinch.domains.conversation.models.message.requests.ListConversationMessagesRequest` | `list()` with individual parameters: `conversation_id`, `contact_id`, `app_id`, `page_size`, `page_token`, `view`, `messages_source`, `only_recipient_originated` (signature aligned with V1 where available) | +| `sinch.domains.conversation.models.message.responses.ListConversationMessagesResponse` | Response type for `list()` (messages list, next_page_token) | + +#### Replacement APIs + +The Conversation domain API access remains `sinch_client.conversation`; message operations are under `sinch_client.conversation.messages`. Recipient is specified with exactly one of `contact_id` or `recipient_identities` (list of `{channel, identity}`). + +##### Messages API + +| Old method | New method in `conversation.messages` | +|------------|----------------------------------------| +| `send()` with `SendConversationMessageRequest` | Use convenience methods: `send_text_message()`, `send_card_message()`, `send_carousel_message()`, `send_choice_message()`, `send_contact_info_message()`, `send_list_message()`, `send_location_message()`, `send_media_message()`, `send_template_message()`
Or `send()` with `app_id`, `message` (dict or `SendMessageRequestBodyDict`), and either `contact_id` or `recipient_identities` | +| `get()` with `GetConversationMessageRequest` | `get()` with `message_id: str` parameter | +| `delete()` with `DeleteConversationMessageRequest` | `delete()` with `message_id: str` parameter | +| `list()` with `ListConversationMessagesRequest` | `list()` with the same fields as keyword arguments (see models table above). V2 adds optional `channel_identity`, `start_time`, `end_time`, `channel`, `direction`. Returns **`Paginator[ConversationMessageResponse]`**: use `.content()` for messages on the current page, `.next_page()` to load the next page, or `.iterator()` to walk every message across all pages. | +| — | **New in V2:** `update()` with `message_id`, `metadata`, and optional `messages_source` | + +##### Replacement APIs / attributes + +| Old | New | +|-----|-----| +| `sinch_client.conversation.webhook` (REST: create, list, get, update, delete webhooks; models under `sinch.domains.conversation.models.webhook`, e.g. `CreateConversationWebhookRequest`, `SinchListWebhooksResponse`) | **Not available in V2.** The Conversation client only exposes `messages` and `sinch_events`; More features are planned for future releases. To validate and parse inbound Sinch Events payloads, use `sinch_client.conversation.sinch_events(callback_secret)`—see **Sinch Events** below. | + +#### Sinch Events (Event Destinations payload models and package path) + +| Old | New | +|-----|-----| +| — _(N/A)_ | `sinch.domains.conversation.models.v1.sinch_events` (package path for inbound payload models) | +| — | [`ConversationSinchEvent`](sinch/domains/conversation/sinch_events/v1/conversation_sinch_event.py) (handler: signature validation and `parse_event`) | +| — | `ConversationSinchEventPayload`, `ConversationSinchEventBase`, and concrete event types (e.g. `MessageInboundEvent`, `MessageDeliveryReceiptEvent`, `MessageSubmitEvent`) | + +To obtain a Conversation Sinch Events handler: `sinch_client.conversation.sinch_events(callback_secret)` returns a [`ConversationSinchEvent`](sinch/domains/conversation/sinch_events/v1/conversation_sinch_event.py) instance; `handler.parse_event(request_body)` returns a `ConversationSinchEventPayload`. + +```python +# New +handler = sinch_client.conversation.sinch_events("your_callback_secret") +event = handler.parse_event(request_body) +``` + +#### Request and response fields: callback URL → event destination target + +| | Old | New | +|---|-----|-----| +| **Messages (`send`)** | `sinch.domains.conversation.models.message.requests.SendConversationMessageRequest` field `callback_url` | [`SendMessageRequest`](sinch/domains/conversation/models/v1/messages/internal/request/send_message_request.py) field `event_destination_target` | +| **Messages (methods)** | `ConversationMessage.send(..., callback_url=...)` | `sinch_client.conversation.messages.send()`, `send_text_message()`, and other `send_*_message()` methods with `event_destination_target=...` | +| **Send event** | `sinch.domains.conversation.models.event.requests.SendConversationEventRequest` field `callback_url` | `event_destination_target` on the V2 send-event request model when that API is exposed | + +The Conversation HTTP API still expects the JSON field **`callback_url`**. In V2, use the Python parameter / model field `event_destination_target`; it is serialized as `callback_url` on the wire (same pattern as other domains, e.g. SMS). + +--- + +### [`SMS`](https://github.com/sinch/sinch-sdk-python/tree/main/sinch/domains/sms) + +#### Replacement models + +##### Batches + +| Old class | New class | +|-----------|-----------| +| `sinch.domains.sms.models.batches.requests.BatchRequest` | [`sinch.domains.sms.models.v1.shared.TextRequest`](sinch/domains/sms/models/v1/shared/text_request.py), [`sinch.domains.sms.models.v1.shared.BinaryRequest`](sinch/domains/sms/models/v1/shared/binary_request.py), or [`sinch.domains.sms.models.v1.shared.MediaRequest`](sinch/domains/sms/models/v1/shared/media_request.py) | +| `sinch.domains.sms.models.batches.requests.SendBatchRequest` | [`sinch.domains.sms.models.v1.shared.TextRequest`](sinch/domains/sms/models/v1/shared/text_request.py), [`sinch.domains.sms.models.v1.shared.BinaryRequest`](sinch/domains/sms/models/v1/shared/binary_request.py), or [`sinch.domains.sms.models.v1.shared.MediaRequest`](sinch/domains/sms/models/v1/shared/media_request.py) | +| `sinch.domains.sms.models.batches.requests.ListBatchesRequest` | [`sinch.domains.sms.models.v1.internal.ListBatchesRequest`](sinch/domains/sms/models/v1/internal/list_batches_request.py) | +| `sinch.domains.sms.models.batches.requests.GetBatchRequest` | [`sinch.domains.sms.models.v1.internal.BatchIdRequest`](sinch/domains/sms/models/v1/internal/batch_id_request.py) | +| `sinch.domains.sms.models.batches.requests.CancelBatchRequest` | [`sinch.domains.sms.models.v1.internal.BatchIdRequest`](sinch/domains/sms/models/v1/internal/batch_id_request.py) | +| `sinch.domains.sms.models.batches.requests.BatchDryRunRequest` | [`sinch.domains.sms.models.v1.internal.DryRunRequest`](sinch/domains/sms/models/v1/internal/dry_run_request.py) (Union of [`DryRunTextRequest`](sinch/domains/sms/models/v1/internal/dry_run_request.py), [`DryRunBinaryRequest`](sinch/domains/sms/models/v1/internal/dry_run_request.py), [`DryRunMediaRequest`](sinch/domains/sms/models/v1/internal/dry_run_request.py)) | +| `sinch.domains.sms.models.batches.requests.UpdateBatchRequest` | [`sinch.domains.sms.models.v1.internal.UpdateBatchMessageRequest`](sinch/domains/sms/models/v1/internal/update_batch_message_request.py) (Union of [`UpdateTextRequestWithBatchId`](sinch/domains/sms/models/v1/internal/update_batch_message_request.py), [`UpdateBinaryRequestWithBatchId`](sinch/domains/sms/models/v1/internal/update_batch_message_request.py), [`UpdateMediaRequestWithBatchId`](sinch/domains/sms/models/v1/internal/update_batch_message_request.py)) | +| `sinch.domains.sms.models.batches.requests.ReplaceBatchRequest` | [`sinch.domains.sms.models.v1.internal.ReplaceBatchRequest`](sinch/domains/sms/models/v1/internal/replace_batch_request.py) (Union of [`ReplaceTextRequest`](sinch/domains/sms/models/v1/internal/replace_batch_request.py), [`ReplaceBinaryRequest`](sinch/domains/sms/models/v1/internal/replace_batch_request.py), [`ReplaceMediaRequest`](sinch/domains/sms/models/v1/internal/replace_batch_request.py)) | +| `sinch.domains.sms.models.batches.requests.SendDeliveryFeedbackRequest` | [`sinch.domains.sms.models.v1.internal.DeliveryFeedbackRequest`](sinch/domains/sms/models/v1/internal/delivery_feedback_request.py) | +| `sinch.domains.sms.models.batches.responses.SendSMSBatchResponse` | [`sinch.domains.sms.models.v1.types.BatchResponse`](sinch/domains/sms/models/v1/types/batch_response.py) (Union of [`TextResponse`](sinch/domains/sms/models/v1/shared/text_response.py), [`BinaryResponse`](sinch/domains/sms/models/v1/shared/binary_response.py), [`MediaResponse`](sinch/domains/sms/models/v1/shared/media_response.py)) | +| `sinch.domains.sms.models.batches.responses.ReplaceSMSBatchResponse` | [`sinch.domains.sms.models.v1.types.BatchResponse`](sinch/domains/sms/models/v1/types/batch_response.py) | +| `sinch.domains.sms.models.batches.responses.ListSMSBatchesResponse` | [`sinch.domains.sms.models.v1.response.ListBatchesResponse`](sinch/domains/sms/models/v1/response/list_batches_response.py) | +| `sinch.domains.sms.models.batches.responses.GetSMSBatchResponse` | [`sinch.domains.sms.models.v1.types.BatchResponse`](sinch/domains/sms/models/v1/types/batch_response.py) | +| `sinch.domains.sms.models.batches.responses.CancelSMSBatchResponse` | [`sinch.domains.sms.models.v1.types.BatchResponse`](sinch/domains/sms/models/v1/types/batch_response.py) | +| `sinch.domains.sms.models.batches.responses.SendSMSBatchDryRunResponse` | [`sinch.domains.sms.models.v1.response.DryRunResponse`](sinch/domains/sms/models/v1/response/dry_run_response.py) | +| `sinch.domains.sms.models.batches.responses.UpdateSMSBatchResponse` | [`sinch.domains.sms.models.v1.types.BatchResponse`](sinch/domains/sms/models/v1/types/batch_response.py) | +| `sinch.domains.sms.models.batches.responses.SendSMSDeliveryFeedbackResponse` | `None` (The method returns an empty 202 HTTP response) | + +##### Delivery Reports + +| Old class | New class | +|-----------|-----------| +| `sinch.domains.sms.models.delivery_reports.requests.ListSMSDeliveryReportsRequest` | [`sinch.domains.sms.models.v1.internal.ListDeliveryReportsRequest`](sinch/domains/sms/models/v1/internal/list_delivery_reports_request.py) | +| `sinch.domains.sms.models.delivery_reports.requests.GetSMSDeliveryReportForBatchRequest` | [`sinch.domains.sms.models.v1.internal.GetBatchDeliveryReportRequest`](sinch/domains/sms/models/v1/internal/get_batch_delivery_report_request.py) | +| `sinch.domains.sms.models.delivery_reports.requests.GetSMSDeliveryReportForNumberRequest` | [`sinch.domains.sms.models.v1.internal.GetRecipientDeliveryReportRequest`](sinch/domains/sms/models/v1/internal/get_recipient_delivery_report_request.py) | +| `sinch.domains.sms.models.delivery_reports.responses.ListSMSDeliveryReportsResponse` | [`sinch.domains.sms.models.v1.internal.ListDeliveryReportsResponse`](sinch/domains/sms/models/v1/internal/list_delivery_reports_response.py) | +| `sinch.domains.sms.models.delivery_reports.responses.GetSMSDeliveryReportForBatchResponse` | [`sinch.domains.sms.models.v1.response.BatchDeliveryReport`](sinch/domains/sms/models/v1/response/batch_delivery_report.py) | +| `sinch.domains.sms.models.delivery_reports.responses.GetSMSDeliveryReportForNumberResponse` | [`sinch.domains.sms.models.v1.response.RecipientDeliveryReport`](sinch/domains/sms/models/v1/response/recipient_delivery_report.py) | + +#### Replacement APIs + +The SMS domain API access remains the same: `sinch.sms.batches` and `sinch.sms.delivery_reports`. However, the underlying models and method signatures have changed. + +Note that `sinch.sms.groups` and `sinch.sms.inbounds` are not supported yet and will be available in future minor versions. + +##### Batches API + +| Old method | New method in `sms.batches` | +|------------|----------------------------| +| `send()` with `SendBatchRequest` | Use convenience methods: `send_sms()`, `send_binary()`, `send_mms()`
Or `send()` with `SendSMSRequest` (Union of `TextRequest`, `BinaryRequest`, `MediaRequest`) | +| `list()` with `ListBatchesRequest` | `list()` with individual parameters: `page`, `page_size`, `start_date`, `end_date`, `from_`, `client_reference` | +| `get()` with `GetBatchRequest` | `get()` with `batch_id: str` parameter | +| `send_dry_run()` with `BatchDryRunRequest` | Use convenience methods: `dry_run_sms()`, `dry_run_binary()`, `dry_run_mms()`
Or `dry_run()` with `DryRunRequest` (Union of `DryRunTextRequest`, `DryRunBinaryRequest`, `DryRunMediaRequest`) | +| `update()` with `UpdateBatchRequest` | Use convenience methods: `update_sms()`, `update_binary()`, `update_mms()`
Or `update()` with `UpdateBatchMessageRequest` (Union of `UpdateTextRequestWithBatchId`, `UpdateBinaryRequestWithBatchId`, `UpdateMediaRequestWithBatchId`) | +| `replace()` with `ReplaceBatchRequest` | Use convenience methods: `replace_sms()`, `replace_binary()`, `replace_mms()`
Or `replace()` with `ReplaceBatchRequest` (Union of `ReplaceTextRequest`, `ReplaceBinaryRequest`, `ReplaceMediaRequest`) | + +--- + +##### Delivery Reports API + +| Old method | New method in `sms.delivery_reports` | +|------------|-------------------------------------| +| `list()` with `ListSMSDeliveryReportsRequest` | `list()` the parameters `start_date` and `end_date` now accepts both `str` and `datetime` | +| `get_for_batch()` with `GetSMSDeliveryReportForBatchRequest` | `get()` with `batch_id: str` and optional parameters: `report_type`, `status`, `code`, `client_reference` | +| `get_for_number()` with `GetSMSDeliveryReportForNumberRequest` | `get_for_number()` with `batch_id: str` and `recipient: str` parameters | + +--- + +### [`Numbers` (Virtual Numbers)](https://github.com/sinch/sinch-sdk-python/tree/main/sinch/domains/numbers) + +##### Replacement APIs / attributes + +| Old | New | +|-----|-----| +| `sinch_client.numbers.callbacks` (attribute) | `sinch_client.numbers.event_destinations` (attribute) | +| `numbers.callbacks.get_configuration()` (method) | `numbers.event_destinations.get()` (method) | +| `numbers.callbacks.update_configuration(hmac_secret)` (method) | `numbers.event_destinations.update(hmac_secret=hmac_secret)` (method) | + +##### Replacement models + +| Old class | New class | +|-----------|-----------| +| `UpdateNumbersCallbackConfigurationRequest` | `UpdateEventDestinationRequest` | +| `GetNumbersCallbackConfigurationResponse` | `EventDestinationResponse` | +| `UpdateNumbersCallbackConfigurationResponse` | `EventDestinationResponse` | + +**Example:** + +```python +# Old +config = sinch_client.numbers.callbacks.get_configuration() +sinch_client.numbers.callbacks.update_configuration("your_hmac_secret") + +# New +config = sinch_client.numbers.event_destinations.get() +sinch_client.numbers.event_destinations.update(hmac_secret="your_hmac_secret") +``` + +##### Available and Active: method locations + +| Old method | New method | +|------------|------------| +| `numbers.available.rent_any(...)`, `numbers.available.activate(...)`, `numbers.available.check_availability(...)`, `numbers.available.list(...)` | `numbers.rent_any(...)`, `numbers.rent(...)`, `numbers.check_availability(...)`, `numbers.search_for_available_numbers(...)` | +| `numbers.active.list(...)`, `numbers.active.get(...)`, `numbers.active.update(...)`, `numbers.active.release(...)` | `numbers.list(...)`, `numbers.get(...)`, `numbers.update(...)`, `numbers.release(...)` | + +#### Sinch Events (Event Destinations payload models and package path) + +| Old | New | +|-----|-----| +| — _(N/A)_ | `sinch.domains.numbers.sinch_events` (package path) | +| — | `NumberSinchEvent` (class, payload model) | + +To obtain a Numbers Sinch Events handler: `sinch_client.numbers.sinch_events(callback_secret)` returns a `SinchEvents` instance; `handler.parse_event(request_body)` returns a `NumberSinchEvent`. + +```python +# New +from sinch.domains.numbers.sinch_events.v1.events import NumberSinchEvent +handler = sinch_client.numbers.sinch_events("your_callback_secret") +event = handler.parse_event(request_body) # event is a NumberSinchEvent +``` + +#### Request and response fields: callback URL → event destination target + +| | Old | New | +|---|-----|-----| +| **Methods that accept the parameter** | Only `numbers.available.rent_any(..., callback_url=...)` | `numbers.rent(...)`, `numbers.rent_any(...)`, and `numbers.update(...)` accept `event_destination_target` | +| **Parameter name** | `callback_url` | `event_destination_target` | + +##### Replacement request/response attributes + +| Old | New | +|-----|-----| +| `RentAnyNumberRequest.callback_url` | `RentNumberRequest.event_destination_target`, `RentAnyNumberRequest.event_destination_target`, `UpdateNumberConfigurationRequest.event_destination_target` | +| `ActiveNumber` has no callback field | `ActiveNumber.event_destination_target` (response) | + +**Example:** + +```python +# Old +sinch_client.numbers.available.rent_any( + region_code="US", + type_="LOCAL", + sms_configuration={...}, + voice_configuration={...}, + callback_url="https://example.com/events", +) + +# New +sinch_client.numbers.rent_any( + region_code="US", + number_type="LOCAL", + sms_configuration={...}, + voice_configuration={...}, + event_destination_target="https://example.com/events", +) +``` diff --git a/README.md b/README.md index 5b7c40c4..7d72bfc8 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ For more information on the Sinch APIs on which this SDK is based, refer to the - [Prerequisites](#prerequisites) - [Installation](#installation) - [Getting started](#getting-started) -- [Logging]() +- [Logging](#logging) ## Prerequisites @@ -35,47 +35,53 @@ For more information on the Sinch APIs on which this SDK is based, refer to the You can install this package by typing: `pip install sinch` +## Products + +The Sinch client provides access to the following Sinch products: +- Numbers API +- SMS API +- Conversation API (beta release) + + ## Getting started + ### Client initialization -To initialize communication with Sinch backed, credentials obtained from Sinch portal have to be provided to the main client class of this SDK. -It's highly advised to not hardcode those credentials, but to fetch them from environment variables: +To establish a connection with the Sinch backend, you must provide the appropriate credentials based on the API +you intend to use. For security best practices, avoid hardcoding credentials. +Instead, retrieve them from environment variables. + +#### SMS API +For the SMS API in **Australia (AU)**, **Brazil (BR)**, **Canada (CA)**, **the United States (US)**, +and **the European Union (EU)**, provide the following parameters: ```python from sinch import SinchClient sinch_client = SinchClient( - key_id="key_id", - key_secret="key_secret", - project_id="some_project", - application_key="application_key", - application_secret="application_secret" + service_plan_id="service_plan_id", + sms_api_token="api_token" ) ``` +#### All Other Sinch APIs +For all other Sinch APIs, including SMS in US and EU regions, use the following parameters: + ```python -import os from sinch import SinchClient sinch_client = SinchClient( - key_id=os.getenv("KEY_ID"), - key_secret=os.getenv("KEY_SECRET"), - project_id=os.getenv("PROJECT_ID"), - application_key=os.getenv("APPLICATION_KEY"), - application_secret=os.getenv("APPLICATION_SECRET") + project_id="project_id", + key_id="key_id", + key_secret="key_secret" ) ``` -## Products +### SMS and Conversation regions (V2) -Sinch client provides access to the following Sinch products: -- Numbers -- SMS -- Verification -- Voice API -- Conversation API (beta release) +You must set `sms_region` before using the SMS API and `conversation_region` before using the Conversation API—either in the `SinchClient(...)` constructor or on `sinch_client.configuration` before the first call to that product. See [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) for examples. ## Logging @@ -84,34 +90,26 @@ Logging configuration for this SDK utilizes following hierarchy: 2. If `logger_name` configurable was provided, SDK will use logger related to that name. For example: `myapp.sinch` will inherit configuration from the `myapp` logger. 3. If `logger` (logger instance) configurable was provided, SDK will use that particular logger for all its logging operations. -If all logging returned by this SDK needs to be disabled, usage of `NullHanlder` provided by the standard `logging` module is advised. +If all logging returned by this SDK needs to be disabled, usage of `NullHandler` provided by the standard `logging` module is advised. ## Sample apps -Usage example of the `numbers` domain: +Usage example of the Numbers API via [`VirtualNumbers`](sinch/domains/numbers/virtual_numbers.py) on the client (`sinch_client.numbers`)—`list()` returns your project’s active virtual numbers: ```python -available_numbers = sinch_client.numbers.available.list( +paginator = sinch_client.numbers.list( region_code="US", - number_type="LOCAL" + number_type="LOCAL", ) +for active_number in paginator.iterator(): + print(active_number) ``` -Returned values are represented as Python `dataclasses`: -```python -ListAvailableNumbersResponse( - available_numbers=[ - Number( - phone_number='+17862045855', - region_code='US', - type='LOCAL', - capability=['SMS', 'VOICE'], - setup_price={'currency_code': 'EUR', 'amount': '0.80'}, - monthly_price={'currency_code': 'EUR', 'amount': '0.80'} - ... -``` +Returned values are [Pydantic](https://docs.pydantic.dev/) model instances (for example [`ActiveNumber`](sinch/domains/numbers/models/v1/response/active_number.py)), including fields such as `phone_number`, `region_code`, `type`, and `capabilities`. + +More examples live under [examples/snippets](examples/snippets) on the `main` branch. ### Handling exceptions @@ -120,12 +118,12 @@ Each API throws a custom, API related exception for an unsuccessful backed call. Example for Numbers API: ```python -from sinch.domains.numbers.exceptions import NumbersException +from sinch.domains.numbers.api.v1.exceptions import NumbersException try: - nums = sinch_client.numbers.available.list( + paginator = sinch_client.numbers.list( region_code="US", - number_type="LOCAL" + number_type="LOCAL", ) except NumbersException as err: pass @@ -136,17 +134,15 @@ For handling all possible exceptions thrown by this SDK use `SinchException` (su ## Custom HTTP client implementation -By default, two HTTP implementations are provided: -- Synchronous using `requests` HTTP library -- Asynchronous using `httpx` HTTP library +By default, the HTTP implementation uses the `requests` library. -For creating custom HTTP client code, use either `SinchClient` or `SinchClientAsync` client and inject your transport during initialisation: +To use a custom HTTP client, inject your own transport during initialization: ```python -sinch_client = SinchClientAsync( - key_id="Spanish", - key_secret="Inquisition", +sinch_client = SinchClient( + key_id="key_id", + key_secret="key_secret", project_id="some_project", - transport=MyHTTPAsyncImplementation + transport=MyHTTPImplementation ) ``` @@ -157,6 +153,10 @@ class HTTPTransport(ABC): def request(self, endpoint: HTTPEndpoint) -> HTTPResponse: pass ``` + +Note: Asynchronous HTTP clients are not supported. +The transport must be a synchronous implementation. + ## License -This project is licensed under the Apache License. See the [LICENSE](license.md) file for the license text. +This project is licensed under the Apache License. See the [LICENSE](LICENSE) file for the license text. diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/.env.example b/examples/getting-started/conversation/send_handle_incoming_sms/.env.example new file mode 100644 index 00000000..af1dc362 --- /dev/null +++ b/examples/getting-started/conversation/send_handle_incoming_sms/.env.example @@ -0,0 +1,12 @@ +# Sinch credentials (from dashboard.sinch.com → Access Keys) +SINCH_PROJECT_ID= +SINCH_KEY_ID= +SINCH_KEY_SECRET= + +# Conversation API: existing app (already created and configured for SMS). +# SINCH_CONVERSATION_REGION is required. +# Set it to the same region as the one your app was created in (e.g. eu). +SINCH_CONVERSATION_REGION= + +# Server +SERVER_PORT=3001 diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/README.md b/examples/getting-started/conversation/send_handle_incoming_sms/README.md new file mode 100644 index 00000000..4e091b73 --- /dev/null +++ b/examples/getting-started/conversation/send_handle_incoming_sms/README.md @@ -0,0 +1,92 @@ +# Getting Started: Receive Mobile-originated (MO) SMS and send Mobile-terminated (MT) reply (Conversation API) + + +This directory contains a small server built with the [Sinch Python SDK](https://github.com/sinch/sinch-sdk-python) +that receives mobile-originated (MO) SMS on your Sinch number and sends a mobile-terminated (MT) SMS back +to the same phone. The reply echoes the incoming text (e.g. *"Your message said: <content of MO>"*) so you can +see that the MO was received and processed. + + + +## Requirements + +- [Python 3.9+](https://www.python.org/) +- [Flask](https://flask.palletsprojects.com/en/stable/) +- [Sinch account](https://dashboard.sinch.com/) +- An existing Conversation API app configured for SMS (with a Sinch number) +- [ngrok](https://ngrok.com/docs) (or similar) to expose your local server +- [Poetry](https://python-poetry.org/) + +## Configuration + +1. **Environment variables** + Copy [.env.example](.env.example) to `.env` in this directory, then set your credentials and app settings. + + - Sinch credentials (from the Sinch dashboard, Access Keys): + ``` + SINCH_PROJECT_ID=your_project_id + SINCH_KEY_ID=your_key_id + SINCH_KEY_SECRET=your_key_secret + ``` + + - Conversation API: set `SINCH_CONVERSATION_REGION` to the same region as the one your app was created in (e.g. `eu`). + ``` + SINCH_CONVERSATION_REGION= + ``` + + - Server port (optional; default 3001): + ``` + SERVER_PORT=3001 + ``` + +2. **Install dependencies** + From this directory: + ```bash + poetry install + ``` + Install the Sinch SDK from the **repository root**: `pip install -e .` (recommended when developing from this repo). + Alternatively, install with pip: `flask`, `python-dotenv`, and `sinch` (e.g. from PyPI). + +## Usage + +### Running the server + +1. Navigate to this directory: + ``` + cd examples/getting-started/conversation/send_handle_incoming_sms + ``` + + +2. Start the server: + ```bash + poetry run python server.py + ``` + Or run it directly: + ```bash + python server.py + ``` + +The server listens on the port set in your `.env` file (default: 3001). + +### Exposing the server with ngrok + +To receive Conversation API Sinch Events on your machine, expose the server with a tunnel (e.g. ngrok). + + +```bash +ngrok http 3001 +``` + +You will see output similar to: +``` +Forwarding https://abc123.ngrok-free.app -> http://localhost:3001 +``` + +Use the **HTTPS** URL when configuring the callback: +`https:///ConversationEvent` + +Configure this callback URL in the Sinch dashboard for your Conversation API app. + +### Sending an SMS to your Sinch number + +Send an SMS from your phone to the **Sinch number** linked to your Conversation API app. You should receive the echo reply on your phone. diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/controller.py b/examples/getting-started/conversation/send_handle_incoming_sms/controller.py new file mode 100644 index 00000000..0e187eee --- /dev/null +++ b/examples/getting-started/conversation/send_handle_incoming_sms/controller.py @@ -0,0 +1,22 @@ +from flask import request, Response +from server_business_logic import handle_conversation_event + + +class ConversationController: + def __init__(self, sinch_client): + self.sinch_client = sinch_client + self.logger = self.sinch_client.configuration.logger + + def conversation_event(self): + headers = dict(request.headers) + raw_body = getattr(request, "raw_body", None) or b"" + + sinch_events_service = self.sinch_client.conversation.sinch_events() + event = sinch_events_service.parse_event(raw_body, headers) + handle_conversation_event( + event=event, + logger=self.logger, + sinch_client=self.sinch_client, + ) + + return Response(status=200) diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/pyproject.toml b/examples/getting-started/conversation/send_handle_incoming_sms/pyproject.toml new file mode 100644 index 00000000..7d9661ea --- /dev/null +++ b/examples/getting-started/conversation/send_handle_incoming_sms/pyproject.toml @@ -0,0 +1,15 @@ +[tool.poetry] +name = "sinch-getting-started-send-handle-incoming-sms" +version = "0.1.0" +description = "Getting Started: send and handle incoming SMS with Conversation API (DISPATCH, channel identity)" +readme = "README.md" +package-mode = false + +[tool.poetry.dependencies] +python = "^3.9" +python-dotenv = "^1.0.0" +flask = "^3.0.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/server.py b/examples/getting-started/conversation/send_handle_incoming_sms/server.py new file mode 100644 index 00000000..f5805228 --- /dev/null +++ b/examples/getting-started/conversation/send_handle_incoming_sms/server.py @@ -0,0 +1,57 @@ +import logging +from pathlib import Path + + +from flask import Flask, request +from dotenv import dotenv_values + +from sinch import SinchClient +from controller import ConversationController + +app = Flask(__name__) + + +def load_config(): + current_dir = Path(__file__).resolve().parent + env_file = current_dir / ".env" + if not env_file.exists(): + raise FileNotFoundError(f"Missing .env in {current_dir}. Copy from .env.example.") + return dict(dotenv_values(env_file)) + + +config = load_config() +port = int(config.get("SERVER_PORT") or "3001") +conversation_region = (config.get("SINCH_CONVERSATION_REGION") or "").strip() +if not conversation_region: + raise ValueError( + "SINCH_CONVERSATION_REGION is required in .env to provide all parameters needed for Conversation API requests. " + "Set it to the same region as the one your Conversation API app was created in (e.g. eu)." + ) + +sinch_client = SinchClient( + project_id=config.get("SINCH_PROJECT_ID", ""), + key_id=config.get("SINCH_KEY_ID", ""), + key_secret=config.get("SINCH_KEY_SECRET", ""), + conversation_region=conversation_region, +) +logging.basicConfig() +sinch_client.configuration.logger.setLevel(logging.INFO) + +conversation_controller = ConversationController(sinch_client) + + +@app.before_request +def before_request(): + request.raw_body = request.get_data() + + +app.add_url_rule( + "/ConversationEvent", + methods=["POST"], + view_func=conversation_controller.conversation_event, +) + +if __name__ == "__main__": + print("Getting Started: MO SMS → MT reply (Conversation API, DISPATCH, channel identity)") + print(f"Listening on port {port}. Expose with: ngrok http {port}") + app.run(port=port) diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/server_business_logic.py b/examples/getting-started/conversation/send_handle_incoming_sms/server_business_logic.py new file mode 100644 index 00000000..6ed9986a --- /dev/null +++ b/examples/getting-started/conversation/send_handle_incoming_sms/server_business_logic.py @@ -0,0 +1,53 @@ +""" +On inbound SMS (MO), send a reply (MT) to the same number: "Your message said: ". +Uses channel identity (SMS + phone number) only; app is in DISPATCH mode. +""" + +from sinch.domains.conversation.models.v1.sinch_events import MessageInboundEvent + + +def handle_conversation_event(event, logger, sinch_client): + """Sinch Event entry: handle only MESSAGE_INBOUND; delegate to inbound handler.""" + if not isinstance(event, MessageInboundEvent): + return + _handle_message_inbound(event, logger, sinch_client) + + +def _get_mo_text(event: MessageInboundEvent) -> str: + """Return the inbound message text, or a short placeholder if none.""" + msg = event.message + contact_msg = msg.contact_message + if getattr(contact_msg, "text_message", None): + return contact_msg.text_message.text or "(empty)" + return "(no text content)" + + +def _handle_message_inbound(event: MessageInboundEvent, logger, sinch_client): + """Parse MO, then send MT echo to the same number via Conversation API.""" + msg = event.message + channel_identity = msg.channel_identity + if not channel_identity: + logger.warning("MESSAGE_INBOUND with no channel_identity") + return + + identity = channel_identity.identity + mo_text = _get_mo_text(event) + logger.info("MO SMS from %s: %s", identity, mo_text) + + app_id = event.app_id + if not app_id: + logger.warning("Event has no app_id; skipping MT reply.") + return + + reply_text = f"Your message said: {mo_text}" + response = sinch_client.conversation.messages.send_text_message( + app_id=app_id, + text=reply_text, + recipient_identities=[{"channel": "SMS", "identity": identity}], + ) + logger.info("MT reply sent to %s (channel identity): %s", identity, reply_text[:60]) + logger.debug( + "Response: message_id=%s accepted_time=%s", + response.message_id, + response.accepted_time + ) diff --git a/examples/sinch_events/.env.example b/examples/sinch_events/.env.example new file mode 100644 index 00000000..02133356 --- /dev/null +++ b/examples/sinch_events/.env.example @@ -0,0 +1,11 @@ +# Server Configuration +SERVER_PORT = + +# Sinch Event Configuration +# The secret value used for Sinch Event callback validation +# See https://developers.sinch.com/docs/numbers/api-reference/numbers/tag/Numbers-Callbacks/ +NUMBERS_SINCH_EVENT_SECRET = NUMBERS_SINCH_EVENT_SECRET +# See https://developers.sinch.com/docs/sms/api-reference/sms/tag/Webhooks/#tag/Webhooks/section/Callbacks +SMS_SINCH_EVENT_SECRET = SMS_SINCH_EVENT_SECRET +# See https://developers.sinch.com/docs/conversation/callbacks +CONVERSATION_SINCH_EVENT_SECRET = CONVERSATION_SINCH_EVENT_SECRET \ No newline at end of file diff --git a/examples/sinch_events/README.md b/examples/sinch_events/README.md new file mode 100644 index 00000000..1523eb86 --- /dev/null +++ b/examples/sinch_events/README.md @@ -0,0 +1,113 @@ +# Sinch Events Handlers for Sinch Python SDK + +This directory contains a server application built with [Sinch Python SDK](https://github.com/sinch/sinch-sdk-python) +to process incoming events from Sinch services. + +The Sinch Events Handlers are organized by service: +- **SMS**: Handlers for SMS events (`sms_api/`) +- **Numbers**: Handlers for Numbers API events (`numbers_api/`) +- **Conversation**: Handlers for Conversation API events (`conversation_api/`) + +This directory contains both the Event handlers and the server application (`server.py`) that uses them. + +## Requirements + +- [Python 3.9+](https://www.python.org/) +- [Flask](https://flask.palletsprojects.com/en/stable/) +- [Sinch account](https://dashboard.sinch.com/) +- [ngrok](https://ngrok.com/docs) +- [Poetry](https://python-poetry.org/) + +## Configuration + +1. **Environment Variables**: + Rename [.env.example](.env.example) to `.env` in this directory (`examples/sinch_events/`), then add your credentials from the Sinch dashboard under the Access Keys section. + + - Server Port: + Define the port your server will listen to on (default: 3001): + ``` + SERVER_PORT=3001 + ``` + + - Controller Settings + - Numbers controller: Set the `numbers` Sinch Event secret. You can retrieve it using the `/event_destination` endpoint (see SDK implementation: [event_destinations_apis.py](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/numbers/api/v1/event_destinations_apis.py); for additional details, refer to the [Numbers API callbacks documentation](https://developers.sinch.com/docs/numbers/api-reference/numbers/tag/Numbers-Callbacks/)): + ``` + NUMBERS_SINCH_EVENT_SECRET=Your Sinch Numbers Sinch Event Secret + ``` + - SMS controller: To configure the `sms` Sinch Event secret, contact your account manager to enable authentication for SMS callbacks. For more details, refer to + [SMS API](https://developers.sinch.com/docs/sms/api-reference/sms/tag/Webhooks/#tag/Webhooks/section/Callbacks), + + ``` + SMS_SINCH_EVENT_SECRET=Your Sinch SMS Sinch Event Secret + ``` + - Conversation controller: Set the Sinch Event secret you configured for your Conversation app event destination (see [Conversation API callbacks](https://developers.sinch.com/docs/conversation/callbacks)): + ``` + CONVERSATION_SINCH_EVENT_SECRET=Your Conversation Sinch Event Secret + ``` + +## Usage + +### Running the server application + +1. Navigate to the examples events directory: +``` + cd examples/sinch_events +``` + +2. Install the project dependencies: +``` bash + poetry install +``` + +3. Start the server: +``` bash + poetry run python server.py +``` +Or run it directly: +``` bash + python server.py +``` + +The server will start on the port specified in your `.env` file (default: 3001). + +### Endpoints + +The server exposes the following endpoints: + +| Service | Endpoint | +|--------------|----------------------| +| Numbers | /NumbersEvent | +| SMS | /SmsEvent | +| Conversation | /ConversationEvent | + +## Using ngrok to expose your local server + +To test your "Sinch Events" processing locally, you can tunnel requests to your local server using ngrok. + +*Note: The default port is `3001`, but this can be changed (see [Server port](#Configuration))* + +```bash + ngrok http 3001 +``` + +You'll see output similar to this: +``` +ngrok (Ctrl+C to quit) +... +Forwarding https://adbd-79-148-170-158.ngrok-free.app -> http://localhost:3001 +``` +Use the `https` forwarding URL in your event destination 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 Sinch Events URLs: +- **Numbers**: Set the `event_destination_target` 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 `event_destination_target` 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 Sinch Events URLs in the Sinch dashboard; the API parameters above override the default values configured there. + +> **Note**: If you have set a Sinch Event secret (e.g., `SMS_SINCH_EVENT_SECRET`), the Sinch Event URL must be configured in the Sinch dashboard +> and cannot be overridden via API parameters. The Sinch Event secret is used to validate incoming Sinch Events requests, +> and the URL associated with it must be set in the dashboard. diff --git a/sinch/domains/conversation/endpoints/__init__.py b/examples/sinch_events/conversation_api/__init__.py similarity index 100% rename from sinch/domains/conversation/endpoints/__init__.py rename to examples/sinch_events/conversation_api/__init__.py diff --git a/examples/sinch_events/conversation_api/controller.py b/examples/sinch_events/conversation_api/controller.py new file mode 100644 index 00000000..5ac8c2c7 --- /dev/null +++ b/examples/sinch_events/conversation_api/controller.py @@ -0,0 +1,32 @@ +from flask import request, Response +from sinch_events.conversation_api.server_business_logic import handle_conversation_event + + +class ConversationController: + def __init__(self, sinch_client, sinch_event_secret): + self.sinch_client = sinch_client + self.sinch_event_secret = sinch_event_secret + self.logger = self.sinch_client.configuration.logger + + def conversation_event(self): + headers = dict(request.headers) + raw_body = request.raw_body if request.raw_body else b"" + + sinch_events_service = self.sinch_client.conversation.sinch_events( + self.sinch_event_secret + ) + + # Set to True to enforce signature validation (recommended in production) + ensure_valid_signature = False + if ensure_valid_signature: + valid = sinch_events_service.validate_authentication_header( + headers=headers, + json_payload=raw_body, + ) + if not valid: + return Response(status=401) + + event = sinch_events_service.parse_event(raw_body, headers) + handle_conversation_event(event=event, logger=self.logger) + + return Response(status=200) diff --git a/examples/sinch_events/conversation_api/server_business_logic.py b/examples/sinch_events/conversation_api/server_business_logic.py new file mode 100644 index 00000000..57b7ede4 --- /dev/null +++ b/examples/sinch_events/conversation_api/server_business_logic.py @@ -0,0 +1,81 @@ +from sinch.domains.conversation.models.v1.sinch_events import ( + ConversationSinchEventBase, + MessageDeliveryReceiptEvent, + MessageInboundEvent, + MessageSubmitEvent, +) + + +def handle_conversation_event(event: ConversationSinchEventBase, logger): + """ + Dispatch a Conversation Sinch Event to the appropriate handler by trigger type. + + :param event: Parsed Sinch Event (MessageDeliveryReceiptEvent, MessageInboundEvent, etc.). + :param logger: Logger instance for output. + """ + if isinstance(event, MessageInboundEvent): + _handle_message_inbound(event, logger) + elif isinstance(event, MessageDeliveryReceiptEvent): + _handle_message_delivery(event, logger) + elif isinstance(event, MessageSubmitEvent): + _handle_message_submit(event, logger) + else: + logger.debug("Event: %s", event.model_dump_json(indent=2) if hasattr(event, "model_dump_json") else event) + + +def _handle_message_inbound(event: MessageInboundEvent, logger): + """Handle MESSAGE_INBOUND: log inbound message.""" + logger.info("## MESSAGE_INBOUND") + msg = event.message + contact_msg = msg.contact_message + channel_identity = msg.channel_identity + contact_id = msg.contact_id + channel = channel_identity.channel if channel_identity else "?" + identity = channel_identity.identity if channel_identity else "?" + logger.info( + "A new message has been received on the channel '%s' (identity: %s) from the contact ID '%s'", + channel, + identity, + contact_id, + ) + if contact_msg: + if hasattr(contact_msg, "text_message") and contact_msg.text_message: + logger.info("Text: %s", contact_msg.text_message.text) + elif hasattr(contact_msg, "media_message") and contact_msg.media_message: + logger.info("Media: %s", getattr(contact_msg.media_message, "url", contact_msg.media_message)) + elif hasattr(contact_msg, "fallback_message") and contact_msg.fallback_message: + logger.info("Fallback: %s", contact_msg.fallback_message) + else: + logger.info("Contact message: %s", contact_msg) + + +def _handle_message_delivery(event: MessageDeliveryReceiptEvent, logger): + """Handle MESSAGE_DELIVERY: log delivery status and failure reason if failed.""" + logger.info("## MESSAGE_DELIVERY") + report = event.message_delivery_report + status = report.status + logger.info("Message delivery status: '%s'", status) + if status == "FAILED" and report.reason: + logger.info( + "Reason: %s (%s) - %s", + report.reason.code, + getattr(report.reason, "sub_code", ""), + report.reason.description, + ) + + +def _handle_message_submit(event: MessageSubmitEvent, logger): + """Handle MESSAGE_SUBMIT: log that the message was submitted to the channel.""" + logger.info("## MESSAGE_SUBMIT") + submit_notification = event.message_submit_notification + channel_identity = submit_notification.channel_identity + channel = channel_identity.channel if channel_identity else "?" + identity = channel_identity.identity if channel_identity else "?" + logger.info( + "The following message has been submitted on the channel '%s' (identity: %s) to the contact ID '%s'", + channel, + identity, + submit_notification.contact_id, + ) + if submit_notification.submitted_message: + logger.debug("Submitted message: %s", submit_notification.submitted_message) diff --git a/sinch/domains/conversation/endpoints/app/__init__.py b/examples/sinch_events/numbers_api/__init__.py similarity index 100% rename from sinch/domains/conversation/endpoints/app/__init__.py rename to examples/sinch_events/numbers_api/__init__.py diff --git a/examples/sinch_events/numbers_api/controller.py b/examples/sinch_events/numbers_api/controller.py new file mode 100644 index 00000000..cdc43e22 --- /dev/null +++ b/examples/sinch_events/numbers_api/controller.py @@ -0,0 +1,33 @@ +from flask import request, Response +from sinch_events.numbers_api.server_business_logic import handle_numbers_event + + +class NumbersController: + def __init__(self, sinch_client, sinch_event_secret): + self.sinch_client = sinch_client + self.sinch_event_secret = sinch_event_secret + self.logger = self.sinch_client.configuration.logger + + def numbers_event(self): + headers = dict(request.headers) + raw_body = request.raw_body if request.raw_body else b"" + + sinch_events_service = self.sinch_client.numbers.sinch_events( + self.sinch_event_secret + ) + + ensure_valid_authentication = False + if ensure_valid_authentication: + valid_auth = sinch_events_service.validate_authentication_header( + headers=headers, + json_payload=raw_body, + ) + + if not valid_auth: + return Response(status=401) + + event = sinch_events_service.parse_event(raw_body, headers) + + handle_numbers_event(numbers_event=event, logger=self.logger) + + return Response(status=200) diff --git a/examples/sinch_events/numbers_api/server_business_logic.py b/examples/sinch_events/numbers_api/server_business_logic.py new file mode 100644 index 00000000..305772ce --- /dev/null +++ b/examples/sinch_events/numbers_api/server_business_logic.py @@ -0,0 +1,11 @@ +from sinch.domains.numbers.sinch_events.v1.events import NumberSinchEvent + + +def handle_numbers_event(numbers_event: NumberSinchEvent, logger): + """ + This method handles a Numbers event. + Args: + numbers_event (NumberSinchEvent): The Numbers event data. + logger (logging.Logger, optional): Logger instance for logging. Defaults to None. + """ + logger.info(f'Handling Numbers event:\n{numbers_event.model_dump_json(indent=2)}') diff --git a/examples/sinch_events/pyproject.toml b/examples/sinch_events/pyproject.toml new file mode 100644 index 00000000..4fb38639 --- /dev/null +++ b/examples/sinch_events/pyproject.toml @@ -0,0 +1,16 @@ +[tool.poetry] +name = "sinch-sdk-python-quickstart-server" +version = "0.1.0" +description = "Sinch SDK Python Quickstart Sinch Events Server" +readme = "README.md" +package-mode = false + +[tool.poetry.dependencies] +python = "^3.9" +python-dotenv = "^1.0.0" +flask = "^3.0.0" +sinch = "^2.0.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/examples/sinch_events/server.py b/examples/sinch_events/server.py new file mode 100644 index 00000000..368cec68 --- /dev/null +++ b/examples/sinch_events/server.py @@ -0,0 +1,47 @@ +import logging +import sys +from pathlib import Path + +# Add examples directory to Python path to allow importing sinch_events +examples_dir = Path(__file__).resolve().parent.parent +if str(examples_dir) not in sys.path: + sys.path.insert(0, str(examples_dir)) + +from flask import Flask, request +from sinch_events.numbers_api.controller import NumbersController +from sinch_events.sms_api.controller import SmsController +from sinch_events.conversation_api.controller import ConversationController +from sinch_events.sinch_client_helper import get_sinch_client, load_config + +app = Flask(__name__) + +config = load_config() +port = int(config.get('SERVER_PORT') or 3001) +numbers_sinch_event_secret = config.get('NUMBERS_SINCH_EVENT_SECRET') +sms_sinch_event_secret = config.get('SMS_SINCH_EVENT_SECRET') +conversation_sinch_event_secret = config.get('CONVERSATION_SINCH_EVENT_SECRET') +sinch_client = get_sinch_client(config) + +# Set up logging at the INFO level +logging.basicConfig() +sinch_client.configuration.logger.setLevel(logging.INFO) + +numbers_controller = NumbersController(sinch_client, numbers_sinch_event_secret) +sms_controller = SmsController(sinch_client, sms_sinch_event_secret) +conversation_controller = ConversationController( + sinch_client, conversation_sinch_event_secret or '' +) + + +# Middleware to capture raw body +@app.before_request +def before_request(): + request.raw_body = request.get_data() + + +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/examples/sinch_events/sinch_client_helper.py b/examples/sinch_events/sinch_client_helper.py new file mode 100644 index 00000000..fdc662ab --- /dev/null +++ b/examples/sinch_events/sinch_client_helper.py @@ -0,0 +1,34 @@ +from pathlib import Path +from sinch import SinchClient +from dotenv import dotenv_values + + +def load_config() -> dict[str, str]: + """ + Load configuration from the .env file in the sinch_events directory. + + Returns: + dict[str, str]: Dictionary containing configuration values + """ + # Get the directory where this file is located + current_dir = Path(__file__).resolve().parent + env_file = current_dir / '.env' + + if not env_file.exists(): + raise FileNotFoundError(f"Could not find .env file in sinch_events directory: {env_file}") + + config_dict = dotenv_values(env_file) + + return config_dict + + +def get_sinch_client(config: dict) -> SinchClient: + """ + Create and return a configured SinchClient instance. + + Args: + config (dict): Dictionary containing configuration values + Returns: + SinchClient: Configured Sinch client instance + """ + return SinchClient() diff --git a/sinch/domains/conversation/endpoints/contact/__init__.py b/examples/sinch_events/sms_api/__init__.py similarity index 100% rename from sinch/domains/conversation/endpoints/contact/__init__.py rename to examples/sinch_events/sms_api/__init__.py diff --git a/examples/sinch_events/sms_api/controller.py b/examples/sinch_events/sms_api/controller.py new file mode 100644 index 00000000..2ebfda6c --- /dev/null +++ b/examples/sinch_events/sms_api/controller.py @@ -0,0 +1,36 @@ +from flask import request, Response +from sinch_events.sms_api.server_business_logic import ( + handle_sms_event, +) + + +class SmsController: + def __init__(self, sinch_client, sinch_event_secret): + self.sinch_client = sinch_client + self.sinch_event_secret = sinch_event_secret + self.logger = self.sinch_client.configuration.logger + + def sms_event(self): + headers = dict(request.headers) + raw_body = request.raw_body if request.raw_body else b"" + + sinch_events_service = self.sinch_client.sms.sinch_events(self.sinch_event_secret) + + # Signature headers may be absent unless your account manager enables them + # (see README: Configuration -> Controller Settings -> SMS controller); + # leave auth disabled here unless SMS callbacks are configured. + ensure_valid_authentication = False + if ensure_valid_authentication: + valid_auth = sinch_events_service.validate_authentication_header( + headers=headers, + json_payload=raw_body, + ) + + if not valid_auth: + return Response(status=401) + + event = sinch_events_service.parse_event(raw_body, headers) + + handle_sms_event(sms_event=event, logger=self.logger) + + return Response(status=200) diff --git a/examples/sinch_events/sms_api/server_business_logic.py b/examples/sinch_events/sms_api/server_business_logic.py new file mode 100644 index 00000000..7061394d --- /dev/null +++ b/examples/sinch_events/sms_api/server_business_logic.py @@ -0,0 +1,13 @@ +from sinch.domains.sms.sinch_events.v1.events.sms_sinch_event import ( + IncomingSMSSinchEvent, +) + + +def handle_sms_event(sms_event: IncomingSMSSinchEvent, logger): + """ + This method handles an SMS event. + Args: + sms_event (IncomingSMSSinchEvent): The SMS event data. + logger (logging.Logger, optional): Logger instance for logging. Defaults to None. + """ + logger.info(f'Handling SMS event:\n{sms_event.model_dump_json(indent=2)}') diff --git a/examples/snippets/.env.example b/examples/snippets/.env.example new file mode 100644 index 00000000..e4a62a3d --- /dev/null +++ b/examples/snippets/.env.example @@ -0,0 +1,15 @@ +# The project ID where are defined the resources you want to use. +SINCH_PROJECT_ID= + +# The API key ID and secret to authenticate your requests to the Sinch API. +SINCH_KEY_ID= +SINCH_KEY_SECRET= + +# The virtual phone number you have rented from Sinch or planning to rent. +SINCH_PHONE_NUMBER= + +# The service plan ID for your Sinch account to configure the SMS plan associated with your virtual phone number. +SINCH_SERVICE_PLAN_ID= + +# The SMS region code. See https://developers.sinch.com/docs/sms/api-reference/#base-url for available regions +SINCH_SMS_REGION= \ No newline at end of file diff --git a/examples/snippets/README.md b/examples/snippets/README.md new file mode 100644 index 00000000..b5dd5e2f --- /dev/null +++ b/examples/snippets/README.md @@ -0,0 +1,61 @@ +# sinch-sdk-python-snippets + +Sinch Python SDK Code Snippets + +This directory contains code snippets demonstrating usage of the +[Sinch Python SDK](https://github.com/sinch/sinch-sdk-python). + +## Requirements +- Python 3.9 or later +- [Poetry](https://python-poetry.org/) for dependency management +- [Sinch account](https://dashboard.sinch.com) +- [Sinch package](https://pypi.org/project/sinch/) + + +## Snippets execution settings +When executing a snippet, you will need to provide some information about your Sinch account (credentials, Sinch virtual phone number, ...) + +These settings can be placed directly in the snippet source code, **or** you can use an environment file (`.env`). Using an environment file allows the settings to be shared and used automatically by every snippet. + +### Setting Up Your Environment File + +#### 1. Rename the example file + +**Linux / Mac:** +```bash +cp .env.example .env +``` + +**Windows (Command Prompt):** +```cmd +copy .env.example .env +``` + +Windows (PowerShell): +```powershell +Copy-Item .env.example .env +``` + +#### 2. Fill in your credentials + +Open the newly created [.env](.env) file in your preferred text editor and fill in the required values (e.g., SINCH_PROJECT_ID=your_project_id). + +Note: Do not share your .env file or credentials publicly. + + +### Install dependencies using Poetry: + +```bash +poetry install +``` + + +## Running snippets + +All available code snippets are located in subdirectories, structured by feature and corresponding actions (e.g., `numbers/`, `sms/`). + +To execute a specific snippet, navigate to the appropriate subdirectory and run: + +```shell +python snippet.py +``` \ No newline at end of file diff --git a/examples/snippets/conversation/messages/delete/snippet.py b/examples/snippets/conversation/messages/delete/snippet.py new file mode 100644 index 00000000..b67d1a9f --- /dev/null +++ b/examples/snippets/conversation/messages/delete/snippet.py @@ -0,0 +1,25 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the message to delete +message_id = "MESSAGE_ID" + +sinch_client.conversation.messages.delete(message_id=message_id) + +print("Message deleted successfully") diff --git a/examples/snippets/conversation/messages/get/snippet.py b/examples/snippets/conversation/messages/get/snippet.py new file mode 100644 index 00000000..7e9ff953 --- /dev/null +++ b/examples/snippets/conversation/messages/get/snippet.py @@ -0,0 +1,25 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the message to retrieve +message_id = "MESSAGE_ID" + +response = sinch_client.conversation.messages.get(message_id=message_id) + +print(f"Message details:\n{response}") diff --git a/examples/snippets/conversation/messages/list/snippet.py b/examples/snippets/conversation/messages/list/snippet.py new file mode 100644 index 00000000..a10dd293 --- /dev/null +++ b/examples/snippets/conversation/messages/list/snippet.py @@ -0,0 +1,35 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the Conversation App to list messages from +app_id = "CONVERSATION_APP_ID" + +messages = sinch_client.conversation.messages.list( + app_id=app_id, +) + +page_counter = 1 +while True: + print(f"Page {page_counter} List of Messages: {messages}") + + if not messages.has_next_page: + break + + messages = messages.next_page() + page_counter += 1 diff --git a/examples/snippets/conversation/messages/list_last_messages_by_channel_identity/snippet.py b/examples/snippets/conversation/messages/list_last_messages_by_channel_identity/snippet.py new file mode 100644 index 00000000..7cde3ee9 --- /dev/null +++ b/examples/snippets/conversation/messages/list_last_messages_by_channel_identity/snippet.py @@ -0,0 +1,35 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The channel identities to fetch the last message for +channel_identities = ["CHANNEL_IDENTITY_1", "CHANNEL_IDENTITY_2"] + +messages = sinch_client.conversation.messages.list_last_messages_by_channel_identity( + channel_identities=channel_identities, +) + +page_counter = 1 +while True: + print(f"Page {page_counter} Last messages: {messages}") + + if not messages.has_next_page: + break + + messages = messages.next_page() + page_counter += 1 diff --git a/examples/snippets/conversation/messages/send/snippet.py b/examples/snippets/conversation/messages/send/snippet.py new file mode 100644 index 00000000..1798eb99 --- /dev/null +++ b/examples/snippets/conversation/messages/send/snippet.py @@ -0,0 +1,43 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the Conversation App to send the message from +app_id = "CONVERSATION_APP_ID" +# The phone number of the recipient in E.164 format (e.g. +46701234567) +recipient_identities = [ + { + "channel": "RCS", + "identity": "RECIPIENT_PHONE_NUMBER" + } +] + +# The conversation message payload to send +message = { + "text_message": { + "text": "[Python SDK: Conversation Message] Sample text message", + }, +} + +response = sinch_client.conversation.messages.send( + app_id=app_id, + message=message, + recipient_identities=recipient_identities, +) + +print(f"Successfully sent message.\n{response}") diff --git a/examples/snippets/conversation/messages/send_card_message/snippet.py b/examples/snippets/conversation/messages/send_card_message/snippet.py new file mode 100644 index 00000000..03a6496c --- /dev/null +++ b/examples/snippets/conversation/messages/send_card_message/snippet.py @@ -0,0 +1,45 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the Conversation App to send the message from +app_id = "CONVERSATION_APP_ID" +# The phone number of the recipient in E.164 format (e.g. +46701234567) +recipient_identities = [ + { + "channel": "RCS", + "identity": "RECIPIENT_PHONE_NUMBER" + } +] + +card_message = { + "title": "Card title", + "description": "Optional card description", + "choices": [ + {"text_message": {"text": "Yes"}, "postback_data": "yes"}, + {"text_message": {"text": "No"}, "postback_data": "no"}, + ] +} + +response = sinch_client.conversation.messages.send_card_message( + app_id=app_id, + card_message=card_message, + recipient_identities=recipient_identities +) + +print(f"Successfully sent card message.\n{response}") diff --git a/examples/snippets/conversation/messages/send_carousel_message/snippet.py b/examples/snippets/conversation/messages/send_carousel_message/snippet.py new file mode 100644 index 00000000..92f5f7ec --- /dev/null +++ b/examples/snippets/conversation/messages/send_carousel_message/snippet.py @@ -0,0 +1,51 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the Conversation App to send the message from +app_id = "CONVERSATION_APP_ID" +# The phone number of the recipient in E.164 format (e.g. +46701234567) +recipient_identities = [ + { + "channel": "RCS", + "identity": "RECIPIENT_PHONE_NUMBER" + } +] + +carousel_message = { + "cards": [ + { + "title": "Card 1", + "description": "First card description", + "choices": [{"text_message": {"text": "Option 1"}}], + }, + { + "title": "Card 2", + "description": "Second card description", + "choices": [{"url_message": {"title": "Link", "url": "https://example.com"}}], + }, + ], +} + +response = sinch_client.conversation.messages.send_carousel_message( + app_id=app_id, + carousel_message=carousel_message, + recipient_identities=recipient_identities +) + +print(f"Successfully sent carousel message.\n{response}") diff --git a/examples/snippets/conversation/messages/send_choice_message/snippet.py b/examples/snippets/conversation/messages/send_choice_message/snippet.py new file mode 100644 index 00000000..5db53bfe --- /dev/null +++ b/examples/snippets/conversation/messages/send_choice_message/snippet.py @@ -0,0 +1,44 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the Conversation App to send the message from +app_id = "CONVERSATION_APP_ID" +# The phone number of the recipient in E.164 format (e.g. +46701234567) +recipient_identities = [ + { + "channel": "RCS", + "identity": "RECIPIENT_PHONE_NUMBER" + } +] + +choice_message = { + "text_message": {"text": "Choose an option:"}, + "choices": [ + {"text_message": {"text": "Option A"}, "postback_data": "option_a"}, + {"text_message": {"text": "Option B"}, "postback_data": "option_b"}, + ], +} + +response = sinch_client.conversation.messages.send_choice_message( + app_id=app_id, + choice_message=choice_message, + recipient_identities=recipient_identities +) + +print(f"Successfully sent choice message.\n{response}") diff --git a/examples/snippets/conversation/messages/send_contact_info_message/snippet.py b/examples/snippets/conversation/messages/send_contact_info_message/snippet.py new file mode 100644 index 00000000..4f3cffa4 --- /dev/null +++ b/examples/snippets/conversation/messages/send_contact_info_message/snippet.py @@ -0,0 +1,41 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the Conversation App to send the message from +app_id = "CONVERSATION_APP_ID" +# The phone number of the recipient in E.164 format (e.g. +46701234567) +recipient_identities = [ + { + "channel": "RCS", + "identity": "RECIPIENT_PHONE_NUMBER" + } +] + +contact_info_message = { + "name": {"full_name": "John Doe"}, + "phone_numbers": [{"phone_number": "+1234567890"}], +} + +response = sinch_client.conversation.messages.send_contact_info_message( + app_id=app_id, + contact_info_message=contact_info_message, + recipient_identities=recipient_identities +) + +print(f"Successfully sent contact info message.\n{response}") diff --git a/examples/snippets/conversation/messages/send_list_message/snippet.py b/examples/snippets/conversation/messages/send_list_message/snippet.py new file mode 100644 index 00000000..8b807aa1 --- /dev/null +++ b/examples/snippets/conversation/messages/send_list_message/snippet.py @@ -0,0 +1,50 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the Conversation App to send the message from +app_id = "CONVERSATION_APP_ID" +# The phone number of the recipient in E.164 format (e.g. +46701234567) +recipient_identities = [ + { + "channel": "RCS", + "identity": "RECIPIENT_PHONE_NUMBER" + } +] + +list_message = { + "title": "Choose an option", + "description": "Select from the list below", + "sections": [ + { + "title": "Section 1", + "items": [ + {"choice": {"title": "Option A", "postback_data": "option_a"}}, + {"choice": {"title": "Option B", "postback_data": "option_b"}}, + ], + }, + ], +} + +response = sinch_client.conversation.messages.send_list_message( + app_id=app_id, + list_message=list_message, + recipient_identities=recipient_identities +) + +print(f"Successfully sent list message.\n{response}") diff --git a/examples/snippets/conversation/messages/send_location_message/snippet.py b/examples/snippets/conversation/messages/send_location_message/snippet.py new file mode 100644 index 00000000..0b8b3426 --- /dev/null +++ b/examples/snippets/conversation/messages/send_location_message/snippet.py @@ -0,0 +1,41 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the Conversation App to send the message from +app_id = "CONVERSATION_APP_ID" +# The phone number of the recipient in E.164 format (e.g. +46701234567) +recipient_identities = [ + { + "channel": "RCS", + "identity": "RECIPIENT_PHONE_NUMBER" + } +] + +location_message = { + "title": "Our office", + "coordinates": {"latitude": 59.3293, "longitude": 18.0686}, +} + +response = sinch_client.conversation.messages.send_location_message( + app_id=app_id, + location_message=location_message, + recipient_identities=recipient_identities +) + +print(f"Successfully sent location message.\n{response}") diff --git a/examples/snippets/conversation/messages/send_media_message/snippet.py b/examples/snippets/conversation/messages/send_media_message/snippet.py new file mode 100644 index 00000000..ca977797 --- /dev/null +++ b/examples/snippets/conversation/messages/send_media_message/snippet.py @@ -0,0 +1,40 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the Conversation App to send the message from +app_id = "CONVERSATION_APP_ID" +# The phone number of the recipient in E.164 format (e.g. +46701234567) +recipient_identities = [ + { + "channel": "RCS", + "identity": "RECIPIENT_PHONE_NUMBER" + } +] + +media_message = { + "url": "https://example.com/image.jpg", +} + +response = sinch_client.conversation.messages.send_media_message( + app_id=app_id, + media_message=media_message, + recipient_identities=recipient_identities +) + +print(f"Successfully sent media message.\n{response}") diff --git a/examples/snippets/conversation/messages/send_template_message/snippet.py b/examples/snippets/conversation/messages/send_template_message/snippet.py new file mode 100644 index 00000000..c6b19843 --- /dev/null +++ b/examples/snippets/conversation/messages/send_template_message/snippet.py @@ -0,0 +1,43 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the Conversation App to send the message from +app_id = "CONVERSATION_APP_ID" +# The phone number of the recipient in E.164 format (e.g. +46701234567) +recipient_identities = [ + { + "channel": "RCS", + "identity": "RECIPIENT_PHONE_NUMBER" + } +] + +template_message = { + "omni_template": { + "template_id": "TEMPLATE_ID", + "version": "1", + }, +} + +response = sinch_client.conversation.messages.send_template_message( + app_id=app_id, + template_message=template_message, + recipient_identities=recipient_identities +) + +print(f"Successfully sent template message.\n{response}") diff --git a/examples/snippets/conversation/messages/send_text_message/snippet.py b/examples/snippets/conversation/messages/send_text_message/snippet.py new file mode 100644 index 00000000..fa6431c5 --- /dev/null +++ b/examples/snippets/conversation/messages/send_text_message/snippet.py @@ -0,0 +1,36 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the Conversation App to send the message from +app_id = "CONVERSATION_APP_ID" +# The phone number of the recipient in E.164 format (e.g. +46701234567) +recipient_identities = [ + { + "channel": "SMS", + "identity": "RECIPIENT_PHONE_NUMBER" + } +] + +response = sinch_client.conversation.messages.send_text_message( + app_id=app_id, + text="[Python SDK: Conversation] Sample text message", + recipient_identities=recipient_identities +) + +print(f"Successfully sent text message.\n{response}") diff --git a/examples/snippets/conversation/messages/update/snippet.py b/examples/snippets/conversation/messages/update/snippet.py new file mode 100644 index 00000000..b6f89a45 --- /dev/null +++ b/examples/snippets/conversation/messages/update/snippet.py @@ -0,0 +1,30 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the message to update +message_id = "MESSAGE_ID" +# The metadata string to set on the message +metadata = "MESSAGE_METADATA" + +response = sinch_client.conversation.messages.update( + message_id=message_id, + metadata=metadata, +) + +print(f"Updated message:\n{response}") diff --git a/examples/snippets/number_lookup/lookup/snippet.py b/examples/snippets/number_lookup/lookup/snippet.py new file mode 100644 index 00000000..98eec221 --- /dev/null +++ b/examples/snippets/number_lookup/lookup/snippet.py @@ -0,0 +1,24 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", +) + +# The phone number to look up in E.164 format (e.g. +1234567890) +phone_number = "PHONE_NUMBER" + +response = sinch_client.number_lookup.lookup(number=phone_number) + +print(f"Number lookup result:\n{response}") diff --git a/examples/snippets/numbers/active_numbers/get/snippet.py b/examples/snippets/numbers/active_numbers/get/snippet.py new file mode 100644 index 00000000..5787fd90 --- /dev/null +++ b/examples/snippets/numbers/active_numbers/get/snippet.py @@ -0,0 +1,24 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" +) + +# The active phone number to retrieve details for in E.164 format +phone_number = os.environ.get("SINCH_PHONE_NUMBER") or "MY_PHONE_NUMBER" + +response = sinch_client.numbers.get(phone_number=phone_number) + +print(f"Rented number details:\n{response}") diff --git a/examples/snippets/numbers/active_numbers/list/snippet.py b/examples/snippets/numbers/active_numbers/list/snippet.py new file mode 100644 index 00000000..65cfc4f7 --- /dev/null +++ b/examples/snippets/numbers/active_numbers/list/snippet.py @@ -0,0 +1,32 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" +) + +active_numbers = sinch_client.numbers.list( + region_code="US", + number_type="LOCAL" +) + +page_counter = 1 +while True: + print(f"Page {page_counter} List of Numbers: {active_numbers}") + + if not active_numbers.has_next_page: + break + + active_numbers = active_numbers.next_page() + page_counter += 1 diff --git a/examples/snippets/numbers/active_numbers/list_auto/snippet.py b/examples/snippets/numbers/active_numbers/list_auto/snippet.py new file mode 100644 index 00000000..ff180cd6 --- /dev/null +++ b/examples/snippets/numbers/active_numbers/list_auto/snippet.py @@ -0,0 +1,26 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" +) + +active_numbers = sinch_client.numbers.list( + region_code="US", + number_type="LOCAL" +) + +print("List of numbers printed one by one:\n") +for number in active_numbers.iterator(): + print(number) diff --git a/examples/snippets/numbers/active_numbers/release/snippet.py b/examples/snippets/numbers/active_numbers/release/snippet.py new file mode 100644 index 00000000..93accd26 --- /dev/null +++ b/examples/snippets/numbers/active_numbers/release/snippet.py @@ -0,0 +1,26 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" +) + +# The phone number to release in E.164 format +phone_number = os.environ.get("SINCH_PHONE_NUMBER") or "MY_PHONE_NUMBER" + +released_number = sinch_client.numbers.release( + phone_number=phone_number +) + +print("Released Number:", released_number) diff --git a/examples/snippets/numbers/active_numbers/update/snippet.py b/examples/snippets/numbers/active_numbers/update/snippet.py new file mode 100644 index 00000000..4d9902ad --- /dev/null +++ b/examples/snippets/numbers/active_numbers/update/snippet.py @@ -0,0 +1,29 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" +) + +# The phone number to update in E.164 format +phone_number = os.environ.get("SINCH_PHONE_NUMBER") or "MY_PHONE_NUMBER" +# The display name to set for the number +display_name = "DISPLAY_NAME" + +response = sinch_client.numbers.update( + phone_number=phone_number, + display_name=display_name, +) + +print("Updated Number:\n", response) diff --git a/examples/snippets/numbers/available_numbers/check_availability/snippet.py b/examples/snippets/numbers/available_numbers/check_availability/snippet.py new file mode 100644 index 00000000..3cce5738 --- /dev/null +++ b/examples/snippets/numbers/available_numbers/check_availability/snippet.py @@ -0,0 +1,26 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" +) + +# The phone number to check in E.164 format +phone_number = "PHONE_NUMBER" + +response = sinch_client.numbers.check_availability( + phone_number=phone_number +) + +print("The phone number is available:\n", response) diff --git a/examples/snippets/numbers/available_numbers/rent/snippet.py b/examples/snippets/numbers/available_numbers/rent/snippet.py new file mode 100644 index 00000000..f369d9fe --- /dev/null +++ b/examples/snippets/numbers/available_numbers/rent/snippet.py @@ -0,0 +1,32 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient +from sinch.domains.numbers.models.v1.types import SmsConfigurationDict + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" +) + +# The available phone number to rent in E.164 format +phone_number = "PHONE_NUMBER" +# The service plan ID to associate with the phone number +service_plan_id = os.environ.get("SINCH_SERVICE_PLAN_ID") or "MY_SERVICE_PLAN_ID" +sms_configuration: SmsConfigurationDict = { + "service_plan_id": service_plan_id, +} + +rented_number = sinch_client.numbers.rent( + phone_number=phone_number, + sms_configuration=sms_configuration +) +print("Rented Number:\n", rented_number) diff --git a/examples/snippets/numbers/available_numbers/rent_any/snippet.py b/examples/snippets/numbers/available_numbers/rent_any/snippet.py new file mode 100644 index 00000000..cc55923a --- /dev/null +++ b/examples/snippets/numbers/available_numbers/rent_any/snippet.py @@ -0,0 +1,36 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient +from sinch.domains.numbers.models.v1.types import SmsConfigurationDict + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" +) + +# The service plan ID to associate with the phone number +service_plan_id = os.environ.get("SINCH_SERVICE_PLAN_ID") or "MY_SERVICE_PLAN_ID" +sms_configuration: SmsConfigurationDict = { + "service_plan_id": service_plan_id, +} +# The URL to receive the notifications about provisioning events +event_destination_target = "CALLBACK_URL" + +response = sinch_client.numbers.rent_any( + region_code="US", + number_type="LOCAL", + capabilities=["SMS", "VOICE"], + sms_configuration=sms_configuration, + event_destination_target=event_destination_target +) + +print("Rented Number:\n", response) diff --git a/examples/snippets/numbers/available_numbers/search_for_available_numbers/snippet.py b/examples/snippets/numbers/available_numbers/search_for_available_numbers/snippet.py new file mode 100644 index 00000000..8d174729 --- /dev/null +++ b/examples/snippets/numbers/available_numbers/search_for_available_numbers/snippet.py @@ -0,0 +1,26 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" +) + +available_numbers = sinch_client.numbers.search_for_available_numbers( + region_code="AR", + number_type="LOCAL" +) + +print("Available numbers to rent:\n") +for number in available_numbers.iterator(): + print(number) diff --git a/examples/snippets/numbers/available_regions/list/snippet.py b/examples/snippets/numbers/available_regions/list/snippet.py new file mode 100644 index 00000000..59ff6459 --- /dev/null +++ b/examples/snippets/numbers/available_regions/list/snippet.py @@ -0,0 +1,25 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" +) + +available_regions = sinch_client.numbers.regions.list( + number_types=["MOBILE"] +) + +print("Available regions:\n") +for region in available_regions.iterator(): + print(region) diff --git a/examples/snippets/numbers/event_destinations/get/snippet.py b/examples/snippets/numbers/event_destinations/get/snippet.py new file mode 100644 index 00000000..3f33031e --- /dev/null +++ b/examples/snippets/numbers/event_destinations/get/snippet.py @@ -0,0 +1,21 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" +) + +response = sinch_client.numbers.event_destinations.get() + +print("Event Destination Configuration:\n", response) diff --git a/examples/snippets/numbers/event_destinations/update/snippet.py b/examples/snippets/numbers/event_destinations/update/snippet.py new file mode 100644 index 00000000..9e6dc8b0 --- /dev/null +++ b/examples/snippets/numbers/event_destinations/update/snippet.py @@ -0,0 +1,26 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" +) + +# The HMAC secret for signing webhook requests to your event destination +hmac_secret = "HMAC_SECRET" + +response = sinch_client.numbers.event_destinations.update( + hmac_secret=hmac_secret +) + +print("Updated event destination configuration:\n", response) diff --git a/examples/snippets/pyproject.toml b/examples/snippets/pyproject.toml new file mode 100644 index 00000000..da427070 --- /dev/null +++ b/examples/snippets/pyproject.toml @@ -0,0 +1,15 @@ +[tool.poetry] +name = "sinch-sdk-python-snippets" +version = "0.1.0" +description = "Code snippets demonstrating usage of the Sinch Python SDK" +readme = "README.md" +package-mode = false + +[tool.poetry.dependencies] +python = "^3.9" +python-dotenv = "^1.0.0" +sinch = {path = "..", develop = true} + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" \ No newline at end of file diff --git a/examples/snippets/sms/batches/cancel/snippet.py b/examples/snippets/sms/batches/cancel/snippet.py new file mode 100644 index 00000000..ebc64110 --- /dev/null +++ b/examples/snippets/sms/batches/cancel/snippet.py @@ -0,0 +1,25 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the batch to cancel +batch_id = "BATCH_ID" + +response = sinch_client.sms.batches.cancel(batch_id=batch_id) + +print(f"Cancelled batch:\n{response}") diff --git a/examples/snippets/sms/batches/dry_run_binary/snippet.py b/examples/snippets/sms/batches/dry_run_binary/snippet.py new file mode 100644 index 00000000..689cb02e --- /dev/null +++ b/examples/snippets/sms/batches/dry_run_binary/snippet.py @@ -0,0 +1,35 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +import base64 +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# Example: Encode message body as Base64 +message = "Test message for dry run" +body = base64.b64encode(message.encode('utf-8')).decode('utf-8') + +# Example: UDH header (HEX encoded) +udh = "06050423F423F4" + +response = sinch_client.sms.batches.dry_run_binary( + to=["+1234567890"], + from_="+2345678901", + body=body, + udh=udh +) + +print(f"Dry run result:\n{response}") diff --git a/examples/snippets/sms/batches/dry_run_mms/snippet.py b/examples/snippets/sms/batches/dry_run_mms/snippet.py new file mode 100644 index 00000000..8df83795 --- /dev/null +++ b/examples/snippets/sms/batches/dry_run_mms/snippet.py @@ -0,0 +1,32 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient +from sinch.domains.sms.models.v1.shared import MediaBody + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +body = MediaBody( + url="https://example.com/image.jpg", + message="Test message for dry run" +) + +response = sinch_client.sms.batches.dry_run_mms( + to=["+1234567890"], + from_="+2345678901", + body=body +) + +print(f"Dry run result:\n{response}") diff --git a/examples/snippets/sms/batches/dry_run_sms/snippet.py b/examples/snippets/sms/batches/dry_run_sms/snippet.py new file mode 100644 index 00000000..f9eb0ded --- /dev/null +++ b/examples/snippets/sms/batches/dry_run_sms/snippet.py @@ -0,0 +1,27 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +response = sinch_client.sms.batches.dry_run_sms( + to=["+1234567890"], + from_="+2345678901", + body="Test message for dry run" +) + +print(f"Dry run result:\n{response}") + diff --git a/examples/snippets/sms/batches/get/snippet.py b/examples/snippets/sms/batches/get/snippet.py new file mode 100644 index 00000000..369851b8 --- /dev/null +++ b/examples/snippets/sms/batches/get/snippet.py @@ -0,0 +1,25 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the batch to retrieve +batch_id = "BATCH_ID" + +response = sinch_client.sms.batches.get(batch_id=batch_id) + +print(f"Batch details:\n{response}") diff --git a/examples/snippets/sms/batches/list/snippet.py b/examples/snippets/sms/batches/list/snippet.py new file mode 100644 index 00000000..15473da4 --- /dev/null +++ b/examples/snippets/sms/batches/list/snippet.py @@ -0,0 +1,35 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +batches = sinch_client.sms.batches.list( + page=0, + page_size=10 +) + +page_counter = 1 +reached_last_page = False + +while not reached_last_page: + print(f"Page {page_counter} List of Batches: {batches}") + + if batches.has_next_page: + batches = batches.next_page() + page_counter += 1 + else: + reached_last_page = True diff --git a/examples/snippets/sms/batches/replace_binary/snippet.py b/examples/snippets/sms/batches/replace_binary/snippet.py new file mode 100644 index 00000000..17f13be2 --- /dev/null +++ b/examples/snippets/sms/batches/replace_binary/snippet.py @@ -0,0 +1,39 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +import base64 +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the batch to replace +batch_id = "BATCH_ID" + +# Example: Encode message body as Base64 +message = "Updated binary message content" +body = base64.b64encode(message.encode('utf-8')).decode('utf-8') + +# Example: UDH header (HEX encoded) +udh = "06050423F423F4" + +response = sinch_client.sms.batches.replace_binary( + batch_id=batch_id, + to=["+1234567890"], + from_="+2345678901", + body=body, + udh=udh +) + +print(f"Replaced batch:\n{response}") diff --git a/examples/snippets/sms/batches/replace_mms/snippet.py b/examples/snippets/sms/batches/replace_mms/snippet.py new file mode 100644 index 00000000..69bcb708 --- /dev/null +++ b/examples/snippets/sms/batches/replace_mms/snippet.py @@ -0,0 +1,36 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient +from sinch.domains.sms.models.v1.shared import MediaBody + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the batch to replace +batch_id = "BATCH_ID" + +body = MediaBody( + url="https://example.com/image.jpg", + message="Updated MMS message content" +) + +response = sinch_client.sms.batches.replace_mms( + batch_id=batch_id, + to=["+1234567890"], + from_="+2345678901", + body=body +) + +print(f"Replaced batch:\n{response}") diff --git a/examples/snippets/sms/batches/replace_sms/snippet.py b/examples/snippets/sms/batches/replace_sms/snippet.py new file mode 100644 index 00000000..f2e8f373 --- /dev/null +++ b/examples/snippets/sms/batches/replace_sms/snippet.py @@ -0,0 +1,31 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the batch to replace +batch_id = "BATCH_ID" + +response = sinch_client.sms.batches.replace_sms( + batch_id=batch_id, + to=["+1234567890"], + from_="+2345678901", + body="Updated message content" +) + +print(f"Replaced batch:\n{response}") + diff --git a/examples/snippets/sms/batches/send_binary/snippet.py b/examples/snippets/sms/batches/send_binary/snippet.py new file mode 100644 index 00000000..f93dbb10 --- /dev/null +++ b/examples/snippets/sms/batches/send_binary/snippet.py @@ -0,0 +1,35 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +import base64 +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# Example: Encode message body as Base64 +message = "Hello, this is a binary message!" +body = base64.b64encode(message.encode('utf-8')).decode('utf-8') + +# Example: UDH header (HEX encoded) +udh = "06050423F423F4" + +response = sinch_client.sms.batches.send_binary( + to=["+1234567890"], + from_="+2345678901", + body=body, + udh=udh +) + +print(f"Batch sent:\n{response}") diff --git a/examples/snippets/sms/batches/send_delivery_feedback/snippet.py b/examples/snippets/sms/batches/send_delivery_feedback/snippet.py new file mode 100644 index 00000000..5da87fc0 --- /dev/null +++ b/examples/snippets/sms/batches/send_delivery_feedback/snippet.py @@ -0,0 +1,30 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the batch to send delivery feedback for +batch_id = "BATCH_ID" +# The recipient phone numbers in E.164 format +recipients = ["+1234567890"] + +sinch_client.sms.batches.send_delivery_feedback( + batch_id=batch_id, + recipients=recipients +) + +print("Delivery feedback sent successfully") diff --git a/examples/snippets/sms/batches/send_mms/snippet.py b/examples/snippets/sms/batches/send_mms/snippet.py new file mode 100644 index 00000000..6627b712 --- /dev/null +++ b/examples/snippets/sms/batches/send_mms/snippet.py @@ -0,0 +1,33 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient +from sinch.domains.sms.models.v1.shared import MediaBody + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +body = MediaBody( + url="https://example.com/image.jpg", + message="Hello, this is an MMS message!", + subject="Test MMS" +) + +response = sinch_client.sms.batches.send_mms( + to=["+1234567890"], + from_="+2345678901", + body=body +) + +print(f"Batch sent:\n{response}") diff --git a/examples/snippets/sms/batches/send_sms/snippet.py b/examples/snippets/sms/batches/send_sms/snippet.py new file mode 100644 index 00000000..1073cc31 --- /dev/null +++ b/examples/snippets/sms/batches/send_sms/snippet.py @@ -0,0 +1,27 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +response = sinch_client.sms.batches.send_sms( + to=["+1234567890"], + from_="+2345678901", + body="Hello, this is a test message!" +) + +print(f"Batch sent:\n{response}") + diff --git a/examples/snippets/sms/batches/update_binary/snippet.py b/examples/snippets/sms/batches/update_binary/snippet.py new file mode 100644 index 00000000..7b1360d0 --- /dev/null +++ b/examples/snippets/sms/batches/update_binary/snippet.py @@ -0,0 +1,38 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +import base64 +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the batch to update +batch_id = "BATCH_ID" + +# Example: UDH header (HEX encoded) +udh = "06050423F423F4" + +# Example: Encode message body as Base64 (optional) +message = "Updated binary message body" +body = base64.b64encode(message.encode('utf-8')).decode('utf-8') + +response = sinch_client.sms.batches.update_binary( + batch_id=batch_id, + udh=udh, + body=body, + to_add=["+1987654321"] +) + +print(f"Updated batch:\n{response}") diff --git a/examples/snippets/sms/batches/update_mms/snippet.py b/examples/snippets/sms/batches/update_mms/snippet.py new file mode 100644 index 00000000..7c37dbb6 --- /dev/null +++ b/examples/snippets/sms/batches/update_mms/snippet.py @@ -0,0 +1,35 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient +from sinch.domains.sms.models.v1.shared import MediaBody + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the batch to update +batch_id = "BATCH_ID" + +body = MediaBody( + url="https://example.com/image.jpg", + message="Updated MMS message body" +) + +response = sinch_client.sms.batches.update_mms( + batch_id=batch_id, + body=body, + to_add=["+1987654321"] +) + +print(f"Updated batch:\n{response}") diff --git a/examples/snippets/sms/batches/update_sms/snippet.py b/examples/snippets/sms/batches/update_sms/snippet.py new file mode 100644 index 00000000..1e6fd4e6 --- /dev/null +++ b/examples/snippets/sms/batches/update_sms/snippet.py @@ -0,0 +1,30 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the batch to update +batch_id = "BATCH_ID" + +response = sinch_client.sms.batches.update_sms( + batch_id=batch_id, + body="Updated message body", + to_add=["+1987654321"] +) + +print(f"Updated batch:\n{response}") + diff --git a/examples/snippets/sms/delivery_reports/get/snippet.py b/examples/snippets/sms/delivery_reports/get/snippet.py new file mode 100644 index 00000000..d1e715e1 --- /dev/null +++ b/examples/snippets/sms/delivery_reports/get/snippet.py @@ -0,0 +1,25 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the batch to get delivery report for +batch_id = "BATCH_ID" + +response = sinch_client.sms.delivery_reports.get(batch_id=batch_id) + +print(f"Delivery report for batch:\n{response}") diff --git a/examples/snippets/sms/delivery_reports/get_for_number/snippet.py b/examples/snippets/sms/delivery_reports/get_for_number/snippet.py new file mode 100644 index 00000000..859a8768 --- /dev/null +++ b/examples/snippets/sms/delivery_reports/get_for_number/snippet.py @@ -0,0 +1,30 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the batch to get delivery reports for +batch_id = "BATCH_ID" +# The phone number from which you will receive delivery reports, in E.164 format (e.g., +1234567890). +recipient = "RECIPIENT_PHONE_NUMBER" + +response = sinch_client.sms.delivery_reports.get_for_number( + batch_id=batch_id, + recipient=recipient +) + +print(f"Delivery report for recipient:\n{response}") diff --git a/examples/snippets/sms/delivery_reports/list/snippet.py b/examples/snippets/sms/delivery_reports/list/snippet.py new file mode 100644 index 00000000..d92f88ef --- /dev/null +++ b/examples/snippets/sms/delivery_reports/list/snippet.py @@ -0,0 +1,35 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +delivery_reports = sinch_client.sms.delivery_reports.list( + page=0, + page_size=10 +) + +page_counter = 1 +reached_last_page = False + +while not reached_last_page: + print(f"Page {page_counter} List of Delivery Reports: {delivery_reports}") + + if delivery_reports.has_next_page: + delivery_reports = delivery_reports.next_page() + page_counter += 1 + else: + reached_last_page = True diff --git a/pyproject.toml b/pyproject.toml index 44d69345..4e4533f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "sinch" description = "Sinch SDK for Python programming language" -version = "1.1.4" +version = "2.0.0" license = "Apache 2.0" readme = "README.md" authors = [ @@ -29,8 +29,20 @@ keywords = ["sinch", "sdk"] [tool.poetry.dependencies] python = ">=3.9" requests = "*" -httpx = "*" +pydantic = ">=2.0.0" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" + +[tool.ruff] +line-length = 79 +target-version = "py39" +extend-exclude = [ + "sinch/core", + "tests", + "venv", + "__pycache__", + "dist", + "build" +] diff --git a/pytest.ini b/pytest.ini index 2f4c80e3..e69de29b 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +0,0 @@ -[pytest] -asyncio_mode = auto diff --git a/requirements-dev.txt b/requirements-dev.txt index 56637da2..384715db 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,11 +1,14 @@ # Testing pytest -pytest-asyncio +pytest-mock coverage +behave # Code Quality -flake8 +ruff # HTTP Libraries -httpx -requests \ No newline at end of file +requests + +# Data Validation +pydantic >= 2.0.0 \ No newline at end of file diff --git a/scripts/check_snippet_coverage.py b/scripts/check_snippet_coverage.py new file mode 100644 index 00000000..98167e05 --- /dev/null +++ b/scripts/check_snippet_coverage.py @@ -0,0 +1,101 @@ +""" +Validate that snippets have valid syntax, working imports, and reference existing SDK methods by executing them until the first outbound API call. +""" +import argparse +import os +import sys +from pathlib import Path +from unittest.mock import patch + +for var in [ + "SINCH_PROJECT_ID", "SINCH_KEY_ID", "SINCH_KEY_SECRET", + "SINCH_SMS_REGION", "SINCH_CONVERSATION_REGION", + "SINCH_PHONE_NUMBER", "SINCH_SERVICE_PLAN_ID", +]: + os.environ.setdefault(var, "test") + + +class SnippetValidationComplete(Exception): + """Raised when snippet successfully reaches first API call.""" + + +def validate_snippet(snippet_path: Path, quiet: bool = True) -> tuple[bool, str]: + """Run snippet; success when it reaches the first API call.""" + def mock_request(self, endpoint): + raise SnippetValidationComplete() + + try: + with patch( + "sinch.core.adapters.requests_http_transport.HTTPTransportRequests.request", + mock_request, + ): + with open(snippet_path) as f: + source = f.read() + if quiet: + with open(os.devnull, "w") as devnull: + old_stdout, old_stderr = sys.stdout, sys.stderr + sys.stdout, sys.stderr = devnull, devnull + try: + exec(source, {"__name__": "__main__"}) + finally: + sys.stdout, sys.stderr = old_stdout, old_stderr + else: + exec(source, {"__name__": "__main__"}) + return False, "Snippet ran without making API call" + except SnippetValidationComplete: + return True, "" + except ModuleNotFoundError as e: + return False, f"Broken import: {e}" + except ImportError as e: + return False, f"Broken import: {e}" + except AttributeError as e: + return False, f"Method/attribute does not exist: {e}" + except SyntaxError as e: + return False, f"Syntax error: {e}" + except Exception as e: + return False, f"{type(e).__name__}: {e}" + + +def main(): + parser = argparse.ArgumentParser( + description="Validate snippets (imports, syntax, SDK method names)" + ) + parser.add_argument("-q", "--quiet", action="store_true", help="Only print failures") + args = parser.parse_args() + + root = Path(__file__).parent.parent + os.chdir(root) + + snippets_dir = root / "examples" / "snippets" + if not snippets_dir.exists(): + print("ERROR: examples/snippets directory not found") + return 1 + + snippet_files = list(snippets_dir.rglob("snippet.py")) + if not snippet_files: + print("ERROR: No snippet.py files found") + return 1 + + failed = [] + for snippet_path in sorted(snippet_files): + rel_path = snippet_path.relative_to(root) + success, error = validate_snippet(snippet_path, quiet=args.quiet) + if success: + if not args.quiet: + print(f" OK {rel_path}") + else: + print(f" FAIL {rel_path}\n {error}") + failed.append((rel_path, error)) + + if failed: + print(f"\n{len(failed)} snippet(s) failed validation:") + for path, err in failed: + print(f" - {path}: {err}") + return 1 + + print(f"\nAll {len(snippet_files)} snippets validated successfully.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/sinch/__init__.py b/sinch/__init__.py index 769e4e96..2d00736e 100644 --- a/sinch/__init__.py +++ b/sinch/__init__.py @@ -1,9 +1,6 @@ -""" Sinch Python SDK -To access Sinch resources, use the Sync or Async version of the Sinch Client. -""" -__version__ = "1.1.4" +""" Sinch Python SDK""" +__version__ = "2.0.0" from sinch.core.clients.sinch_client_sync import SinchClient -from sinch.core.clients.sinch_client_async import SinchClientAsync -__all__ = (SinchClient, SinchClientAsync) +__all__ = ["SinchClient"] diff --git a/sinch/core/adapters/httpx_adapter.py b/sinch/core/adapters/httpx_adapter.py deleted file mode 100644 index 54820037..00000000 --- a/sinch/core/adapters/httpx_adapter.py +++ /dev/null @@ -1,62 +0,0 @@ -import httpx -from sinch.core.ports.http_transport import AsyncHTTPTransport, HttpRequest -from sinch.core.endpoint import HTTPEndpoint -from sinch.core.models.http_response import HTTPResponse - - -class HTTPXTransport(AsyncHTTPTransport): - def __init__(self, sinch): - super().__init__(sinch) - self.http_session = None - - async def request(self, endpoint: HTTPEndpoint) -> HTTPResponse: - request_data: HttpRequest = self.prepare_request(endpoint) - request_data: HttpRequest = await self.authenticate(endpoint, request_data) - - if not self.http_session: - self.http_session = httpx.AsyncClient() - - self.sinch.configuration.logger.debug( - f"Async HTTP {request_data.http_method} call with headers:" - f" {request_data.headers} and body: {request_data.request_body} to URL: {request_data.url}" - ) - - if isinstance(request_data.request_body, str): - response = await self.http_session.request( - method=request_data.http_method, - headers=request_data.headers, - url=request_data.url, - content=request_data.request_body, - auth=request_data.auth, - params=request_data.query_params, - timeout=self.sinch.configuration.connection_timeout - ) - else: - response = await self.http_session.request( - method=request_data.http_method, - headers=request_data.headers, - url=request_data.url, - data=request_data.request_body, - auth=request_data.auth, - params=request_data.query_params, - timeout=self.sinch.configuration.connection_timeout, - ) - - response_body = self.deserialize_json_response(response) - - self.sinch.configuration.logger.debug( - f"Async HTTP {response.status_code} response with headers: {response.headers}" - f"and body: {response_body} from URL: {request_data.url}" - ) - - return await self.handle_response( - endpoint=endpoint, - http_response=HTTPResponse( - status_code=response.status_code, - body=response_body, - headers=response.headers - ) - ) - - async def close_session(self): - await self.http_session.aclose() diff --git a/sinch/core/adapters/requests_http_transport.py b/sinch/core/adapters/requests_http_transport.py index 7163bd34..04671812 100644 --- a/sinch/core/adapters/requests_http_transport.py +++ b/sinch/core/adapters/requests_http_transport.py @@ -17,7 +17,6 @@ def request(self, endpoint: HTTPEndpoint) -> HTTPResponse: f"Sync HTTP {request_data.http_method} call with headers:" f" {request_data.headers} and body: {request_data.request_body} to URL: {request_data.url}" ) - response = self.http_session.request( method=request_data.http_method, url=request_data.url, diff --git a/sinch/core/clients/sinch_client_async.py b/sinch/core/clients/sinch_client_async.py deleted file mode 100644 index 65c4b2c4..00000000 --- a/sinch/core/clients/sinch_client_async.py +++ /dev/null @@ -1,51 +0,0 @@ -from logging import Logger -from sinch.core.clients.sinch_client_base import SinchClientBase -from sinch.core.clients.sinch_client_configuration import Configuration -from sinch.core.token_manager import TokenManagerAsync -from sinch.core.adapters.httpx_adapter import HTTPXTransport -from sinch.domains.authentication import AuthenticationAsync -from sinch.domains.numbers import NumbersAsync -from sinch.domains.conversation import ConversationAsync -from sinch.domains.sms import SMSAsync -from sinch.domains.verification import VerificationAsync -from sinch.domains.voice import VoiceAsync - - -class SinchClientAsync(SinchClientBase): - """ - Asynchronous implementation of the Sinch Client - By default this implementation uses HTTPXTransport based on httpx library - Custom Async HTTPTransport implementation can be provided via `transport` argument - """ - def __init__( - self, - key_id: str = None, - key_secret: str = None, - project_id: str = None, - logger_name: str = None, - logger: Logger = None, - application_key: str = None, - application_secret: str = None, - service_plan_id: str = None, - sms_api_token: str = None - ): - self.configuration = Configuration( - key_id=key_id, - key_secret=key_secret, - project_id=project_id, - logger_name=logger_name, - logger=logger, - transport=HTTPXTransport(self), - token_manager=TokenManagerAsync(self), - application_secret=application_secret, - application_key=application_key, - service_plan_id=service_plan_id, - sms_api_token=sms_api_token - ) - - self.authentication = AuthenticationAsync(self) - self.numbers = NumbersAsync(self) - self.conversation = ConversationAsync(self) - self.sms = SMSAsync(self) - self.verification = VerificationAsync(self) - self.voice = VoiceAsync(self) diff --git a/sinch/core/clients/sinch_client_base.py b/sinch/core/clients/sinch_client_base.py deleted file mode 100644 index 6072bfe0..00000000 --- a/sinch/core/clients/sinch_client_base.py +++ /dev/null @@ -1,40 +0,0 @@ -from logging import Logger -from abc import ABC, abstractmethod -from sinch.core.clients.sinch_client_configuration import Configuration -from sinch.domains.authentication import AuthenticationBase -from sinch.domains.numbers import NumbersBase -from sinch.domains.conversation import ConversationBase -from sinch.domains.sms import SMSBase -from sinch.domains.voice import VoiceBase - - -class SinchClientBase(ABC): - """ - Sinch abstract base class for concrete Sinch Client implementations. - By default, this SDK provides two implementations - sync and async. - Feel free to utilize any of them for you custom implementation. - """ - configuration = Configuration - authentication = AuthenticationBase - numbers = NumbersBase - conversation = ConversationBase - sms = SMSBase - voice = VoiceBase - - @abstractmethod - def __init__( - self, - key_id: str = None, - key_secret: str = None, - project_id: str = None, - logger_name: str = None, - logger: Logger = None, - application_key: str = None, - application_secret: str = None, - service_plan_id: str = None, - sms_api_token: str = None - ): - pass - - def __repr__(self): - return "Sinch SDK client" diff --git a/sinch/core/clients/sinch_client_configuration.py b/sinch/core/clients/sinch_client_configuration.py index 1af0f756..0bba7730 100644 --- a/sinch/core/clients/sinch_client_configuration.py +++ b/sinch/core/clients/sinch_client_configuration.py @@ -1,9 +1,8 @@ import logging from logging import Logger -from typing import Union from sinch.core.ports.http_transport import HTTPTransport -from sinch.core.token_manager import TokenManager, TokenManagerAsync +from sinch.core.token_manager import TokenManager from sinch.core.enums import HTTPAuthentication @@ -13,52 +12,46 @@ class Configuration: """ def __init__( self, - key_id: str, - key_secret: str, - project_id: str, transport: HTTPTransport, - token_manager: Union[TokenManager, TokenManagerAsync], + token_manager: TokenManager, + connection_timeout=10, + key_id: str = None, + key_secret: str = None, + project_id: str = None, logger: Logger = None, logger_name: str = None, - disable_https=False, - connection_timeout=10, - application_key: str = None, - application_secret: str = None, service_plan_id: str = None, - sms_api_token: str = None + sms_api_token: str = None, + sms_region: str = None, + conversation_region: str = None, ): self.key_id = key_id self.key_secret = key_secret self.project_id = project_id - self.application_key = application_key - self.application_secret = application_secret self.connection_timeout = connection_timeout self.sms_api_token = sms_api_token self.service_plan_id = service_plan_id - self.auth_origin = "auth.sinch.com" - self.numbers_origin = "numbers.api.sinch.com" - self.verification_origin = "verification.api.sinch.com" - self.voice_applications_origin = "callingapi.sinch.com" - self._voice_domain = "{}.api.sinch.com" - self._voice_region = None - self._conversation_region = "eu" - self._conversation_domain = ".conversation.api.sinch.com" - self._sms_region = "us" - self._sms_region_with_service_plan_id = "us" - self._sms_domain = "zt.{}.sms.api.sinch.com" - self._sms_domain_with_service_plan_id = "{}.sms.api.sinch.com" - self._sms_authentication = HTTPAuthentication.OAUTH.value + + # Determine authentication method based on provided parameters + self._authentication_method = self._determine_authentication_method() + self.auth_origin = "https://auth.sinch.com" + self.numbers_origin = "https://numbers.api.sinch.com" + self.number_lookup_origin = "https://lookup.api.sinch.com" + self._conversation_region = conversation_region + self._conversation_domain = "https://{}.conversation.api.sinch.com" + self._sms_region = sms_region + self._sms_region_with_service_plan_id = sms_region + self._sms_domain = "https://zt.{}.sms.api.sinch.com" + self._sms_domain_with_service_plan_id = "https://{}.sms.api.sinch.com" self._templates_region = "eu" self._templates_domain = ".template.api.sinch.com" self.token_manager = token_manager - self.disable_https = disable_https self.transport: HTTPTransport = transport self._set_conversation_origin() self._set_sms_origin() self._set_sms_origin_with_service_plan_id() self._set_templates_origin() - self._set_voice_origin() if logger_name: self.logger = logging.getLogger(logger_name) @@ -68,9 +61,12 @@ def __init__( self.logger = logging.getLogger("Sinch") def _set_sms_origin_with_service_plan_id(self): - self.sms_origin_with_service_plan_id = self._sms_domain_with_service_plan_id.format( - self._sms_region_with_service_plan_id - ) + if self._sms_region_with_service_plan_id: + self.sms_origin_with_service_plan_id = self._sms_domain_with_service_plan_id.format( + self._sms_region_with_service_plan_id + ) + else: + self.sms_origin_with_service_plan_id = None def _set_sms_region_with_service_plan_id(self, region): self._sms_region_with_service_plan_id = region @@ -99,7 +95,10 @@ def _get_sms_domain_with_service_plan_id(self): ) def _set_sms_origin(self): - self.sms_origin = self._sms_domain.format(self._sms_region) + if self._sms_region: + self.sms_origin = self._sms_domain.format(self._sms_region) + else: + self.sms_origin = None def _set_sms_region(self, region): self._sms_region = region @@ -128,7 +127,10 @@ def _get_sms_domain(self): ) def _set_conversation_origin(self): - self.conversation_origin = self._conversation_region + self._conversation_domain + if self._conversation_region: + self.conversation_origin = self._conversation_domain.format(self._conversation_region) + else: + self.conversation_origin = None def _set_conversation_region(self, region): self._conversation_region = region @@ -185,21 +187,93 @@ def _get_templates_domain(self): doc="Conversation API Templates Domain" ) - def _set_voice_origin(self): - if not self._voice_region: - self.voice_origin = self._voice_domain.format("calling") + def _determine_authentication_method(self): + """ + Determines the authentication method based on provided parameters. + Priority: SMS authentication (service_plan_id + sms_api_token) over project authentication (project_id). + """ + if self.service_plan_id and self.sms_api_token: + return "sms_auth" + elif self.project_id: + return "project_auth" else: - self.voice_origin = self._voice_domain.format("calling-" + self._voice_region) - - def _set_voice_region(self, region): - self._voice_region = region - self._set_voice_origin() - - def _get_voice_region(self): - return self._voice_region - - voice_region = property( - _get_voice_region, - _set_voice_region, - doc="Voice Region" - ) + # No authentication parameters provided - will be validated later + return None + + @property + def authentication_method(self): + """Returns the determined authentication method""" + return self._authentication_method + + def validate_authentication_parameters(self): + """ + Validates that sufficient authentication parameters are provided. + Recalculates the authentication method based on current credentials before validating. + This should be called before making actual API requests. + """ + + self._authentication_method = self._determine_authentication_method() + + # Check for incomplete SMS auth only if not using project auth + # This prevents false positives when both service_plan_id and project_id are provided + has_project_auth = self.project_id and self.key_id and self.key_secret + if self.service_plan_id and not self.sms_api_token and not has_project_auth: + raise ValueError( + "The sms_api_token is required when using service_plan_id" + ) + if self._authentication_method is None or self._authentication_method == "project_auth": + # Default to project_auth and validate parameters + if not self.project_id: + raise ValueError( + "The project_id is required" + ) + if not self.key_id or not self.key_secret: + raise ValueError( + "The key_id and key_secret are required" + ) + elif self._authentication_method == "sms_auth": + if not self.service_plan_id or not self.sms_api_token: + raise ValueError( + "The service_plan_id and sms_api_token are required" + ) + + def get_sms_origin_for_auth(self): + """ + Returns the appropriate SMS origin based on the authentication method. + - SMS auth (service_plan_id + sms_api_token): uses sms_origin_with_service_plan_id + - Project auth (project_id): uses regular sms_origin + + Raises: + ValueError: If the SMS origin is None (sms_region not set) + """ + if self._authentication_method == "sms_auth": + origin = self.sms_origin_with_service_plan_id + else: + origin = self.sms_origin + + if origin is None: + raise ValueError( + "SMS region is required. " + "Provide sms_region when initializing SinchClient " + "Example: SinchClient(project_id='...', key_id='...', key_secret='...', sms_region='eu')" + " or set it via sinch_client.configuration.sms_region. " + ) + + return origin + + def get_conversation_origin(self): + """ + Returns the conversation origin. + + Raises: + ValueError: If the conversation region is None (conversation_region not set) + """ + if self.conversation_origin is None: + raise ValueError( + "Conversation region is required. " + "Provide conversation_region when initializing SinchClient " + "Example: SinchClient(project_id='...', key_id='...', key_secret='...', conversation_region='eu')" + " or set it via sinch_client.configuration.conversation_region. " + ) + + return self.conversation_origin diff --git a/sinch/core/clients/sinch_client_sync.py b/sinch/core/clients/sinch_client_sync.py index b2e6055c..5ea361ef 100644 --- a/sinch/core/clients/sinch_client_sync.py +++ b/sinch/core/clients/sinch_client_sync.py @@ -1,17 +1,15 @@ from logging import Logger -from sinch.core.clients.sinch_client_base import SinchClientBase from sinch.core.clients.sinch_client_configuration import Configuration from sinch.core.token_manager import TokenManager from sinch.core.adapters.requests_http_transport import HTTPTransportRequests from sinch.domains.authentication import Authentication -from sinch.domains.numbers import Numbers +from sinch.domains.numbers import VirtualNumbers from sinch.domains.conversation import Conversation from sinch.domains.sms import SMS -from sinch.domains.verification import Verification -from sinch.domains.voice import Voice +from sinch.domains.number_lookup import NumberLookup -class SinchClient(SinchClientBase): +class SinchClient: """ Synchronous implementation of the Sinch Client By default this implementation uses HTTPTransportRequests based on Requests library @@ -24,10 +22,10 @@ def __init__( project_id: str = None, logger_name: str = None, logger: Logger = None, - application_key: str = None, - application_secret: str = None, service_plan_id: str = None, - sms_api_token: str = None + sms_api_token: str = None, + sms_region: str = None, + conversation_region: str = None, ): self.configuration = Configuration( key_id=key_id, @@ -37,15 +35,14 @@ def __init__( logger=logger, transport=HTTPTransportRequests(self), token_manager=TokenManager(self), - application_key=application_key, - application_secret=application_secret, service_plan_id=service_plan_id, - sms_api_token=sms_api_token + sms_api_token=sms_api_token, + sms_region=sms_region, + conversation_region=conversation_region, ) self.authentication = Authentication(self) - self.numbers = Numbers(self) + self.numbers = VirtualNumbers(self) self.conversation = Conversation(self) self.sms = SMS(self) - self.verification = Verification(self) - self.voice = Voice(self) + self.number_lookup = NumberLookup(self) diff --git a/sinch/core/endpoint.py b/sinch/core/endpoint.py index d11608b2..30b4b6f7 100644 --- a/sinch/core/endpoint.py +++ b/sinch/core/endpoint.py @@ -4,12 +4,21 @@ class HTTPEndpoint(ABC): ENDPOINT_URL = None - HTTP_METHOD = None - HTTP_AUTHENTICATION = None - def __init__(self, project_id, request_data): + @property + @abstractmethod + def HTTP_METHOD(self) -> str: + pass + + @property + @abstractmethod + def HTTP_AUTHENTICATION(self) -> str: pass + def __init__(self, project_id, request_data): + self.project_id = project_id + self.request_data = request_data + def get_url_without_origin(self, sinch): return '/' + '/'.join(self.build_url(sinch).split('/')[1:]) @@ -17,6 +26,12 @@ def build_url(self, sinch): return def build_query_params(self): + """ + Constructs the query parameters for the endpoint. + + Returns: + dict: The query parameters to be sent with the API request. + """ pass def request_body(self): diff --git a/sinch/core/enums.py b/sinch/core/enums.py index 0898c2cc..0ba15c1b 100644 --- a/sinch/core/enums.py +++ b/sinch/core/enums.py @@ -12,5 +12,4 @@ class HTTPMethods(Enum): class HTTPAuthentication(Enum): BASIC = "BASIC" OAUTH = "OAUTH" - SIGNED = "SIGNED" SMS_TOKEN = "SMS_TOKEN" diff --git a/sinch/core/models/__init__.py b/sinch/core/models/__init__.py index e69de29b..7457f49f 100644 --- a/sinch/core/models/__init__.py +++ b/sinch/core/models/__init__.py @@ -0,0 +1,10 @@ +from sinch.core.models.utils import ( + model_dump_for_query_params, + serialize_datetime_in_dict, +) + +__all__ = [ + "model_dump_for_query_params", + "serialize_datetime_in_dict", +] + diff --git a/sinch/core/models/http_request.py b/sinch/core/models/http_request.py index d824e42a..9daa0c33 100644 --- a/sinch/core/models/http_request.py +++ b/sinch/core/models/http_request.py @@ -4,7 +4,6 @@ @dataclass class HttpRequest: headers: dict - protocol: str url: str http_method: str request_body: dict diff --git a/sinch/core/models/http_response.py b/sinch/core/models/http_response.py index 21db3fd3..390921b4 100644 --- a/sinch/core/models/http_response.py +++ b/sinch/core/models/http_response.py @@ -4,5 +4,5 @@ @dataclass class HTTPResponse: status_code: int - body: dict headers: dict + body: dict = None diff --git a/sinch/core/models/utils.py b/sinch/core/models/utils.py new file mode 100644 index 00000000..aef322e2 --- /dev/null +++ b/sinch/core/models/utils.py @@ -0,0 +1,61 @@ +from datetime import datetime, date +from typing import Optional, Dict, Any + + +def serialize_datetime_in_dict(value: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """ + Serialize datetime/date objects in a dictionary to ISO 8601 date strings. + + :param value: Optional dictionary that may contain datetime/date objects + :type value: Optional[Dict[str, Any]] + :returns: Dictionary with datetime/date objects converted to ISO 8601 date strings, + or None if input is None + :rtype: Optional[Dict[str, Any]] + """ + if value is None: + return None + + serialized = {} + for key, val in value.items(): + if isinstance(val, (datetime, date)): + # Convert datetime/date to ISO 8601 date format (YYYY-MM-DD) + if isinstance(val, datetime): + serialized[key] = val.date().isoformat() + else: + serialized[key] = val.isoformat() + else: + # Pass string values directly to the backend without modification + serialized[key] = val + return serialized + + +def model_dump_for_query_params(model, exclude_none=True, by_alias=True): + """ + Serializes a Pydantic model for use as query parameters. + Converts list values to comma-separated strings for APIs that expect this format. + Filters out empty values (empty strings and empty lists). + + :param model: A Pydantic BaseModel instance + :type model: BaseModel + :param exclude_none: Whether to exclude None values (default: True) + :type exclude_none: bool + :param by_alias: Whether to use field aliases (default: True) + :type by_alias: bool + :returns: Serialized model data with lists converted to comma-separated strings + :rtype: dict + """ + data = model.model_dump(exclude_none=exclude_none, by_alias=by_alias) + filtered_data = {} + for key, value in data.items(): + # Filter out empty strings + if value == "": + continue + # Filter out empty lists + if isinstance(value, list) and len(value) == 0: + continue + # Convert lists to comma-separated strings + if isinstance(value, list): + filtered_data[key] = ",".join(str(item) for item in value) + else: + filtered_data[key] = value + return filtered_data diff --git a/sinch/core/pagination.py b/sinch/core/pagination.py index 1b77cb51..abc17741 100644 --- a/sinch/core/pagination.py +++ b/sinch/core/pagination.py @@ -1,49 +1,34 @@ from abc import ABC, abstractmethod +from typing import Generic +from sinch.core.types import BM class PageIterator: - def __init__(self, paginator): + def __init__(self, paginator, yield_first_page=False): self.paginator = paginator + # If yielding the first page, set started to False + self.started = not yield_first_page def __iter__(self): return self def __next__(self): - if self.paginator.has_next_page: - return self.paginator.next_page() - else: - raise StopIteration - - -class AsyncPageIterator: - def __init__(self, paginator): - self.paginator = paginator + if not self.started: + self.started = True + return self.paginator - def __aiter__(self): - return self - - async def __anext__(self): if self.paginator.has_next_page: - return await self.paginator.next_page() + self.paginator = self.paginator.next_page() + return self.paginator else: - raise StopAsyncIteration + raise StopIteration -class Paginator(ABC): +class Paginator(ABC, Generic[BM]): """ Pagination response object. - - auto_paging_iter method returns an iterator object that can be used for iterator-based page traversing. - For example: - for page in paginated_response.auto_paging_iter(): - ...process page object - - For manual pagination use has_next_page property with next_page() method. - For example: - if paginated_response.has_next_page: - paginated_response = paginated_response.next_page() """ - def __init__(self, sinch, endpoint, result): + def __init__(self, sinch, endpoint, result: BM): self._sinch = sinch self.result = result self.endpoint = endpoint @@ -53,8 +38,14 @@ def __init__(self, sinch, endpoint, result): def __repr__(self): return "Paginated response content: " + str(self.result) - @abstractmethod - def auto_paging_iter(self): + # TODO: Make content() method abstract in Parent class as we implement in the other domains: + # - Refactor pydantic models in other domains to have a content property. + def content(self): + pass + + # TODO: Make iterator() method abstract in Parent class as we implement in the other domains: + # - Refactor pydantic models in other domains to have a content property. + def iterator(self): pass @abstractmethod @@ -71,85 +62,96 @@ def _initialize(cls, sinch, endpoint): pass -class IntBasedPaginator(Paginator): - __doc__ = Paginator.__doc__ +class SMSPaginator(Paginator[BM]): + """Base paginator for integer-based pagination with explicit page navigation and metadata.""" - def _calculate_next_page(self): - if self.result.page_size: - self.has_next_page = True - else: - self.has_next_page = False + def __init__(self, sinch, endpoint, result=None): + super().__init__(sinch, endpoint, result or sinch.configuration.transport.request(endpoint)) + + def content(self) -> list[BM]: + """Returns the content list from the result.""" + return getattr(self.result, "content", []) def next_page(self): + """Returns a new paginator instance for the next page.""" + if not self.has_next_page: + return None + + if self.endpoint.request_data.page is None: + self.endpoint.request_data.page = 0 self.endpoint.request_data.page += 1 self.result = self._sinch.configuration.transport.request(self.endpoint) self._calculate_next_page() return self - def auto_paging_iter(self): - return PageIterator(self) + def iterator(self): + """Iterates over individual items across all pages.""" + paginator = self + while paginator: + yield from paginator.content() + + next_page_instance = paginator.next_page() + if not next_page_instance: + break + paginator = next_page_instance + + def _calculate_next_page(self): + """Calculates if there's a next page based on count, page, and page_size.""" + if hasattr(self.result, 'count') and hasattr(self.result, 'page'): + # Use the requested page_size from the endpoint + request_page_size = self.endpoint.request_data.page_size or 1 + if request_page_size > 0 and hasattr(self.result, 'page_size'): + # Calculate total pages needed using the request page_size + total_pages = (self.result.count + request_page_size - 1) // request_page_size + # Check if current page is less than total pages - 1 (0-indexed) + self.has_next_page = self.result.page < (total_pages - 1) + else: + self.has_next_page = False + else: + self.has_next_page = False @classmethod def _initialize(cls, sinch, endpoint): + """Creates an instance of the paginator skipping first page.""" result = sinch.configuration.transport.request(endpoint) - return cls(sinch, endpoint, result) + return cls(sinch, endpoint, result=result) -class AsyncIntBasedPaginator(IntBasedPaginator): - __doc__ = IntBasedPaginator.__doc__ +class TokenBasedPaginator(Paginator[BM]): + """Base paginator for token-based pagination with explicit page navigation and metadata.""" - async def next_page(self): - self.endpoint.request_data.page += 1 - self.result = await self._sinch.configuration.transport.request(self.endpoint) - self._calculate_next_page() - return self - - def auto_paging_iter(self): - return AsyncPageIterator(self) - - @classmethod - async def _initialize(cls, sinch, endpoint): - result = await sinch.configuration.transport.request(endpoint) - return cls(sinch, endpoint, result) + def __init__(self, sinch, endpoint, result=None): + super().__init__(sinch, endpoint, result or sinch.configuration.transport.request(endpoint)) - -class TokenBasedPaginator(Paginator): - __doc__ = Paginator.__doc__ - - def _calculate_next_page(self): - if self.result.next_page_token: - self.has_next_page = True - else: - self.has_next_page = False + def content(self) -> list[BM]: + return getattr(self.result, "content", []) def next_page(self): + """Returns a new paginator instance for the next page.""" + if not self.has_next_page: + return None + self.endpoint.request_data.page_token = self.result.next_page_token self.result = self._sinch.configuration.transport.request(self.endpoint) self._calculate_next_page() return self - def auto_paging_iter(self): - return PageIterator(self) + def iterator(self): + """Iterates over individual items across all pages.""" + paginator = self + while paginator: + yield from paginator.content() - @classmethod - def _initialize(cls, sinch, endpoint): - result = sinch.configuration.transport.request(endpoint) - return cls(sinch, endpoint, result) - - -class AsyncTokenBasedPaginator(TokenBasedPaginator): - __doc__ = TokenBasedPaginator.__doc__ - - async def next_page(self): - self.endpoint.request_data.page_token = self.result.next_page_token - self.result = await self._sinch.configuration.transport.request(self.endpoint) - self._calculate_next_page() - return self + next_page_instance = paginator.next_page() + if not next_page_instance: + break + paginator = next_page_instance - def auto_paging_iter(self): - return AsyncPageIterator(self) + def _calculate_next_page(self): + self.has_next_page = bool(getattr(self.result, "next_page_token", None)) @classmethod - async def _initialize(cls, sinch, endpoint): - result = await sinch.configuration.transport.request(endpoint) - return cls(sinch, endpoint, result) + def _initialize(cls, sinch, endpoint): + """Creates an instance of the paginator skipping first page.""" + result = sinch.configuration.transport.request(endpoint) + return cls(sinch, endpoint, result=result) diff --git a/sinch/core/ports/http_transport.py b/sinch/core/ports/http_transport.py index 9385b31d..f4e30dcd 100644 --- a/sinch/core/ports/http_transport.py +++ b/sinch/core/ports/http_transport.py @@ -1,8 +1,6 @@ -import httpx from abc import ABC, abstractmethod from platform import python_version from sinch.core.endpoint import HTTPEndpoint -from sinch.core.signature import Signature from sinch.core.models.http_request import HttpRequest from sinch.core.models.http_response import HTTPResponse from sinch.core.exceptions import ValidationException, SinchException @@ -46,23 +44,6 @@ def authenticate(self, endpoint, request_data): "Authorization": f"Bearer {token}", "Content-Type": "application/json" }) - elif endpoint.HTTP_AUTHENTICATION == HTTPAuthentication.SIGNED.value: - if not self.sinch.configuration.application_key or not self.sinch.configuration.application_secret: - raise ValidationException( - message=( - "application key and application secret are required by this API. " - "Those credentials can be obtained from Sinch portal." - ), - is_from_server=False, - response=None - ) - signature = Signature( - self.sinch, - endpoint.HTTP_METHOD, - request_data.request_body, - endpoint.get_url_without_origin(self.sinch) - ) - request_data.headers = signature.get_http_headers_with_signature() elif endpoint.HTTP_AUTHENTICATION == HTTPAuthentication.SMS_TOKEN.value: if not self.sinch.configuration.sms_api_token or not self.sinch.configuration.service_plan_id: raise ValidationException( @@ -81,7 +62,6 @@ def authenticate(self, endpoint, request_data): return request_data def prepare_request(self, endpoint: HTTPEndpoint) -> HttpRequest: - protocol = "http://" if self.sinch.configuration.disable_https else "https://" url_query_params = endpoint.build_query_params() return HttpRequest( @@ -89,8 +69,7 @@ def prepare_request(self, endpoint: HTTPEndpoint) -> HttpRequest: "User-Agent": f"sinch-sdk/{sdk_version} (Python/{python_version()};" f" {self.__class__.__name__};)" }, - protocol=protocol, - url=protocol + endpoint.build_url(self.sinch), + url=endpoint.build_url(self.sinch), http_method=endpoint.HTTP_METHOD, request_body=endpoint.request_body(), query_params=url_query_params, @@ -123,77 +102,3 @@ def handle_response(self, endpoint: HTTPEndpoint, http_response: HTTPResponse): return self.request(endpoint=endpoint) return endpoint.handle_response(http_response) - - -class AsyncHTTPTransport(HTTPTransport): - async def authenticate(self, endpoint, request_data): - if endpoint.HTTP_AUTHENTICATION in (HTTPAuthentication.BASIC.value, HTTPAuthentication.OAUTH.value): - if ( - not self.sinch.configuration.key_id - or not self.sinch.configuration.key_secret - or not self.sinch.configuration.project_id - ): - raise ValidationException( - message=( - "key_id, key_secret and project_id are required by this API. " - "Those credentials can be obtained from Sinch portal." - ), - is_from_server=False, - response=None - ) - - if endpoint.HTTP_AUTHENTICATION == HTTPAuthentication.BASIC.value: - request_data.auth = httpx.BasicAuth( - self.sinch.configuration.key_id, - self.sinch.configuration.key_secret - ) - else: - request_data.auth = None - - if endpoint.HTTP_AUTHENTICATION == HTTPAuthentication.OAUTH.value: - token_response = await self.sinch.authentication.get_auth_token() - request_data.headers = { - "Authorization": f"Bearer {token_response.access_token}", - "Content-Type": "application/json" - } - elif endpoint.HTTP_AUTHENTICATION == HTTPAuthentication.SIGNED.value: - if not self.sinch.configuration.application_key or not self.sinch.configuration.application_secret: - raise ValidationException( - message=( - "application key and application secret are required by this API. " - "Those credentials can be obtained from Sinch portal." - ), - is_from_server=False, - response=None - ) - signature = Signature( - self.sinch, - endpoint.HTTP_METHOD, - request_data.request_body, - endpoint.get_url_without_origin(self.sinch) - ) - request_data.headers = signature.get_http_headers_with_signature() - elif endpoint.HTTP_AUTHENTICATION == HTTPAuthentication.SMS_TOKEN.value: - if not self.sinch.configuration.sms_api_token or not self.sinch.configuration.service_plan_id: - raise ValidationException( - message=( - "sms_api_token and service_plan_id are required by this API. " - "Those credentials can be obtained from Sinch portal." - ), - is_from_server=False, - response=None - ) - request_data.headers.update({ - "Authorization": f"Bearer {self.sinch.configuration.sms_api_token}", - "Content-Type": "application/json" - }) - - return request_data - - async def handle_response(self, endpoint: HTTPEndpoint, http_response: HTTPResponse): - if http_response.status_code == 401 and endpoint.HTTP_AUTHENTICATION == HTTPAuthentication.OAUTH.value: - self.sinch.configuration.token_manager.handle_invalid_token(http_response) - if self.sinch.configuration.token_manager.token_state == TokenState.EXPIRED: - return await self.request(endpoint=endpoint) - - return endpoint.handle_response(http_response) diff --git a/sinch/core/signature.py b/sinch/core/signature.py deleted file mode 100644 index 5e456266..00000000 --- a/sinch/core/signature.py +++ /dev/null @@ -1,58 +0,0 @@ -import hashlib -import hmac -import base64 -from datetime import datetime, timezone - - -class Signature: - def __init__( - self, - sinch, - http_method, - request_data, - request_uri, - content_type=None, - signature_timestamp=None - ): - self.sinch = sinch - self.http_method = http_method - self.content_type = content_type or 'application/json; charset=UTF-8' - self.request_data = request_data - self.signature_timestamp = signature_timestamp or datetime.now(timezone.utc).isoformat() - self.request_uri = request_uri - self.authorization_signature = None - - def get_http_headers_with_signature(self): - if not self.authorization_signature: - self.calculate() - - return { - "Content-Type": self.content_type, - "Authorization": ( - f"Application {self.sinch.configuration.application_key}:{self.authorization_signature}" - ), - "x-timestamp": self.signature_timestamp - } - - def calculate(self): - b64_decoded_application_secret = base64.b64decode(self.sinch.configuration.application_secret) - if self.request_data: - encoded_verification_request = hashlib.md5(self.request_data.encode()) - encoded_verification_request = base64.b64encode(encoded_verification_request.digest()) - - else: - encoded_verification_request = ''.encode() - - request_timestamp = "x-timestamp:" + self.signature_timestamp - - string_to_sign = ( - self.http_method + '\n' - + encoded_verification_request.decode() + '\n' - + self.content_type + '\n' - + request_timestamp + '\n' - + self.request_uri - ) - - self.authorization_signature = base64.b64encode( - hmac.new(b64_decoded_application_secret, string_to_sign.encode(), hashlib.sha256).digest() - ).decode() diff --git a/sinch/core/token_manager.py b/sinch/core/token_manager.py index 8713ef8e..b66bba85 100644 --- a/sinch/core/token_manager.py +++ b/sinch/core/token_manager.py @@ -1,7 +1,7 @@ from enum import Enum from abc import ABC, abstractmethod -from sinch.domains.authentication.models.authentication import OAuthToken -from sinch.domains.authentication.endpoints.oauth import OAuthEndpoint +from sinch.domains.authentication.models.v1.authentication import OAuthToken +from sinch.domains.authentication.endpoints.v1.oauth import OAuthEndpoint from sinch.core.exceptions import ValidationException @@ -49,13 +49,3 @@ def get_auth_token(self) -> OAuthToken: self.token = self.sinch.configuration.transport.request(OAuthEndpoint()) self.token_state = TokenState.VALID return self.token - - -class TokenManagerAsync(TokenManagerBase): - async def get_auth_token(self) -> OAuthToken: - if self.token: - return self.token - - self.token = await self.sinch.configuration.transport.request(OAuthEndpoint()) - self.token_state = TokenState.VALID - return self.token diff --git a/sinch/core/types.py b/sinch/core/types.py new file mode 100644 index 00000000..592a9201 --- /dev/null +++ b/sinch/core/types.py @@ -0,0 +1,4 @@ +from typing import TypeVar +from pydantic import BaseModel + +BM = TypeVar("BM", bound=BaseModel) diff --git a/sinch/domains/authentication/__init__.py b/sinch/domains/authentication/__init__.py index 8bcc576a..8774f7ef 100644 --- a/sinch/domains/authentication/__init__.py +++ b/sinch/domains/authentication/__init__.py @@ -20,11 +20,3 @@ def get_auth_token(self): def set_auth_token(self, token): self.sinch.configuration.token_manager.set_auth_token(token) - - -class AuthenticationAsync(AuthenticationBase): - async def get_auth_token(self): - return await self.sinch.configuration.token_manager.get_auth_token() - - async def set_auth_token(self, token): - return await self.sinch.configuration.token_manager.set_auth_token() diff --git a/sinch/domains/conversation/endpoints/conversation/__init__.py b/sinch/domains/authentication/endpoints/v1/__init__.py similarity index 100% rename from sinch/domains/conversation/endpoints/conversation/__init__.py rename to sinch/domains/authentication/endpoints/v1/__init__.py diff --git a/sinch/domains/authentication/endpoints/oauth.py b/sinch/domains/authentication/endpoints/v1/oauth.py similarity index 94% rename from sinch/domains/authentication/endpoints/oauth.py rename to sinch/domains/authentication/endpoints/v1/oauth.py index 13fb3a80..b8b867c5 100644 --- a/sinch/domains/authentication/endpoints/oauth.py +++ b/sinch/domains/authentication/endpoints/v1/oauth.py @@ -2,7 +2,7 @@ from sinch.core.endpoint import HTTPEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.domains.authentication.exceptions import AuthenticationException -from sinch.domains.authentication.models.authentication import OAuthToken +from sinch.domains.authentication.models.v1.authentication import OAuthToken class OAuthEndpoint(HTTPEndpoint): diff --git a/sinch/domains/conversation/endpoints/message/__init__.py b/sinch/domains/authentication/models/v1/__init__.py similarity index 100% rename from sinch/domains/conversation/endpoints/message/__init__.py rename to sinch/domains/authentication/models/v1/__init__.py diff --git a/sinch/domains/authentication/models/authentication.py b/sinch/domains/authentication/models/v1/authentication.py similarity index 100% rename from sinch/domains/authentication/models/authentication.py rename to sinch/domains/authentication/models/v1/authentication.py diff --git a/sinch/domains/conversation/endpoints/templates/__init__.py b/sinch/domains/authentication/sinch_events/__init__.py similarity index 100% rename from sinch/domains/conversation/endpoints/templates/__init__.py rename to sinch/domains/authentication/sinch_events/__init__.py diff --git a/sinch/domains/conversation/endpoints/webhooks/__init__.py b/sinch/domains/authentication/sinch_events/v1/__init__.py similarity index 100% rename from sinch/domains/conversation/endpoints/webhooks/__init__.py rename to sinch/domains/authentication/sinch_events/v1/__init__.py diff --git a/sinch/domains/authentication/sinch_events/v1/authentication_validation.py b/sinch/domains/authentication/sinch_events/v1/authentication_validation.py new file mode 100644 index 00000000..20a16ca4 --- /dev/null +++ b/sinch/domains/authentication/sinch_events/v1/authentication_validation.py @@ -0,0 +1,135 @@ +import hashlib +import hmac +import base64 +import json +from typing import Dict, Union, Optional, List + + +def validate_signature_header( + callback_secret: str, + headers: Dict[str, str], + body: str +) -> bool: + """ + Validate signature headers for Numbers callback. + + Note: A ``callback_url`` must be associated with the number. + + :param callback_secret: Secret associated with the rented number. + :type callback_secret: str + :param headers: Incoming request's headers. + :type headers: Dict[str, str] + :param body: Incoming request's body. + :type body: str + :returns: True if the signature header is valid. + :rtype: bool + """ + + if callback_secret is None: + return False + normalized_headers = normalize_headers(headers) + signature = get_header(normalized_headers.get('x-sinch-signature')) + if signature is None: + return False + + expected_signature = compute_hmac_signature(body, callback_secret) + return hmac.compare_digest(signature, expected_signature) + + +def normalize_headers(headers: Dict[str, str]) -> Dict[str, str]: + """ + Normalize headers by converting keys to lowercase and filtering out None values + """ + return {k.lower(): v for k, v in headers.items() if v is not None} + + +def compute_hmac_signature(body: str, secret: str) -> str: + """ + Compute HMAC-SHA1 signature + """ + return hmac.new( + key=secret.encode('utf-8'), + msg=body.encode('utf-8') if isinstance(body, str) else body, + digestmod=hashlib.sha1 + ).hexdigest() + + +def get_header(header_value: Optional[Union[str, List[str]]]) -> Optional[str]: + """ + Extract header value, handling both string and list cases + """ + if header_value is None: + return None + if isinstance(header_value, list): + return header_value[0] if header_value else None + return header_value + + +def validate_sinch_event_signature_with_nonce( + callback_secret: str, + headers: Dict[str, str], + body: str +) -> bool: + """ + Validate signature headers for Sinch Event callbacks that use nonce and timestamp. + + :param callback_secret: Secret associated with the Sinch Event destination. + :type callback_secret: str + :param headers: Incoming request's headers. + :type headers: Dict[str, str] + :param body: Incoming request's body. + :type body: str + :returns: True if the ``X-Sinch-Webhook-Signature`` header and related nonce/timestamp headers are valid. + :rtype: bool + """ + if callback_secret is None: + return False + + normalized_headers = normalize_headers(headers) + signature = get_header(normalized_headers.get('x-sinch-webhook-signature')) + if signature is None: + return False + + nonce = get_header(normalized_headers.get('x-sinch-webhook-signature-nonce')) + timestamp = get_header(normalized_headers.get('x-sinch-webhook-signature-timestamp')) + + if nonce is None or timestamp is None: + return False + + body_as_string = body + if isinstance(body, dict): + body_as_string = json.dumps(body) + + signed_data = compute_signed_data(body_as_string, nonce, timestamp) + + expected_signature = calculate_sinch_event_signature(signed_data, callback_secret) + return hmac.compare_digest(signature, expected_signature) + + +def compute_signed_data(body: str, nonce: str, timestamp: str) -> str: + """ + Compute signed data for Sinch Event signature validation. + + Format: body.nonce.timestamp (with dots as separators) + """ + return f'{body}.{nonce}.{timestamp}' + + +def calculate_sinch_event_signature(signed_data: str, secret: str) -> str: + """ + Calculate Sinch Event signature using HMAC-SHA256 with Base64 encoding. + + :param signed_data: The data to sign (body.nonce.timestamp) + :type signed_data: str + :param secret: The secret key for HMAC + :type secret: str + :returns: Base64-encoded HMAC-SHA256 signature + :rtype: str + """ + return base64.b64encode( + hmac.new( + key=secret.encode('utf-8'), + msg=signed_data.encode('utf-8'), + digestmod=hashlib.sha256 + ).digest() + ).decode('utf-8') diff --git a/sinch/domains/authentication/sinch_events/v1/sinch_event_utils.py b/sinch/domains/authentication/sinch_events/v1/sinch_event_utils.py new file mode 100644 index 00000000..c65441aa --- /dev/null +++ b/sinch/domains/authentication/sinch_events/v1/sinch_event_utils.py @@ -0,0 +1,86 @@ +import json +import re +from datetime import datetime +from typing import Any, Dict, Optional, Union + + +def _content_type_from_headers(headers: Optional[Dict[str, str]]) -> str: + """Get Content-Type from headers dict (case-insensitive).""" + if not headers: + return "" + return headers.get("content-type") or headers.get("Content-Type") or "" + + +def _charset_from_content_type(content_type: str) -> str: + """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_payload( + payload: Union[str, bytes], headers: Optional[Dict[str, str]] = None +) -> str: + """ + Decode request body to str using Content-Type charset when payload is bytes. + + When payload is str, return as-is. When bytes, use charset from headers + (default utf-8); + """ + if isinstance(payload, str): + return payload + if not payload: + return "" + content_type = _content_type_from_headers(headers) + charset = _charset_from_content_type(content_type) + try: + return payload.decode(charset) + except (LookupError, UnicodeDecodeError): + raise + + +def parse_json(payload: str) -> Dict[str, Any]: + """ + Parse JSON string into a dictionary. + + :param payload: JSON string to parse. + :type payload: str + :returns: Parsed dictionary. + :rtype: Dict[str, Any] + :raises ValueError: If JSON parsing fails. + """ + try: + return json.loads(payload) + except json.JSONDecodeError as e: + raise ValueError(f"Failed to decode JSON: {e}") + + +def normalize_iso_timestamp(timestamp: str) -> datetime: + """ + Normalize a timestamp string to ensure compatibility with Python's `datetime.fromisoformat()`. + + - Ensures that the timestamp includes a UTC offset (e.g., "+00:00") if missing. + - Replaces trailing "Z" with "+00:00" to indicate UTC. + - Trims microseconds to 6 digits. + + :param timestamp: Timestamp string to normalize. + :type timestamp: str + :returns: Timezone-aware datetime object. + :rtype: datetime + :raises ValueError: If timestamp format is invalid. + """ + if timestamp.endswith("Z"): + timestamp = timestamp.replace("Z", "+00:00") + elif not re.search(r"(Z|[+-]\d{2}:?\d{2})$", timestamp): + timestamp += "+00:00" + match_ms = re.search(r"\.(\d{7,})(?=[+-])", timestamp) + if match_ms: + micro_trimmed = match_ms.group(1)[:6] + timestamp = re.sub( + r"\.\d{7,}(?=[+-])", f".{micro_trimmed}", timestamp + ) + try: + return datetime.fromisoformat(timestamp) + except ValueError as e: + raise ValueError(f"Invalid timestamp format: {e}") diff --git a/sinch/domains/conversation/__init__.py b/sinch/domains/conversation/__init__.py index 62f823dd..ec48a5a3 100644 --- a/sinch/domains/conversation/__init__.py +++ b/sinch/domains/conversation/__init__.py @@ -1,1092 +1,3 @@ -from typing import List +from sinch.domains.conversation.conversation import Conversation -from sinch.core.pagination import TokenBasedPaginator, AsyncTokenBasedPaginator - -from sinch.domains.conversation.models import ( - SinchConversationChannelIdentities, - SinchConversationRecipient, - ConversationChannel -) - -from sinch.domains.conversation.models.app.requests import ( - CreateConversationAppRequest, - DeleteConversationAppRequest, - GetConversationAppRequest, - UpdateConversationAppRequest -) - -from sinch.domains.conversation.models.app.responses import ( - CreateConversationAppResponse, - DeleteConversationAppResponse, - ListConversationAppsResponse, - GetConversationAppResponse, - UpdateConversationAppResponse -) - -from sinch.domains.conversation.models.contact.requests import ( - CreateConversationContactRequest, - UpdateConversationContactRequest, - ListConversationContactRequest, - DeleteConversationContactRequest, - GetConversationContactRequest, - MergeConversationContactsRequest, - GetConversationChannelProfileRequest -) - -from sinch.domains.conversation.models.contact.responses import ( - UpdateConversationContactResponse, - ListConversationContactsResponse, - DeleteConversationContactResponse, - MergeConversationContactsResponse, - CreateConversationContactResponse, - GetConversationContactResponse, - GetConversationChannelProfileResponse -) - -from sinch.domains.conversation.models.message.requests import ( - SendConversationMessageRequest, - ListConversationMessagesRequest, - DeleteConversationMessageRequest, - GetConversationMessageRequest -) - -from sinch.domains.conversation.models.message.responses import ( - SendConversationMessageResponse, - ListConversationMessagesResponse, - GetConversationMessageResponse, - DeleteConversationMessageResponse -) - -from sinch.domains.conversation.models.conversation.requests import ( - CreateConversationRequest, - ListConversationsRequest, - GetConversationRequest, - DeleteConversationRequest, - UpdateConversationRequest, - StopConversationRequest, - InjectMessageToConversationRequest -) - -from sinch.domains.conversation.models.conversation.responses import ( - SinchCreateConversationResponse, - SinchUpdateConversationResponse, - SinchGetConversationResponse, - SinchDeleteConversationResponse, - SinchListConversationsResponse, - SinchStopConversationResponse, - SinchInjectMessageResponse -) - -from sinch.domains.conversation.models.webhook.requests import ( - CreateConversationWebhookRequest, - GetConversationWebhookRequest, - DeleteConversationWebhookRequest, - UpdateConversationWebhookRequest, - ListConversationWebhookRequest -) - -from sinch.domains.conversation.models.webhook.responses import ( - CreateWebhookResponse, - GetWebhookResponse, - SinchListWebhooksResponse, - SinchDeleteWebhookResponse, - UpdateWebhookResponse -) - -from sinch.domains.conversation.models.templates.requests import ( - CreateConversationTemplateRequest, - GetConversationTemplateRequest, - DeleteConversationTemplateRequest, - UpdateConversationTemplateRequest -) - -from sinch.domains.conversation.models.templates.responses import ( - CreateConversationTemplateResponse, - UpdateConversationTemplateResponse, - DeleteConversationTemplateResponse, - ListConversationTemplatesResponse, - GetConversationTemplateResponse -) - -from sinch.domains.conversation.models.event.requests import SendConversationEventRequest -from sinch.domains.conversation.models.event.responses import SendConversationEventResponse - -from sinch.domains.conversation.models.opt_in_opt_out.requests import RegisterConversationOptInRequest -from sinch.domains.conversation.models.opt_in_opt_out.responses import RegisterConversationOptInResponse - -from sinch.domains.conversation.models.opt_in_opt_out.requests import RegisterConversationOptOutRequest -from sinch.domains.conversation.models.opt_in_opt_out.responses import RegisterConversationOptOutResponse - -from sinch.domains.conversation.models.capability.requests import QueryConversationCapabilityRequest -from sinch.domains.conversation.models.capability.responses import QueryConversationCapabilityResponse - -from sinch.domains.conversation.models.transcoding.requests import TranscodeConversationMessageRequest -from sinch.domains.conversation.models.transcoding.responses import TranscodeConversationMessageResponse - -from sinch.domains.conversation.endpoints.message.send_message import SendConversationMessageEndpoint -from sinch.domains.conversation.endpoints.message.list_message import ListConversationMessagesEndpoint -from sinch.domains.conversation.endpoints.message.get_message import GetConversationMessageEndpoint -from sinch.domains.conversation.endpoints.message.delete_message import DeleteConversationMessageEndpoint -from sinch.domains.conversation.endpoints.contact.list_contact import ListContactsEndpoint -from sinch.domains.conversation.endpoints.contact.create_contact import CreateConversationContactEndpoint -from sinch.domains.conversation.endpoints.contact.get_contact import GetContactEndpoint -from sinch.domains.conversation.endpoints.contact.delete_contact import DeleteContactEndpoint -from sinch.domains.conversation.endpoints.contact.update_contact import UpdateConversationContactEndpoint -from sinch.domains.conversation.endpoints.contact.merge_contacts import MergeConversationContactsEndpoint -from sinch.domains.conversation.endpoints.contact.get_channel_profile import GetChannelProfileEndpoint -from sinch.domains.conversation.endpoints.app.create_app import CreateConversationAppEndpoint -from sinch.domains.conversation.endpoints.app.delete_app import DeleteConversationAppEndpoint -from sinch.domains.conversation.endpoints.app.list_apps import ListAppsEndpoint -from sinch.domains.conversation.endpoints.app.get_app import GetAppEndpoint -from sinch.domains.conversation.endpoints.app.update_app import UpdateConversationAppEndpoint -from sinch.domains.conversation.endpoints.conversation.create_conversation import CreateConversationEndpoint -from sinch.domains.conversation.endpoints.conversation.list_conversations import ListConversationsEndpoint -from sinch.domains.conversation.endpoints.conversation.get_conversation import GetConversationEndpoint -from sinch.domains.conversation.endpoints.conversation.delete_conversation import DeleteConversationEndpoint -from sinch.domains.conversation.endpoints.conversation.update_conversation import UpdateConversationEndpoint -from sinch.domains.conversation.endpoints.conversation.stop_conversation import StopConversationEndpoint -from sinch.domains.conversation.endpoints.conversation.inject_message_to_conversation import ( - InjectMessageToConversationEndpoint -) -from sinch.domains.conversation.endpoints.webhooks.create_webhook import CreateWebhookEndpoint -from sinch.domains.conversation.endpoints.webhooks.list_webhooks import ListWebhooksEndpoint -from sinch.domains.conversation.endpoints.webhooks.get_webhook import GetWebhookEndpoint -from sinch.domains.conversation.endpoints.webhooks.delete_webhook import DeleteWebhookEndpoint -from sinch.domains.conversation.endpoints.webhooks.update_webhook import UpdateWebhookEndpoint -from sinch.domains.conversation.endpoints.templates.create_template import CreateTemplateEndpoint -from sinch.domains.conversation.endpoints.templates.list_templates import ListTemplatesEndpoint -from sinch.domains.conversation.endpoints.templates.get_template import GetTemplatesEndpoint -from sinch.domains.conversation.endpoints.templates.delete_template import DeleteTemplateEndpoint -from sinch.domains.conversation.endpoints.templates.update_template import UpdateTemplateEndpoint -from sinch.domains.conversation.endpoints.events import SendEventEndpoint -from sinch.domains.conversation.endpoints.transcode import TranscodeMessageEndpoint -from sinch.domains.conversation.endpoints.opt_in import RegisterOptInEndpoint -from sinch.domains.conversation.endpoints.opt_out import RegisterOptOutEndpoint -from sinch.domains.conversation.endpoints.capability import CapabilityQueryEndpoint - - -class ConversationMessage: - def __init__(self, sinch): - self._sinch = sinch - - def send( - self, - app_id: str, - recipient: dict, - message: dict, - callback_url: str = None, - channel_priority_order: list = None, - channel_properties: dict = None, - message_metadata: str = None, - conversation_metadata: dict = None, - queue: str = None, - ttl: str = None, - processing_strategy: str = None - ) -> SendConversationMessageResponse: - return self._sinch.configuration.transport.request( - SendConversationMessageEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=SendConversationMessageRequest( - app_id=app_id, - recipient=recipient, - message=message, - callback_url=callback_url, - channel_priority_order=channel_priority_order, - channel_properties=channel_properties, - message_metadata=message_metadata, - conversation_metadata=conversation_metadata, - queue=queue, - ttl=ttl, - processing_strategy=processing_strategy - ) - ) - ) - - def get( - self, - message_id: str, - messages_source: str = None - ) -> GetConversationMessageResponse: - return self._sinch.configuration.transport.request( - GetConversationMessageEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=GetConversationMessageRequest( - message_id=message_id, - messages_source=messages_source - ) - ) - ) - - def delete( - self, - message_id: str, - messages_source: str = None - ) -> DeleteConversationMessageResponse: - return self._sinch.configuration.transport.request( - DeleteConversationMessageEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=DeleteConversationMessageRequest( - message_id=message_id, - messages_source=messages_source - ) - ) - ) - - def list( - self, - conversation_id: str = None, - contact_id: str = None, - app_id: str = None, - page_size: int = None, - page_token: str = None, - view: str = None, - messages_source: str = None, - only_recipient_originated: bool = None - ) -> ListConversationMessagesResponse: - return TokenBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListConversationMessagesEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ListConversationMessagesRequest( - contact_id=contact_id, - conversation_id=conversation_id, - app_id=app_id, - page_size=page_size, - page_token=page_token, - view=view, - messages_source=messages_source, - only_recipient_originated=only_recipient_originated - ) - ) - ) - - -class ConversationMessageWithAsyncPagination(ConversationMessage): - async def list( - self, - conversation_id: str = None, - contact_id: str = None, - app_id: str = None, - page_size: int = None, - page_token: str = None, - view: str = None, - messages_source: str = None, - only_recipient_originated: bool = None - ) -> ListConversationMessagesResponse: - return await AsyncTokenBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListConversationMessagesEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ListConversationMessagesRequest( - contact_id=contact_id, - conversation_id=conversation_id, - app_id=app_id, - page_size=page_size, - page_token=page_token, - view=view, - messages_source=messages_source, - only_recipient_originated=only_recipient_originated - ) - ) - ) - - -class ConversationApp: - def __init__(self, sinch): - self._sinch = sinch - - def create( - self, - display_name: str, - channel_credentials: list, - conversation_metadata_report_view: str = None, - retention_policy: dict = None, - dispatch_retention_policy: dict = None, - processing_mode: str = None - ) -> CreateConversationAppResponse: - """ - Creates a new Conversation API app with one or more configured channels. - The ID of the app is generated at creation and is returned in the response. - https://developers.sinch.com/docs/conversation/api-reference/conversation/tag/App/#tag/App/operation/App_CreateApp - """ - return self._sinch.configuration.transport.request( - CreateConversationAppEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=CreateConversationAppRequest( - display_name=display_name, - channel_credentials=channel_credentials, - conversation_metadata_report_view=conversation_metadata_report_view, - retention_policy=retention_policy, - dispatch_retention_policy=dispatch_retention_policy, - processing_mode=processing_mode - ) - ) - ) - - def delete(self, app_id: str) -> DeleteConversationAppResponse: - """ - Deletes the app identified by the app_id. - """ - return self._sinch.configuration.transport.request( - DeleteConversationAppEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=DeleteConversationAppRequest(app_id) - ) - ) - - def list(self) -> ListConversationAppsResponse: - """ - Lists all apps for the project identified by the project_id. - Returns the information as an array of app objects in the response. - """ - return self._sinch.configuration.transport.request( - ListAppsEndpoint( - project_id=self._sinch.configuration.project_id - ) - ) - - def get(self, app_id: str) -> GetConversationAppResponse: - """ - Returns the configuration information of the app, specified by the app_id, in the response. - """ - return self._sinch.configuration.transport.request( - GetAppEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=GetConversationAppRequest( - app_id=app_id - ) - ) - ) - - def update( - self, - app_id: str, - display_name: str, - channel_credentials: list = None, - update_mask=None, - conversation_metadata_report_view=None, - retention_policy=None, - dispatch_retention_policy=None, - processing_mode=None - ) -> UpdateConversationAppResponse: - """ - Updates an existing Conversation API app with new configuration options defined in the request. - The details of the updated app are returned in the response. - """ - return self._sinch.configuration.transport.request( - UpdateConversationAppEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=UpdateConversationAppRequest( - app_id=app_id, - display_name=display_name, - channel_credentials=channel_credentials, - update_mask=update_mask, - conversation_metadata_report_view=conversation_metadata_report_view, - retention_policy=retention_policy, - dispatch_retention_policy=dispatch_retention_policy, - processing_mode=processing_mode - ) - ) - ) - - -class ConversationContact: - def __init__(self, sinch): - self._sinch = sinch - - def update( - self, - contact_id: str, - channel_identities: List[SinchConversationChannelIdentities] = None, - language: str = None, - display_name: str = None, - email: str = None, - external_id: str = None, - metadata: str = None, - channel_priority: list = None - ) -> UpdateConversationContactResponse: - """ - Updates an existing Conversation API contact with new configuration options defined in the request. - The details of the updated contact are returned in the response. - """ - return self._sinch.configuration.transport.request( - UpdateConversationContactEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=UpdateConversationContactRequest( - channel_identities=channel_identities, - language=language, - display_name=display_name, - email=email, - external_id=external_id, - metadata=metadata, - channel_priority=channel_priority, - id=contact_id - ) - ) - ) - - def create( - self, - channel_identities: List[SinchConversationChannelIdentities], - language: str, - display_name: str = None, - email: str = None, - external_id: str = None, - metadata: str = None, - channel_priority: list = None - ) -> CreateConversationContactResponse: - """ - Creates a new Conversation API contact. - The ID of the contact is generated at creation and is returned in the response. - """ - return self._sinch.configuration.transport.request( - CreateConversationContactEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=CreateConversationContactRequest( - channel_identities=channel_identities, - language=language, - display_name=display_name, - email=email, - external_id=external_id, - metadata=metadata, - channel_priority=channel_priority - ) - ) - ) - - def delete(self, contact_id: str) -> DeleteConversationContactResponse: - """ - Deletes the Conversation API contact identified by the contact_id. - """ - return self._sinch.configuration.transport.request( - DeleteContactEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=DeleteConversationContactRequest( - contact_id=contact_id - ) - ) - ) - - def get(self, contact_id: str) -> GetConversationContactResponse: - """ - Returns the configuration information of the - Conversation API contact, specified by the contact_id, in the response. - """ - return self._sinch.configuration.transport.request( - GetContactEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=GetConversationContactRequest( - contact_id=contact_id - ) - ) - ) - - def list( - self, - page_size: int = None, - page_token: str = None, - external_id: str = None, - channel: str = None, - identity: str = None - ) -> ListConversationContactsResponse: - """ - Lists all Conversation API contacts for the project identified by the project_id. - Returns the information as an array of contact objects in the response. - """ - return TokenBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListContactsEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ListConversationContactRequest( - page_size=page_size, - page_token=page_token, - external_id=external_id, - channel=channel, - identity=identity - ) - ) - ) - - def merge( - self, - source_id: str, - destination_id: str, - strategy: str = None - ) -> MergeConversationContactsResponse: - """ - Merges two existing Conversation API contacts. - The contact specified by the destination_id will be kept. - The contact specified by the source_id will be deleted. - All conversations from source contact are merged into destination. - Channel identities and optional fields from source contact are only - merged if corresponding entries do not exist in destination. - """ - return self._sinch.configuration.transport.request( - MergeConversationContactsEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=MergeConversationContactsRequest( - destination_id=destination_id, - strategy=strategy, - source_id=source_id - ) - ) - ) - - def get_channel_profile( - self, - app_id: str, - recipient: SinchConversationRecipient, - channel: ConversationChannel, - ) -> GetConversationChannelProfileResponse: - """ - Returns the user profile information for the specified recipient on the specified channel. - This request is not supported for all Conversation API channels. - """ - return self._sinch.configuration.transport.request( - GetChannelProfileEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=GetConversationChannelProfileRequest( - app_id=app_id, - recipient=recipient, - channel=channel - ) - ) - ) - - -class ConversationContactWithAsyncPagination(ConversationContact): - async def list( - self, - page_size: int = None, - page_token: str = None, - external_id: str = None, - channel: str = None, - identity: str = None - ) -> ListConversationContactsResponse: - return await AsyncTokenBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListContactsEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ListConversationContactRequest( - page_size=page_size, - page_token=page_token, - external_id=external_id, - channel=channel, - identity=identity - ) - ) - ) - - -class ConversationEvent: - def __init__(self, sinch): - self._sinch = sinch - - def send( - self, - app_id: str, - recipient: dict, - event: dict, - callback_url: str = None, - channel_priority_order: str = None, - event_metadata: str = None, - queue: str = None - ) -> SendConversationEventResponse: - return self._sinch.configuration.transport.request( - SendEventEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=SendConversationEventRequest( - app_id=app_id, - recipient=recipient, - event=event, - callback_url=callback_url, - channel_priority_order=channel_priority_order, - event_metadata=event_metadata, - queue=queue - ) - ) - ) - - -class ConversationTranscoding: - def __init__(self, sinch): - self._sinch = sinch - - def transcode_message( - self, - app_id: str, - app_message: dict, - channels: list, - from_: str = None, - to: str = None - ) -> TranscodeConversationMessageResponse: - return self._sinch.configuration.transport.request( - TranscodeMessageEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=TranscodeConversationMessageRequest( - app_id=app_id, - app_message=app_message, - channels=channels, - from_=from_, - to=to - ) - ) - ) - - -class ConversationOptIn: - def __init__(self, sinch): - self._sinch = sinch - - def register( - self, - app_id: str, - channels: list, - recipient: dict, - request_id: str = None, - processing_strategy: str = None - ) -> RegisterConversationOptInResponse: - return self._sinch.configuration.transport.request( - RegisterOptInEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=RegisterConversationOptInRequest( - app_id=app_id, - recipient=recipient, - channels=channels, - request_id=request_id, - processing_strategy=processing_strategy - ) - ) - ) - - -class ConversationOptOut: - def __init__(self, sinch): - self._sinch = sinch - - def register( - self, - app_id: str, - channels: list, - recipient: dict, - request_id: str = None, - processing_strategy: str = None - ) -> RegisterConversationOptOutResponse: - return self._sinch.configuration.transport.request( - RegisterOptOutEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=RegisterConversationOptOutRequest( - app_id=app_id, - recipient=recipient, - channels=channels, - request_id=request_id, - processing_strategy=processing_strategy - ) - ) - ) - - -class ConversationCapability: - def __init__(self, sinch): - self._sinch = sinch - - def query( - self, - app_id: str, - recipient: dict, - request_id: str = None - ) -> QueryConversationCapabilityResponse: - return self._sinch.configuration.transport.request( - CapabilityQueryEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=QueryConversationCapabilityRequest( - app_id=app_id, - recipient=recipient, - request_id=request_id - ) - ) - ) - - -class ConversationTemplate: - def __init__(self, sinch): - self._sinch = sinch - - def create( - self, - translations: list, - default_translation: str, - channel: str = None, - create_time: str = None, - description: str = None, - id: str = None, - update_time: str = None - ) -> CreateConversationTemplateResponse: - return self._sinch.configuration.transport.request( - CreateTemplateEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=CreateConversationTemplateRequest( - channel=channel, - create_time=create_time, - description=description, - id=id, - translations=translations, - default_translation=default_translation, - update_time=update_time - ) - ) - ) - - def list(self) -> ListConversationTemplatesResponse: - return self._sinch.configuration.transport.request( - ListTemplatesEndpoint( - project_id=self._sinch.configuration.project_id - ) - ) - - def get(self, template_id: str) -> GetConversationTemplateResponse: - return self._sinch.configuration.transport.request( - GetTemplatesEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=GetConversationTemplateRequest( - template_id=template_id - ) - ) - ) - - def update( - self, - template_id: str, - translations: list, - default_translation: str, - id: str = None, - update_mask: str = None, - channel: str = None, - create_time: str = None, - description: str = None, - update_time: str = None - ) -> UpdateConversationTemplateResponse: - return self._sinch.configuration.transport.request( - UpdateTemplateEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=UpdateConversationTemplateRequest( - channel=channel, - create_time=create_time, - description=description, - id=id, - translations=translations, - default_translation=default_translation, - update_time=update_time, - update_mask=update_mask, - template_id=template_id - ) - ) - ) - - def delete(self, template_id: str) -> DeleteConversationTemplateResponse: - return self._sinch.configuration.transport.request( - DeleteTemplateEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=DeleteConversationTemplateRequest( - template_id=template_id - ) - ) - ) - - -class ConversationWebhook: - def __init__(self, sinch): - self._sinch = sinch - - def create( - self, - app_id: str, - target: str, - triggers: list, - client_credentials: dict = None, - secret: str = None, - target_type: str = None - ) -> CreateWebhookResponse: - return self._sinch.configuration.transport.request( - CreateWebhookEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=CreateConversationWebhookRequest( - app_id=app_id, - target=target, - triggers=triggers, - client_credentials=client_credentials, - secret=secret, - target_type=target_type - ) - ) - ) - - def update( - self, - webhook_id: str, - app_id: str, - target: str, - triggers: list, - update_mask: str = None, - client_credentials: dict = None, - secret: str = None, - target_type: str = None - ) -> UpdateWebhookResponse: - return self._sinch.configuration.transport.request( - UpdateWebhookEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=UpdateConversationWebhookRequest( - app_id=app_id, - target=target, - triggers=triggers, - client_credentials=client_credentials, - secret=secret, - target_type=target_type, - update_mask=update_mask, - webhook_id=webhook_id - ) - ) - ) - - def list(self, app_id: str) -> SinchListWebhooksResponse: - return self._sinch.configuration.transport.request( - ListWebhooksEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ListConversationWebhookRequest( - app_id=app_id - ) - ) - ) - - def get(self, webhook_id: str) -> GetWebhookResponse: - return self._sinch.configuration.transport.request( - GetWebhookEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=GetConversationWebhookRequest( - webhook_id=webhook_id - ) - ) - ) - - def delete(self, webhook_id: str) -> SinchDeleteWebhookResponse: - return self._sinch.configuration.transport.request( - DeleteWebhookEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=DeleteConversationWebhookRequest( - webhook_id=webhook_id - ) - ) - ) - - -class ConversationConversation: - def __init__(self, sinch): - self._sinch = sinch - - def create( - self, - id: str = None, - metadata: str = None, - conversation_metadata: dict = None, - contact_id: str = None, - app_id: str = None, - active_channel: str = None, - active: bool = None, - ) -> SinchCreateConversationResponse: - return self._sinch.configuration.transport.request( - CreateConversationEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=CreateConversationRequest( - app_id=app_id, - contact_id=contact_id, - id=id, - metadata=metadata, - conversation_metadata=conversation_metadata, - active_channel=active_channel, - active=active - ) - ) - ) - - def list( - self, - only_active: bool, - page_size: int = None, - page_token: str = None, - app_id: str = None, - contact_id: str = None - ) -> SinchListConversationsResponse: - return TokenBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListConversationsEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ListConversationsRequest( - only_active=only_active, - page_size=page_size, - page_token=page_token, - app_id=app_id, - contact_id=contact_id - ) - ) - ) - - def get(self, conversation_id: str) -> SinchGetConversationResponse: - return self._sinch.configuration.transport.request( - GetConversationEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=GetConversationRequest( - conversation_id=conversation_id - ) - ) - ) - - def delete(self, conversation_id: str) -> SinchDeleteConversationResponse: - return self._sinch.configuration.transport.request( - DeleteConversationEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=DeleteConversationRequest( - conversation_id=conversation_id - ) - ) - ) - - def update( - self, - conversation_id: str, - update_mask: str = None, - metadata_update_strategy: str = None, - metadata: str = None, - conversation_metadata: dict = None, - contact_id: str = None, - app_id: str = None, - active_channel: str = None, - active: bool = None - ) -> SinchUpdateConversationResponse: - return self._sinch.configuration.transport.request( - UpdateConversationEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=UpdateConversationRequest( - app_id=app_id, - contact_id=contact_id, - conversation_id=conversation_id, - metadata=metadata, - conversation_metadata=conversation_metadata, - active_channel=active_channel, - active=active, - metadata_update_strategy=metadata_update_strategy, - update_mask=update_mask - ) - ) - ) - - def stop(self, conversation_id: str) -> SinchStopConversationResponse: - return self._sinch.configuration.transport.request( - StopConversationEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=StopConversationRequest( - conversation_id=conversation_id - ) - ) - ) - - def inject_message_to_conversation( - self, - conversation_id: str, - accept_time: str = None, - app_message: dict = None, - channel_identity: dict = None, - contact_id: str = None, - contact_message: dict = None, - direction: str = None, - metadata: str = None - ) -> SinchInjectMessageResponse: - return self._sinch.configuration.transport.request( - InjectMessageToConversationEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=InjectMessageToConversationRequest( - conversation_id=conversation_id, - accept_time=accept_time, - app_message=app_message, - channel_identity=channel_identity, - contact_id=contact_id, - contact_message=contact_message, - direction=direction, - metadata=metadata - ) - ) - ) - - -class ConversationConversationWithAsyncPagination(ConversationConversation): - async def list( - self, - only_active: bool, - page_size: int = None, - page_token: str = None, - app_id: str = None, - contact_id: str = None - ) -> SinchListConversationsResponse: - return await AsyncTokenBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListConversationsEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ListConversationsRequest( - only_active=only_active, - page_size=page_size, - page_token=page_token, - app_id=app_id, - contact_id=contact_id - ) - ) - ) - - -class ConversationBase: - """ - Documentation for the Conversation API: https://developers.sinch.com/docs/conversation/ - """ - - def __init__(self, sinch): - self._sinch = sinch - - -class Conversation(ConversationBase): - """ - Synchronous version of the Conversation Domain - """ - __doc__ += ConversationBase.__doc__ - - def __init__(self, sinch): - super(Conversation, self).__init__(sinch) - self.message = ConversationMessage(self._sinch) - self.app = ConversationApp(self._sinch) - self.contact = ConversationContact(self._sinch) - self.event = ConversationEvent(self._sinch) - self.transcoding = ConversationTranscoding(self._sinch) - self.opt_in = ConversationOptIn(self._sinch) - self.opt_out = ConversationOptOut(self._sinch) - self.capability = ConversationCapability(self._sinch) - self.template = ConversationTemplate(self._sinch) - self.webhook = ConversationWebhook(self._sinch) - self.conversation = ConversationConversation(self._sinch) - - -class ConversationAsync(ConversationBase): - """ - Asynchronous version of the Conversation Domain - """ - __doc__ += ConversationBase.__doc__ - - def __init__(self, sinch): - super(ConversationAsync, self).__init__(sinch) - self.message = ConversationMessageWithAsyncPagination(self._sinch) - self.app = ConversationApp(self._sinch) - self.contact = ConversationContactWithAsyncPagination(self._sinch) - self.event = ConversationEvent(self._sinch) - self.transcoding = ConversationTranscoding(self._sinch) - self.opt_in = ConversationOptIn(self._sinch) - self.opt_out = ConversationOptOut(self._sinch) - self.capability = ConversationCapability(self._sinch) - self.template = ConversationTemplate(self._sinch) - self.webhook = ConversationWebhook(self._sinch) - self.conversation = ConversationConversationWithAsyncPagination(self._sinch) +__all__ = ["Conversation"] diff --git a/sinch/domains/conversation/models/app/__init__.py b/sinch/domains/conversation/api/__init__.py similarity index 100% rename from sinch/domains/conversation/models/app/__init__.py rename to sinch/domains/conversation/api/__init__.py diff --git a/sinch/domains/conversation/api/v1/__init__.py b/sinch/domains/conversation/api/v1/__init__.py new file mode 100644 index 00000000..55948540 --- /dev/null +++ b/sinch/domains/conversation/api/v1/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.conversation.api.v1.messages_apis import Messages + +__all__ = [ + "Messages", +] diff --git a/sinch/domains/conversation/api/v1/base/__init__.py b/sinch/domains/conversation/api/v1/base/__init__.py new file mode 100644 index 00000000..5fdfb440 --- /dev/null +++ b/sinch/domains/conversation/api/v1/base/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.conversation.api.v1.base.base_conversation import ( + BaseConversation, +) + +__all__ = ["BaseConversation"] diff --git a/sinch/domains/conversation/api/v1/base/base_conversation.py b/sinch/domains/conversation/api/v1/base/base_conversation.py new file mode 100644 index 00000000..d194a5a3 --- /dev/null +++ b/sinch/domains/conversation/api/v1/base/base_conversation.py @@ -0,0 +1,23 @@ +class BaseConversation: + """Base class for handling Sinch Conversation operations.""" + + def __init__(self, sinch): + self._sinch = sinch + + def _request(self, endpoint_class, request_data): + """ + A helper method to make requests to endpoints. + + Args: + endpoint_class: The endpoint class to call. + request_data: The request data to pass to the endpoint. + + Returns: + The response from the Sinch transport request. + """ + return self._sinch.configuration.transport.request( + endpoint_class( + project_id=self._sinch.configuration.project_id, + request_data=request_data, + ) + ) diff --git a/sinch/domains/verification/exceptions.py b/sinch/domains/conversation/api/v1/exceptions.py similarity index 57% rename from sinch/domains/verification/exceptions.py rename to sinch/domains/conversation/api/v1/exceptions.py index 91d913a8..08310e9a 100644 --- a/sinch/domains/verification/exceptions.py +++ b/sinch/domains/conversation/api/v1/exceptions.py @@ -1,5 +1,5 @@ from sinch.core.exceptions import SinchException -class VerificationException(SinchException): +class ConversationException(SinchException): pass diff --git a/sinch/domains/conversation/api/v1/internal/__init__.py b/sinch/domains/conversation/api/v1/internal/__init__.py new file mode 100644 index 00000000..95c46c42 --- /dev/null +++ b/sinch/domains/conversation/api/v1/internal/__init__.py @@ -0,0 +1,17 @@ +from sinch.domains.conversation.api.v1.internal.messages_endpoints import ( + DeleteMessageEndpoint, + GetMessageEndpoint, + ListLastMessagesByChannelIdentityEndpoint, + ListMessagesEndpoint, + SendMessageEndpoint, + UpdateMessageMetadataEndpoint, +) + +__all__ = [ + "DeleteMessageEndpoint", + "GetMessageEndpoint", + "ListLastMessagesByChannelIdentityEndpoint", + "ListMessagesEndpoint", + "SendMessageEndpoint", + "UpdateMessageMetadataEndpoint", +] diff --git a/sinch/domains/conversation/api/v1/internal/base/__init__.py b/sinch/domains/conversation/api/v1/internal/base/__init__.py new file mode 100644 index 00000000..bb2a6da4 --- /dev/null +++ b/sinch/domains/conversation/api/v1/internal/base/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.conversation.api.v1.internal.base.conversation_endpoint import ( + ConversationEndpoint, +) + +__all__ = ["ConversationEndpoint"] diff --git a/sinch/domains/conversation/api/v1/internal/base/conversation_endpoint.py b/sinch/domains/conversation/api/v1/internal/base/conversation_endpoint.py new file mode 100644 index 00000000..2cd6f35f --- /dev/null +++ b/sinch/domains/conversation/api/v1/internal/base/conversation_endpoint.py @@ -0,0 +1,114 @@ +import re +from abc import ABC +from typing import Type, Union, get_origin, get_args +from sinch.core.models.http_response import HTTPResponse +from sinch.core.endpoint import HTTPEndpoint +from sinch.core.types import BM +from sinch.domains.conversation.api.v1.exceptions import ConversationException + + +class ConversationEndpoint(HTTPEndpoint, ABC): + def __init__(self, project_id: str, request_data: BM): + super().__init__(project_id, request_data) + + def build_url(self, sinch) -> str: + if not self.ENDPOINT_URL: + raise NotImplementedError( + f"ENDPOINT_URL must be defined in the Conversation endpoint subclass " + f"'{self.__class__.__name__}'." + ) + + origin = sinch.configuration.get_conversation_origin() + + return self.ENDPOINT_URL.format( + origin=origin, + project_id=self.project_id, + **vars(self.request_data), + ) + + def _get_path_params_from_url(self) -> set: + """ + Extracts path parameters from ENDPOINT_URL template. + + Returns: + set: Set of path parameter names that should be excluded from request body and query params. + """ + if not self.ENDPOINT_URL: + return set() + + # Extract all placeholders from the URL template (e.g., {message_id}, {project_id}) + path_params = set(re.findall(r"\{(\w+)\}", self.ENDPOINT_URL)) + + # Exclude 'origin' and 'project_id' as they are always path params but not from request_data + path_params.discard("origin") + path_params.discard("project_id") + + return path_params + + def build_query_params(self) -> dict: + """ + Constructs the query parameters for the endpoint. + + Returns: + dict: The query parameters to be sent with the API request. + """ + return {} + + def request_body(self) -> str: + """ + Returns the request body as a JSON string. + + Returns: + str: The request body as a JSON string. + """ + return "" + + def process_response_model( + self, response_body: dict, response_model: Type[BM] + ) -> BM: + """ + Processes the response body and maps it to a response model. + + Args: + response_body (dict): The raw response body. + response_model (type): The Pydantic model class or Union type to map the response. + + Returns: + Parsed response object. + """ + try: + origin = get_origin(response_model) + # Check if response_model is a Union type + if origin is Union: + # For Union types, try to validate against each type in the Union sequentially + # This handles cases where TypeAdapter might not be fully defined + union_args = get_args(response_model) + last_error = None + + # Try each type in the Union until one succeeds + for union_type in union_args: + try: + return union_type.model_validate(response_body) + except Exception as e: + last_error = e + continue + + # If all Union types failed, raise an error with the last error details + if last_error is not None: + raise ValueError( + f"Invalid response structure: None of the Union types matched. " + f"Last error: {last_error}" + ) from last_error + + # Use standard model_validate for regular Pydantic models + return response_model.model_validate(response_body) + except Exception as e: + raise ValueError(f"Invalid response structure: {e}") from e + + def handle_response(self, response: HTTPResponse): + if response.status_code >= 400: + raise ConversationException( + message=f"{response.body['error'].get('message')} {response.body['error'].get('status')}", + response=response, + is_from_server=True, + ) diff --git a/sinch/domains/conversation/api/v1/internal/messages_endpoints.py b/sinch/domains/conversation/api/v1/internal/messages_endpoints.py new file mode 100644 index 00000000..9d98efcd --- /dev/null +++ b/sinch/domains/conversation/api/v1/internal/messages_endpoints.py @@ -0,0 +1,236 @@ +import json +from sinch.core.enums import HTTPAuthentication, HTTPMethods +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.conversation.models.v1.messages.internal.request import ( + ListMessagesRequest, + ListLastMessagesByChannelIdentityRequest, + MessageIdRequest, + UpdateMessageMetadataRequest, + SendMessageRequest, +) +from sinch.domains.conversation.models.v1.messages.internal import ( + ListMessagesResponse, +) +from sinch.domains.conversation.models.v1.messages.response.types import ( + ConversationMessageResponse, +) +from sinch.domains.conversation.models.v1.messages.response import ( + SendMessageResponse, +) +from sinch.domains.conversation.api.v1.internal.base import ( + ConversationEndpoint, +) +from sinch.domains.conversation.api.v1.exceptions import ConversationException + + +class ListMessagesResponseMixin: + """ + Mixin for endpoints that return ListMessagesResponse; centralizes response handling. + """ + + def handle_response(self, response: HTTPResponse) -> ListMessagesResponse: + try: + super().handle_response(response) + except ConversationException as e: + raise ConversationException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, ListMessagesResponse) + + +class MessageEndpoint(ConversationEndpoint): + """ + Base class for message-related endpoints that share common query parameter handling. + """ + + QUERY_PARAM_FIELDS = {"messages_source"} + BODY_PARAM_FIELDS = set() + + def build_query_params(self) -> dict: + path_params = self._get_path_params_from_url() + exclude_set = path_params.union(self.BODY_PARAM_FIELDS) + query_params = self.request_data.model_dump( + include=self.QUERY_PARAM_FIELDS, + exclude_none=True, + by_alias=True, + exclude=exclude_set, + ) + return query_params + + +class ListMessagesEndpoint(ListMessagesResponseMixin, MessageEndpoint): + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + QUERY_PARAM_FIELDS = { + "app_id", + "channel", + "channel_identity", + "contact_id", + "conversation_id", + "direction", + "end_time", + "messages_source", + "only_recipient_originated", + "page_size", + "page_token", + "start_time", + "view", + } + + def __init__(self, project_id: str, request_data: ListMessagesRequest): + super(ListMessagesEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + +class ListLastMessagesByChannelIdentityEndpoint( + ListMessagesResponseMixin, ConversationEndpoint +): + ENDPOINT_URL = ( + "{origin}/v1/projects/{project_id}/messages:fetch-last-message" + ) + HTTP_METHOD = HTTPMethods.POST.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__( + self, + project_id: str, + request_data: ListLastMessagesByChannelIdentityRequest, + ): + super(ListLastMessagesByChannelIdentityEndpoint, self).__init__( + project_id, request_data + ) + self.project_id = project_id + self.request_data = request_data + + def request_body(self): + request_data_dict = self.request_data.model_dump( + mode="json", by_alias=True, exclude_none=True + ) + return json.dumps(request_data_dict) + + +class DeleteMessageEndpoint(MessageEndpoint): + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages/{message_id}" + HTTP_METHOD = HTTPMethods.DELETE.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: MessageIdRequest): + super(DeleteMessageEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def handle_response(self, response: HTTPResponse): + try: + super(DeleteMessageEndpoint, self).handle_response(response) + except ConversationException as e: + raise ConversationException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + + +class GetMessageEndpoint(MessageEndpoint): + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages/{message_id}" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: MessageIdRequest): + super(GetMessageEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def handle_response( + self, response: HTTPResponse + ) -> ConversationMessageResponse: + try: + super(GetMessageEndpoint, self).handle_response(response) + except ConversationException as e: + raise ConversationException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model( + response.body, ConversationMessageResponse + ) + + +class UpdateMessageMetadataEndpoint(MessageEndpoint): + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages/{message_id}" + HTTP_METHOD = HTTPMethods.PATCH.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + BODY_PARAM_FIELDS = {"metadata"} + + def __init__( + self, project_id: str, request_data: UpdateMessageMetadataRequest + ): + super(UpdateMessageMetadataEndpoint, self).__init__( + project_id, request_data + ) + self.project_id = project_id + self.request_data = request_data + + def request_body(self): + path_params = self._get_path_params_from_url() + exclude_set = path_params.union(self.QUERY_PARAM_FIELDS) + request_data = self.request_data.model_dump( + include=self.BODY_PARAM_FIELDS, + by_alias=True, + exclude_none=True, + exclude=exclude_set, + ) + return json.dumps(request_data) + + def handle_response( + self, response: HTTPResponse + ) -> ConversationMessageResponse: + try: + super(UpdateMessageMetadataEndpoint, self).handle_response( + response + ) + except ConversationException as e: + raise ConversationException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model( + response.body, ConversationMessageResponse + ) + + +class SendMessageEndpoint(ConversationEndpoint): + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages:send" + HTTP_METHOD = HTTPMethods.POST.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: SendMessageRequest): + super(SendMessageEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def request_body(self): + request_data_dict = self.request_data.model_dump( + mode="json", + by_alias=True, + exclude_none=True, + ) + return json.dumps(request_data_dict) + + def handle_response(self, response: HTTPResponse) -> SendMessageResponse: + try: + super(SendMessageEndpoint, self).handle_response(response) + except ConversationException as e: + raise ConversationException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, SendMessageResponse) diff --git a/sinch/domains/conversation/api/v1/messages_apis.py b/sinch/domains/conversation/api/v1/messages_apis.py new file mode 100644 index 00000000..c4833899 --- /dev/null +++ b/sinch/domains/conversation/api/v1/messages_apis.py @@ -0,0 +1,1278 @@ +from datetime import datetime +from typing import Any, Dict, List, Optional, Union +from sinch.core.pagination import Paginator, TokenBasedPaginator +from sinch.domains.conversation.models.v1.messages.internal.request import ( + ListMessagesRequest, + ListLastMessagesByChannelIdentityRequest, + MessageIdRequest, + UpdateMessageMetadataRequest, + SendMessageRequest, + SendMessageRequestBody, +) +from sinch.domains.conversation.models.v1.messages.response import ( + SendMessageResponse, +) +from sinch.domains.conversation.models.v1.messages.response.types import ( + ConversationMessageResponse, +) +from sinch.domains.conversation.models.v1.messages.types import ( + ConversationChannelType, + ConversationDirectionType, + ConversationMessagesViewType, + MessageContentType, + MessageQueueType, + MessageSourceType, + MetadataUpdateStrategyType, + ProcessingStrategyType, + CardMessageDict, + CarouselMessageDict, + ChoiceMessageDict, + ContactInfoMessageDict, + ListMessageDict, + LocationMessageDict, + MediaPropertiesDict, + TemplateMessageDict, + ChannelRecipientIdentityDict, + SendMessageRequestBodyDict, +) +from sinch.domains.conversation.models.v1.messages.categories.text import ( + TextMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.card import ( + CardMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.carousel import ( + CarouselMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.choice import ( + ChoiceMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.contactinfo import ( + ContactInfoMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.list import ( + ListMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.location import ( + LocationMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.media import ( + MediaProperties, +) +from sinch.domains.conversation.models.v1.messages.categories.template import ( + TemplateMessage, +) +from sinch.domains.conversation.api.v1.internal import ( + DeleteMessageEndpoint, + ListLastMessagesByChannelIdentityEndpoint, + GetMessageEndpoint, + ListMessagesEndpoint, + UpdateMessageMetadataEndpoint, + SendMessageEndpoint, +) +from sinch.domains.conversation.api.v1.base import BaseConversation +from sinch.domains.conversation.api.v1.utils import ( + build_recipient_dict, + coerce_recipient, + split_send_kwargs, +) + + +class Messages(BaseConversation): + def delete( + self, + message_id: str, + messages_source: Optional[MessageSourceType] = None, + **kwargs, + ) -> None: + """ + Delete a specific message by its ID. Note that this operation deletes the message from Conversation API storage; + this operation does not affect messages already delivered to recipients' handsets. Also note that removing all + messages of a conversation will not automatically delete the + conversation. + + :param message_id: The unique ID of the message. (required) + :type message_id: str + :param messages_source: Specifies the message source for which the request will be processed. Used for + operations on messages in Dispatch Mode. Defaults to `CONVERSATION_SOURCE` when not specified. For more information, + see [Processing Modes](https://developers.sinch.com/docs/conversation/processing-modes/). + (optional) + :type messages_source: Optional[MessageSourceType] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: None + :rtype: None + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + request_data = MessageIdRequest( + message_id=message_id, messages_source=messages_source, **kwargs + ) + return self._request(DeleteMessageEndpoint, request_data) + + def get( + self, + message_id: str, + messages_source: Optional[MessageSourceType] = None, + **kwargs, + ) -> ConversationMessageResponse: + """ + Retrieves a specific message by its ID. + + :param message_id: The unique ID of the message. (required) + :type message_id: str + :param messages_source: Specifies the message source for which the request will be processed. Used for + operations on messages in Dispatch Mode. Defaults to `CONVERSATION_SOURCE` when not specified. For more information, + see [Processing Modes](https://developers.sinch.com/docs/conversation/processing-modes/). + (optional) + :type messages_source: Optional[MessageSourceType] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: ConversationMessageResponse + :rtype: ConversationMessageResponse + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + request_data = MessageIdRequest( + message_id=message_id, messages_source=messages_source, **kwargs + ) + return self._request(GetMessageEndpoint, request_data) + + def list( + self, + page_size: Optional[int] = None, + page_token: Optional[str] = None, + conversation_id: Optional[str] = None, + contact_id: Optional[str] = None, + app_id: Optional[str] = None, + channel_identity: Optional[str] = None, + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, + view: Optional[ConversationMessagesViewType] = None, + messages_source: Optional[MessageSourceType] = None, + only_recipient_originated: Optional[bool] = None, + channel: Optional[ConversationChannelType] = None, + direction: Optional[ConversationDirectionType] = None, + **kwargs, + ) -> Paginator[ConversationMessageResponse]: + """ + List messages sent or received via particular Processing Modes. + The messages are ordered by their accept_time property in descending order. + + :param page_size: Maximum number of messages to fetch. Defaults to 10, maximum is 1000. + :type page_size: Optional[int] + :param page_token: Next page token previously returned if any. + :type page_token: Optional[str] + :param conversation_id: Filter messages by conversation ID. + :type conversation_id: Optional[str] + :param contact_id: Filter messages by contact ID. + :type contact_id: Optional[str] + :param app_id: Filter messages by app ID. + :type app_id: Optional[str] + :param channel_identity: Channel identity of the contact. + :type channel_identity: Optional[str] + :param start_time: Filter messages with accept_time after this timestamp. + :type start_time: Optional[datetime] + :param end_time: Filter messages with accept_time before this timestamp. + :type end_time: Optional[datetime] + :param view: Messages view type. WITH_METADATA or WITHOUT_METADATA. + :type view: Optional[ConversationMessagesViewType] + :param messages_source: Specifies the message source for the request. + :type messages_source: Optional[MessageSourceType] + :param only_recipient_originated: Only fetch recipient-originated messages. + :type only_recipient_originated: Optional[bool] + :param channel: Only fetch messages from the specified channel. + :type channel: Optional[ConversationChannelType] + :param direction: Only fetch messages with the specified direction. TO_APP or TO_CONTACT. + :type direction: Optional[ConversationDirectionType] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: TokenBasedPaginator with ConversationMessageResponse items + :rtype: Paginator[ConversationMessageResponse] + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + return TokenBasedPaginator( + sinch=self._sinch, + endpoint=ListMessagesEndpoint( + project_id=self._sinch.configuration.project_id, + request_data=ListMessagesRequest( + page_size=page_size, + page_token=page_token, + conversation_id=conversation_id, + contact_id=contact_id, + app_id=app_id, + channel_identity=channel_identity, + start_time=start_time, + end_time=end_time, + view=view, + messages_source=messages_source, + only_recipient_originated=only_recipient_originated, + channel=channel, + direction=direction, + **kwargs, + ), + ), + ) + + def list_last_messages_by_channel_identity( + self, + channel_identities: Optional[List[str]] = None, + contact_ids: Optional[List[str]] = None, + app_id: Optional[str] = None, + messages_source: Optional[MessageSourceType] = None, + page_size: Optional[int] = None, + page_token: Optional[str] = None, + view: Optional[ConversationMessagesViewType] = None, + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, + channel: Optional[ConversationChannelType] = None, + direction: Optional[ConversationDirectionType] = None, + **kwargs, + ) -> Paginator[ConversationMessageResponse]: + """ + Retrieves the last message sent to specified channel identities. + In CONVERSATION_SOURCE mode, you can query either by channel_identities or by contact_ids. + Note: Use either contact_ids OR channel_identities per request, not both. + DISPATCH_SOURCE mode does not support contact_ids. + + :param channel_identities: Optional. Filter messages by channel_identity. + :type channel_identities: Optional[List[str]] + :param contact_ids: Optional. Resource name (id) of the contact. CONVERSATION_SOURCE: list last messages by contact_id. DISPATCH_SOURCE: unsupported. + :type contact_ids: Optional[List[str]] + :param app_id: Optional. Resource name (id) of the app. + :type app_id: Optional[str] + :param messages_source: Specifies the message source for the request. + :type messages_source: Optional[MessageSourceType] + :param page_size: Optional. Maximum number of messages to fetch. Defaults to 10, maximum is 1000. + :type page_size: Optional[int] + :param page_token: Optional. Next page token previously returned if any. + :type page_token: Optional[str] + :param view: Optional. Specifies the representation (WITH_METADATA or WITHOUT_METADATA). Default WITH_METADATA. + :type view: Optional[ConversationMessagesViewType] + :param start_time: Optional. Only fetch messages with accept_time after this date. + :type start_time: Optional[datetime] + :param end_time: Optional. Only fetch messages with accept_time before this date. + :type end_time: Optional[datetime] + :param channel: Optional. Only fetch messages from the specified channel. + :type channel: Optional[ConversationChannelType] + :param direction: Optional. Only fetch messages with the specified direction (TO_APP or TO_CONTACT). + :type direction: Optional[ConversationDirectionType] + # Code review: :param **kwargs is invalid Sphinx syntax; use kwargs or document as "Additional keyword arguments". + :param kwargs: Additional parameters for the request. + :type kwargs: dict + + :returns: TokenBasedPaginator with ConversationMessageResponse items + :rtype: Paginator[ConversationMessageResponse] + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + return TokenBasedPaginator( + sinch=self._sinch, + endpoint=ListLastMessagesByChannelIdentityEndpoint( + project_id=self._sinch.configuration.project_id, + request_data=ListLastMessagesByChannelIdentityRequest( + channel_identities=channel_identities, + contact_ids=contact_ids, + app_id=app_id, + messages_source=messages_source, + page_size=page_size, + page_token=page_token, + view=view, + start_time=start_time, + end_time=end_time, + channel=channel, + direction=direction, + **kwargs, + ), + ), + ) + + def update( + self, + message_id: str, + metadata: str, + messages_source: Optional[MessageSourceType] = None, + **kwargs, + ) -> ConversationMessageResponse: + """ + Update a specific message metadata by its ID. + + :param message_id: The unique ID of the message. (required) + :type message_id: str + :param metadata: Metadata that should be associated with the message. (required) + :type metadata: str + :param messages_source: Specifies the message source for which the request will be processed. Used for + operations on messages in Dispatch Mode. Defaults to `CONVERSATION_SOURCE` when not specified. For more information, + see [Processing Modes](https://developers.sinch.com/docs/conversation/processing-modes/). + (optional) + :type messages_source: Optional[MessageSourceType] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: ConversationMessageResponse + :rtype: ConversationMessageResponse + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + request_data = UpdateMessageMetadataRequest( + message_id=message_id, + metadata=metadata, + messages_source=messages_source, + **kwargs, + ) + return self._request(UpdateMessageMetadataEndpoint, request_data) + + def _send_message_variant( + self, + app_id: str, + contact_id: Optional[str], + recipient_identities: Optional[List[ChannelRecipientIdentityDict]], + message_field: str, + message: object, + message_cls: type, + ttl: Optional[Union[str, int]] = None, + event_destination_target: Optional[str] = None, + channel_priority_order: Optional[List[ConversationChannelType]] = None, + channel_properties: Optional[Dict[str, str]] = None, + message_metadata: Optional[str] = None, + conversation_metadata: Optional[Dict[str, Any]] = None, + queue: Optional[MessageQueueType] = None, + processing_strategy: Optional[ProcessingStrategyType] = None, + correlation_id: Optional[str] = None, + conversation_metadata_update_strategy: Optional[ + MetadataUpdateStrategyType + ] = None, + message_content_type: Optional[MessageContentType] = None, + **kwargs, + ) -> SendMessageResponse: + """ + - Builds Recipient Dictionary from contact_id or recipient_identities + - Normalizes recipient dict -> Recipient model + - Normalizes message dict -> message_cls(**message) + - Builds SendMessageRequest(message=..., recipient=..., app_id=...) and sends the request + """ + recipient_dict = build_recipient_dict( + contact_id=contact_id, recipient_identities=recipient_identities + ) + recipient_model = coerce_recipient(recipient_dict) + if isinstance(message, dict): + message = message_cls(**message) + + message_kwargs, request_kwargs = split_send_kwargs(kwargs) + send_message_request_body = SendMessageRequestBody( + **{message_field: message}, + **message_kwargs, + ) + request_data = SendMessageRequest( + app_id=app_id, + recipient=recipient_model, + message=send_message_request_body, + ttl=ttl, + event_destination_target=event_destination_target, + channel_priority_order=channel_priority_order, + channel_properties=channel_properties, + message_metadata=message_metadata, + conversation_metadata=conversation_metadata, + queue=queue, + processing_strategy=processing_strategy, + correlation_id=correlation_id, + conversation_metadata_update_strategy=conversation_metadata_update_strategy, + message_content_type=message_content_type, + **request_kwargs, + ) + return self._request(SendMessageEndpoint, request_data) + + def send( + self, + app_id: str, + message: Union[SendMessageRequestBodyDict, dict], + contact_id: Optional[str] = None, + recipient_identities: Optional[ + List[ChannelRecipientIdentityDict] + ] = None, + ttl: Optional[Union[str, int]] = None, + event_destination_target: Optional[str] = None, + channel_priority_order: Optional[List[ConversationChannelType]] = None, + channel_properties: Optional[Dict[str, str]] = None, + message_metadata: Optional[str] = None, + conversation_metadata: Optional[Dict[str, Any]] = None, + queue: Optional[MessageQueueType] = None, + processing_strategy: Optional[ProcessingStrategyType] = None, + correlation_id: Optional[str] = None, + conversation_metadata_update_strategy: Optional[ + MetadataUpdateStrategyType + ] = None, + message_content_type: Optional[MessageContentType] = None, + **kwargs, + ) -> SendMessageResponse: + """ + Send a message from a Conversation app to a contact associated with that app. + If the recipient is not associated with an existing contact, a new contact will be created. + The message is added to the active conversation with the contact if a conversation already exists. + If no active conversation exists a new one is started automatically. + + :param app_id: The ID of the Conversation API app sending the message. + :type app_id: str + :param message: The message content to send. Can be a SendMessageRequestBodyDict or a dict. + :type message: Union[SendMessageRequestBodyDict, dict] + :param contact_id: The contact ID of the recipient. Either contact_id or recipient_identities must be provided. + :type contact_id: Optional[str] + :param recipient_identities: List of channel identities for the recipient. Either contact_id or recipient_identities must be provided. + :type recipient_identities: Optional[List[ChannelRecipientIdentityDict]] + :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. + :type ttl: Optional[Union[str, int]] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] + :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. + :type channel_priority_order: Optional[List[ConversationChannelType]] + :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. + :type channel_properties: Optional[Dict[str, str]] + :param message_metadata: Metadata that should be associated with the message. Up to 1024 characters long. + :type message_metadata: Optional[str] + :param conversation_metadata: Metadata that will be associated with the conversation. Up to 2048 characters long. + :type conversation_metadata: Optional[Dict[str, Any]] + :param queue: Select the priority type for the message. Can be 'NORMAL_PRIORITY' or 'HIGH_PRIORITY'. + :type queue: Optional[MessageQueueType] + :param processing_strategy: Overrides the app's Processing Mode. Can be 'DEFAULT' or 'DISPATCH_ONLY'. + :type processing_strategy: Optional[ProcessingStrategyType] + :param correlation_id: An arbitrary identifier that will be propagated to callbacks related to this message. Up to 128 characters long. + :type correlation_id: Optional[str] + :param conversation_metadata_update_strategy: Update strategy for the conversation_metadata field. Can be 'REPLACE' or 'MERGE_PATCH'. + :type conversation_metadata_update_strategy: Optional[MetadataUpdateStrategyType] + :param message_content_type: Classifies the message content for use with consent management. Can be 'CONTENT_UNKNOWN', 'CONTENT_MARKETING', or 'CONTENT_NOTIFICATION'. + :type message_content_type: Optional[MessageContentType] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: SendMessageResponse + :rtype: SendMessageResponse + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + recipient_dict = build_recipient_dict( + contact_id=contact_id, recipient_identities=recipient_identities + ) + recipient = coerce_recipient(recipient_dict) + # Coerce message to SendMessageRequestBody if it's a dict + if isinstance(message, dict): + message = SendMessageRequestBody(**message) + message_kwargs, request_kwargs = split_send_kwargs(kwargs) + # message kwargs are applied directly to the message model (if provided as dict) + if message_kwargs: + message = SendMessageRequestBody( + **message.model_dump(), **message_kwargs + ) + request_data = SendMessageRequest( + app_id=app_id, + recipient=recipient, + message=message, + ttl=ttl, + event_destination_target=event_destination_target, + channel_priority_order=channel_priority_order, + channel_properties=channel_properties, + message_metadata=message_metadata, + conversation_metadata=conversation_metadata, + queue=queue, + processing_strategy=processing_strategy, + correlation_id=correlation_id, + conversation_metadata_update_strategy=conversation_metadata_update_strategy, + message_content_type=message_content_type, + **request_kwargs, + ) + return self._request(SendMessageEndpoint, request_data) + + def send_text_message( + self, + app_id: str, + text: str, + contact_id: Optional[str] = None, + recipient_identities: Optional[ + List[ChannelRecipientIdentityDict] + ] = None, + ttl: Optional[Union[str, int]] = None, + event_destination_target: Optional[str] = None, + channel_priority_order: Optional[List[ConversationChannelType]] = None, + channel_properties: Optional[Dict[str, str]] = None, + message_metadata: Optional[str] = None, + conversation_metadata: Optional[Dict[str, Any]] = None, + queue: Optional[MessageQueueType] = None, + processing_strategy: Optional[ProcessingStrategyType] = None, + correlation_id: Optional[str] = None, + conversation_metadata_update_strategy: Optional[ + MetadataUpdateStrategyType + ] = None, + message_content_type: Optional[MessageContentType] = None, + **kwargs, + ) -> SendMessageResponse: + """ + Send a text message from a Conversation app to a contact associated with that app. + If the recipient is not associated with an existing contact, a new contact will be created. + The message is added to the active conversation with the contact if a conversation already exists. + If no active conversation exists a new one is started automatically. + + :param app_id: The ID of the Conversation API app sending the message. + :type app_id: str + :param contact_id: The contact ID of the recipient. Either contact_id or recipient_identities must be provided. + :type contact_id: Optional[str] + :param recipient_identities: List of channel identities for the recipient. Either contact_id or recipient_identities must be provided. + :type recipient_identities: Optional[List[ChannelRecipientIdentityDict]] + :param text: The text content of the message. + :type text: str + :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. + :type ttl: Optional[Union[str, int]] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] + :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. + :type channel_priority_order: Optional[List[ConversationChannelType]] + :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. + :type channel_properties: Optional[Dict[str, str]] + :param message_metadata: Metadata that should be associated with the message. Up to 1024 characters long. + :type message_metadata: Optional[str] + :param conversation_metadata: Metadata that will be associated with the conversation. Up to 2048 characters long. + :type conversation_metadata: Optional[Dict[str, Any]] + :param queue: Select the priority type for the message. Can be 'NORMAL_PRIORITY' or 'HIGH_PRIORITY'. + :type queue: Optional[MessageQueueType] + :param processing_strategy: Overrides the app's Processing Mode. Can be 'DEFAULT' or 'DISPATCH_ONLY'. + :type processing_strategy: Optional[ProcessingStrategyType] + :param correlation_id: An arbitrary identifier that will be propagated to callbacks related to this message. Up to 128 characters long. + :type correlation_id: Optional[str] + :param conversation_metadata_update_strategy: Update strategy for the conversation_metadata field. Can be 'REPLACE' or 'MERGE_PATCH'. + :type conversation_metadata_update_strategy: Optional[MetadataUpdateStrategyType] + :param message_content_type: Classifies the message content for use with consent management. Can be 'CONTENT_UNKNOWN', 'CONTENT_MARKETING', or 'CONTENT_NOTIFICATION'. + :type message_content_type: Optional[MessageContentType] + :param **kwargs: Additional parameters for the message body (e.g., agent, etc.). + :type **kwargs: dict + + :returns: SendMessageResponse + :rtype: SendMessageResponse + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + return self._send_message_variant( + app_id=app_id, + contact_id=contact_id, + recipient_identities=recipient_identities, + message_field="text_message", + message=TextMessage(text=text), + message_cls=TextMessage, + ttl=ttl, + event_destination_target=event_destination_target, + channel_priority_order=channel_priority_order, + channel_properties=channel_properties, + message_metadata=message_metadata, + conversation_metadata=conversation_metadata, + queue=queue, + processing_strategy=processing_strategy, + correlation_id=correlation_id, + conversation_metadata_update_strategy=conversation_metadata_update_strategy, + message_content_type=message_content_type, + **kwargs, + ) + + def send_card_message( + self, + app_id: str, + card_message: CardMessageDict, + contact_id: Optional[str] = None, + recipient_identities: Optional[ + List[ChannelRecipientIdentityDict] + ] = None, + ttl: Optional[Union[str, int]] = None, + event_destination_target: Optional[str] = None, + channel_priority_order: Optional[List[ConversationChannelType]] = None, + channel_properties: Optional[Dict[str, str]] = None, + message_metadata: Optional[str] = None, + conversation_metadata: Optional[Dict[str, Any]] = None, + queue: Optional[MessageQueueType] = None, + processing_strategy: Optional[ProcessingStrategyType] = None, + correlation_id: Optional[str] = None, + conversation_metadata_update_strategy: Optional[ + MetadataUpdateStrategyType + ] = None, + message_content_type: Optional[MessageContentType] = None, + **kwargs, + ) -> SendMessageResponse: + """ + Send a card message from a Conversation app to a contact associated with that app. + If the recipient is not associated with an existing contact, a new contact will be created. + The message is added to the active conversation with the contact if a conversation already exists. + If no active conversation exists a new one is started automatically. + + :param app_id: The ID of the Conversation API app sending the message. + :type app_id: str + :param contact_id: The contact ID of the recipient. Either contact_id or recipient_identities must be provided. + :type contact_id: Optional[str] + :param recipient_identities: List of channel identities for the recipient. Either contact_id or recipient_identities must be provided. + :type recipient_identities: Optional[List[ChannelRecipientIdentityDict]] + :param card_message: The card message content. + :type card_message: CardMessageDict + :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. + :type ttl: Optional[Union[str, int]] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] + :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. + :type channel_priority_order: Optional[List[ConversationChannelType]] + :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. + :type channel_properties: Optional[Dict[str, str]] + :param message_metadata: Metadata that should be associated with the message. Up to 1024 characters long. + :type message_metadata: Optional[str] + :param conversation_metadata: Metadata that will be associated with the conversation. Up to 2048 characters long. + :type conversation_metadata: Optional[Dict[str, Any]] + :param queue: Select the priority type for the message. Can be 'NORMAL_PRIORITY' or 'HIGH_PRIORITY'. + :type queue: Optional[MessageQueueType] + :param processing_strategy: Overrides the app's Processing Mode. Can be 'DEFAULT' or 'DISPATCH_ONLY'. + :type processing_strategy: Optional[ProcessingStrategyType] + :param correlation_id: An arbitrary identifier that will be propagated to callbacks related to this message. Up to 128 characters long. + :type correlation_id: Optional[str] + :param conversation_metadata_update_strategy: Update strategy for the conversation_metadata field. Can be 'REPLACE' or 'MERGE_PATCH'. + :type conversation_metadata_update_strategy: Optional[MetadataUpdateStrategyType] + :param message_content_type: Classifies the message content for use with consent management. Can be 'CONTENT_UNKNOWN', 'CONTENT_MARKETING', or 'CONTENT_NOTIFICATION'. + :type message_content_type: Optional[MessageContentType] + :param **kwargs: Additional parameters for the message body (e.g., agent, etc.). + :type **kwargs: dict + + :returns: SendMessageResponse + :rtype: SendMessageResponse + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + return self._send_message_variant( + app_id=app_id, + contact_id=contact_id, + recipient_identities=recipient_identities, + message_field="card_message", + message=card_message, + message_cls=CardMessage, + ttl=ttl, + event_destination_target=event_destination_target, + channel_priority_order=channel_priority_order, + channel_properties=channel_properties, + message_metadata=message_metadata, + conversation_metadata=conversation_metadata, + queue=queue, + processing_strategy=processing_strategy, + correlation_id=correlation_id, + conversation_metadata_update_strategy=conversation_metadata_update_strategy, + message_content_type=message_content_type, + **kwargs, + ) + + def send_carousel_message( + self, + app_id: str, + carousel_message: CarouselMessageDict, + contact_id: Optional[str] = None, + recipient_identities: Optional[ + List[ChannelRecipientIdentityDict] + ] = None, + ttl: Optional[Union[str, int]] = None, + event_destination_target: Optional[str] = None, + channel_priority_order: Optional[List[ConversationChannelType]] = None, + channel_properties: Optional[Dict[str, str]] = None, + message_metadata: Optional[str] = None, + conversation_metadata: Optional[Dict[str, Any]] = None, + queue: Optional[MessageQueueType] = None, + processing_strategy: Optional[ProcessingStrategyType] = None, + correlation_id: Optional[str] = None, + conversation_metadata_update_strategy: Optional[ + MetadataUpdateStrategyType + ] = None, + message_content_type: Optional[MessageContentType] = None, + **kwargs, + ) -> SendMessageResponse: + """ + Send a carousel message from a Conversation app to a contact associated with that app. + If the recipient is not associated with an existing contact, a new contact will be created. + The message is added to the active conversation with the contact if a conversation already exists. + If no active conversation exists a new one is started automatically. + + :param app_id: The ID of the Conversation API app sending the message. + :type app_id: str + :param contact_id: The contact ID of the recipient. Either contact_id or recipient_identities must be provided. + :type contact_id: Optional[str] + :param recipient_identities: List of channel identities for the recipient. Either contact_id or recipient_identities must be provided. + :type recipient_identities: Optional[List[ChannelRecipientIdentityDict]] + :param carousel_message: The carousel message content. + :type carousel_message: CarouselMessageDict + :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. + :type ttl: Optional[Union[str, int]] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] + :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. + :type channel_priority_order: Optional[List[ConversationChannelType]] + :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. + :type channel_properties: Optional[Dict[str, str]] + :param message_metadata: Metadata that should be associated with the message. Up to 1024 characters long. + :type message_metadata: Optional[str] + :param conversation_metadata: Metadata that will be associated with the conversation. Up to 2048 characters long. + :type conversation_metadata: Optional[Dict[str, Any]] + :param queue: Select the priority type for the message. Can be 'NORMAL_PRIORITY' or 'HIGH_PRIORITY'. + :type queue: Optional[MessageQueueType] + :param processing_strategy: Overrides the app's Processing Mode. Can be 'DEFAULT' or 'DISPATCH_ONLY'. + :type processing_strategy: Optional[ProcessingStrategyType] + :param correlation_id: An arbitrary identifier that will be propagated to callbacks related to this message. Up to 128 characters long. + :type correlation_id: Optional[str] + :param conversation_metadata_update_strategy: Update strategy for the conversation_metadata field. Can be 'REPLACE' or 'MERGE_PATCH'. + :type conversation_metadata_update_strategy: Optional[MetadataUpdateStrategyType] + :param message_content_type: Classifies the message content for use with consent management. Can be 'CONTENT_UNKNOWN', 'CONTENT_MARKETING', or 'CONTENT_NOTIFICATION'. + :type message_content_type: Optional[MessageContentType] + :param **kwargs: Additional parameters for the message body (e.g., agent, etc.). + :type **kwargs: dict + + :returns: SendMessageResponse + :rtype: SendMessageResponse + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + return self._send_message_variant( + app_id=app_id, + contact_id=contact_id, + recipient_identities=recipient_identities, + message_field="carousel_message", + message=carousel_message, + message_cls=CarouselMessage, + ttl=ttl, + event_destination_target=event_destination_target, + channel_priority_order=channel_priority_order, + channel_properties=channel_properties, + message_metadata=message_metadata, + conversation_metadata=conversation_metadata, + queue=queue, + processing_strategy=processing_strategy, + correlation_id=correlation_id, + conversation_metadata_update_strategy=conversation_metadata_update_strategy, + message_content_type=message_content_type, + **kwargs, + ) + + def send_choice_message( + self, + app_id: str, + choice_message: ChoiceMessageDict, + contact_id: Optional[str] = None, + recipient_identities: Optional[ + List[ChannelRecipientIdentityDict] + ] = None, + ttl: Optional[Union[str, int]] = None, + event_destination_target: Optional[str] = None, + channel_priority_order: Optional[List[ConversationChannelType]] = None, + channel_properties: Optional[Dict[str, str]] = None, + message_metadata: Optional[str] = None, + conversation_metadata: Optional[Dict[str, Any]] = None, + queue: Optional[MessageQueueType] = None, + processing_strategy: Optional[ProcessingStrategyType] = None, + correlation_id: Optional[str] = None, + conversation_metadata_update_strategy: Optional[ + MetadataUpdateStrategyType + ] = None, + message_content_type: Optional[MessageContentType] = None, + **kwargs, + ) -> SendMessageResponse: + """ + Send a choice message from a Conversation app to a contact associated with that app. + If the recipient is not associated with an existing contact, a new contact will be created. + The message is added to the active conversation with the contact if a conversation already exists. + If no active conversation exists a new one is started automatically. + + :param app_id: The ID of the Conversation API app sending the message. + :type app_id: str + :param contact_id: The contact ID of the recipient. Either contact_id or recipient_identities must be provided. + :type contact_id: Optional[str] + :param recipient_identities: List of channel identities for the recipient. Either contact_id or recipient_identities must be provided. + :type recipient_identities: Optional[List[ChannelRecipientIdentityDict]] + :param choice_message: The choice message content. + :type choice_message: ChoiceMessageDict + :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. + :type ttl: Optional[Union[str, int]] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] + :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. + :type channel_priority_order: Optional[List[ConversationChannelType]] + :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. + :type channel_properties: Optional[Dict[str, str]] + :param message_metadata: Metadata that should be associated with the message. Up to 1024 characters long. + :type message_metadata: Optional[str] + :param conversation_metadata: Metadata that will be associated with the conversation. Up to 2048 characters long. + :type conversation_metadata: Optional[Dict[str, Any]] + :param queue: Select the priority type for the message. Can be 'NORMAL_PRIORITY' or 'HIGH_PRIORITY'. + :type queue: Optional[MessageQueueType] + :param processing_strategy: Overrides the app's Processing Mode. Can be 'DEFAULT' or 'DISPATCH_ONLY'. + :type processing_strategy: Optional[ProcessingStrategyType] + :param correlation_id: An arbitrary identifier that will be propagated to callbacks related to this message. Up to 128 characters long. + :type correlation_id: Optional[str] + :param conversation_metadata_update_strategy: Update strategy for the conversation_metadata field. Can be 'REPLACE' or 'MERGE_PATCH'. + :type conversation_metadata_update_strategy: Optional[MetadataUpdateStrategyType] + :param message_content_type: Classifies the message content for use with consent management. Can be 'CONTENT_UNKNOWN', 'CONTENT_MARKETING', or 'CONTENT_NOTIFICATION'. + :type message_content_type: Optional[MessageContentType] + :param **kwargs: Additional parameters for the message body (e.g., agent, etc.). + :type **kwargs: dict + + :returns: SendMessageResponse + :rtype: SendMessageResponse + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + return self._send_message_variant( + app_id=app_id, + contact_id=contact_id, + recipient_identities=recipient_identities, + message_field="choice_message", + message=choice_message, + message_cls=ChoiceMessage, + ttl=ttl, + event_destination_target=event_destination_target, + channel_priority_order=channel_priority_order, + channel_properties=channel_properties, + message_metadata=message_metadata, + conversation_metadata=conversation_metadata, + queue=queue, + processing_strategy=processing_strategy, + correlation_id=correlation_id, + conversation_metadata_update_strategy=conversation_metadata_update_strategy, + message_content_type=message_content_type, + **kwargs, + ) + + def send_contact_info_message( + self, + app_id: str, + contact_info_message: ContactInfoMessageDict, + contact_id: Optional[str] = None, + recipient_identities: Optional[ + List[ChannelRecipientIdentityDict] + ] = None, + ttl: Optional[Union[str, int]] = None, + event_destination_target: Optional[str] = None, + channel_priority_order: Optional[List[ConversationChannelType]] = None, + channel_properties: Optional[Dict[str, str]] = None, + message_metadata: Optional[str] = None, + conversation_metadata: Optional[Dict[str, Any]] = None, + queue: Optional[MessageQueueType] = None, + processing_strategy: Optional[ProcessingStrategyType] = None, + correlation_id: Optional[str] = None, + conversation_metadata_update_strategy: Optional[ + MetadataUpdateStrategyType + ] = None, + message_content_type: Optional[MessageContentType] = None, + **kwargs, + ) -> SendMessageResponse: + """ + Send a contact info message from a Conversation app to a contact associated with that app. + If the recipient is not associated with an existing contact, a new contact will be created. + The message is added to the active conversation with the contact if a conversation already exists. + If no active conversation exists a new one is started automatically. + + :param app_id: The ID of the Conversation API app sending the message. + :type app_id: str + :param contact_id: The contact ID of the recipient. Either contact_id or recipient_identities must be provided. + :type contact_id: Optional[str] + :param recipient_identities: List of channel identities for the recipient. Either contact_id or recipient_identities must be provided. + :type recipient_identities: Optional[List[ChannelRecipientIdentityDict]] + :param contact_info_message: The contact info message content. + :type contact_info_message: ContactInfoMessageDict + :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. + :type ttl: Optional[Union[str, int]] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] + :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. + :type channel_priority_order: Optional[List[ConversationChannelType]] + :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. + :type channel_properties: Optional[Dict[str, str]] + :param message_metadata: Metadata that should be associated with the message. Up to 1024 characters long. + :type message_metadata: Optional[str] + :param conversation_metadata: Metadata that will be associated with the conversation. Up to 2048 characters long. + :type conversation_metadata: Optional[Dict[str, Any]] + :param queue: Select the priority type for the message. Can be 'NORMAL_PRIORITY' or 'HIGH_PRIORITY'. + :type queue: Optional[MessageQueueType] + :param processing_strategy: Overrides the app's Processing Mode. Can be 'DEFAULT' or 'DISPATCH_ONLY'. + :type processing_strategy: Optional[ProcessingStrategyType] + :param correlation_id: An arbitrary identifier that will be propagated to callbacks related to this message. Up to 128 characters long. + :type correlation_id: Optional[str] + :param conversation_metadata_update_strategy: Update strategy for the conversation_metadata field. Can be 'REPLACE' or 'MERGE_PATCH'. + :type conversation_metadata_update_strategy: Optional[MetadataUpdateStrategyType] + :param message_content_type: Classifies the message content for use with consent management. Can be 'CONTENT_UNKNOWN', 'CONTENT_MARKETING', or 'CONTENT_NOTIFICATION'. + :type message_content_type: Optional[MessageContentType] + :param **kwargs: Additional parameters for the message body (e.g., agent, etc.). + :type **kwargs: dict + + :returns: SendMessageResponse + :rtype: SendMessageResponse + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + return self._send_message_variant( + app_id=app_id, + contact_id=contact_id, + recipient_identities=recipient_identities, + message_field="contact_info_message", + message=contact_info_message, + message_cls=ContactInfoMessage, + ttl=ttl, + event_destination_target=event_destination_target, + channel_priority_order=channel_priority_order, + channel_properties=channel_properties, + message_metadata=message_metadata, + conversation_metadata=conversation_metadata, + queue=queue, + processing_strategy=processing_strategy, + correlation_id=correlation_id, + conversation_metadata_update_strategy=conversation_metadata_update_strategy, + message_content_type=message_content_type, + **kwargs, + ) + + def send_list_message( + self, + app_id: str, + list_message: ListMessageDict, + contact_id: Optional[str] = None, + recipient_identities: Optional[ + List[ChannelRecipientIdentityDict] + ] = None, + ttl: Optional[Union[str, int]] = None, + event_destination_target: Optional[str] = None, + channel_priority_order: Optional[List[ConversationChannelType]] = None, + channel_properties: Optional[Dict[str, str]] = None, + message_metadata: Optional[str] = None, + conversation_metadata: Optional[Dict[str, Any]] = None, + queue: Optional[MessageQueueType] = None, + processing_strategy: Optional[ProcessingStrategyType] = None, + correlation_id: Optional[str] = None, + conversation_metadata_update_strategy: Optional[ + MetadataUpdateStrategyType + ] = None, + message_content_type: Optional[MessageContentType] = None, + **kwargs, + ) -> SendMessageResponse: + """ + Send a list message from a Conversation app to a contact associated with that app. + If the recipient is not associated with an existing contact, a new contact will be created. + The message is added to the active conversation with the contact if a conversation already exists. + If no active conversation exists a new one is started automatically. + + :param app_id: The ID of the Conversation API app sending the message. + :type app_id: str + :param contact_id: The contact ID of the recipient. Either contact_id or recipient_identities must be provided. + :type contact_id: Optional[str] + :param recipient_identities: List of channel identities for the recipient. Either contact_id or recipient_identities must be provided. + :type recipient_identities: Optional[List[ChannelRecipientIdentityDict]] + :param list_message: The list message content. + :type list_message: ListMessageDict + :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. + :type ttl: Optional[Union[str, int]] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] + :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. + :type channel_priority_order: Optional[List[ConversationChannelType]] + :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. + :type channel_properties: Optional[Dict[str, str]] + :param message_metadata: Metadata that should be associated with the message. Up to 1024 characters long. + :type message_metadata: Optional[str] + :param conversation_metadata: Metadata that will be associated with the conversation. Up to 2048 characters long. + :type conversation_metadata: Optional[Dict[str, Any]] + :param queue: Select the priority type for the message. Can be 'NORMAL_PRIORITY' or 'HIGH_PRIORITY'. + :type queue: Optional[MessageQueueType] + :param processing_strategy: Overrides the app's Processing Mode. Can be 'DEFAULT' or 'DISPATCH_ONLY'. + :type processing_strategy: Optional[ProcessingStrategyType] + :param correlation_id: An arbitrary identifier that will be propagated to callbacks related to this message. Up to 128 characters long. + :type correlation_id: Optional[str] + :param conversation_metadata_update_strategy: Update strategy for the conversation_metadata field. Can be 'REPLACE' or 'MERGE_PATCH'. + :type conversation_metadata_update_strategy: Optional[MetadataUpdateStrategyType] + :param message_content_type: Classifies the message content for use with consent management. Can be 'CONTENT_UNKNOWN', 'CONTENT_MARKETING', or 'CONTENT_NOTIFICATION'. + :type message_content_type: Optional[MessageContentType] + :param **kwargs: Additional parameters for the message body (e.g., agent, etc.). + :type **kwargs: dict + + :returns: SendMessageResponse + :rtype: SendMessageResponse + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + return self._send_message_variant( + app_id=app_id, + contact_id=contact_id, + recipient_identities=recipient_identities, + message_field="list_message", + message=list_message, + message_cls=ListMessage, + ttl=ttl, + event_destination_target=event_destination_target, + channel_priority_order=channel_priority_order, + channel_properties=channel_properties, + message_metadata=message_metadata, + conversation_metadata=conversation_metadata, + queue=queue, + processing_strategy=processing_strategy, + correlation_id=correlation_id, + conversation_metadata_update_strategy=conversation_metadata_update_strategy, + message_content_type=message_content_type, + **kwargs, + ) + + def send_location_message( + self, + app_id: str, + location_message: LocationMessageDict, + contact_id: Optional[str] = None, + recipient_identities: Optional[ + List[ChannelRecipientIdentityDict] + ] = None, + ttl: Optional[Union[str, int]] = None, + event_destination_target: Optional[str] = None, + channel_priority_order: Optional[List[ConversationChannelType]] = None, + channel_properties: Optional[Dict[str, str]] = None, + message_metadata: Optional[str] = None, + conversation_metadata: Optional[Dict[str, Any]] = None, + queue: Optional[MessageQueueType] = None, + processing_strategy: Optional[ProcessingStrategyType] = None, + correlation_id: Optional[str] = None, + conversation_metadata_update_strategy: Optional[ + MetadataUpdateStrategyType + ] = None, + message_content_type: Optional[MessageContentType] = None, + **kwargs, + ) -> SendMessageResponse: + """ + Send a location message from a Conversation app to a contact associated with that app. + If the recipient is not associated with an existing contact, a new contact will be created. + The message is added to the active conversation with the contact if a conversation already exists. + If no active conversation exists a new one is started automatically. + + :param app_id: The ID of the Conversation API app sending the message. + :type app_id: str + :param contact_id: The contact ID of the recipient. Either contact_id or recipient_identities must be provided. + :type contact_id: Optional[str] + :param recipient_identities: List of channel identities for the recipient. Either contact_id or recipient_identities must be provided. + :type recipient_identities: Optional[List[ChannelRecipientIdentityDict]] + :param location_message: The location message content. + :type location_message: LocationMessageDict + :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. + :type ttl: Optional[Union[str, int]] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] + :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. + :type channel_priority_order: Optional[List[ConversationChannelType]] + :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. + :type channel_properties: Optional[Dict[str, str]] + :param message_metadata: Metadata that should be associated with the message. Up to 1024 characters long. + :type message_metadata: Optional[str] + :param conversation_metadata: Metadata that will be associated with the conversation. Up to 2048 characters long. + :type conversation_metadata: Optional[Dict[str, Any]] + :param queue: Select the priority type for the message. Can be 'NORMAL_PRIORITY' or 'HIGH_PRIORITY'. + :type queue: Optional[MessageQueueType] + :param processing_strategy: Overrides the app's Processing Mode. Can be 'DEFAULT' or 'DISPATCH_ONLY'. + :type processing_strategy: Optional[ProcessingStrategyType] + :param correlation_id: An arbitrary identifier that will be propagated to callbacks related to this message. Up to 128 characters long. + :type correlation_id: Optional[str] + :param conversation_metadata_update_strategy: Update strategy for the conversation_metadata field. Can be 'REPLACE' or 'MERGE_PATCH'. + :type conversation_metadata_update_strategy: Optional[MetadataUpdateStrategyType] + :param message_content_type: Classifies the message content for use with consent management. Can be 'CONTENT_UNKNOWN', 'CONTENT_MARKETING', or 'CONTENT_NOTIFICATION'. + :type message_content_type: Optional[MessageContentType] + :param **kwargs: Additional parameters for the message body (e.g., agent, etc.). + :type **kwargs: dict + + :returns: SendMessageResponse + :rtype: SendMessageResponse + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + return self._send_message_variant( + app_id=app_id, + contact_id=contact_id, + recipient_identities=recipient_identities, + message_field="location_message", + message=location_message, + message_cls=LocationMessage, + ttl=ttl, + event_destination_target=event_destination_target, + channel_priority_order=channel_priority_order, + channel_properties=channel_properties, + message_metadata=message_metadata, + conversation_metadata=conversation_metadata, + queue=queue, + processing_strategy=processing_strategy, + correlation_id=correlation_id, + conversation_metadata_update_strategy=conversation_metadata_update_strategy, + message_content_type=message_content_type, + **kwargs, + ) + + def send_media_message( + self, + app_id: str, + media_message: MediaPropertiesDict, + contact_id: Optional[str] = None, + recipient_identities: Optional[ + List[ChannelRecipientIdentityDict] + ] = None, + ttl: Optional[Union[str, int]] = None, + event_destination_target: Optional[str] = None, + channel_priority_order: Optional[List[ConversationChannelType]] = None, + channel_properties: Optional[Dict[str, str]] = None, + message_metadata: Optional[str] = None, + conversation_metadata: Optional[Dict[str, Any]] = None, + queue: Optional[MessageQueueType] = None, + processing_strategy: Optional[ProcessingStrategyType] = None, + correlation_id: Optional[str] = None, + conversation_metadata_update_strategy: Optional[ + MetadataUpdateStrategyType + ] = None, + message_content_type: Optional[MessageContentType] = None, + **kwargs, + ) -> SendMessageResponse: + """ + Send a media message from a Conversation app to a contact associated with that app. + If the recipient is not associated with an existing contact, a new contact will be created. + The message is added to the active conversation with the contact if a conversation already exists. + If no active conversation exists a new one is started automatically. + + :param app_id: The ID of the Conversation API app sending the message. + :type app_id: str + :param contact_id: The contact ID of the recipient. Either contact_id or recipient_identities must be provided. + :type contact_id: Optional[str] + :param recipient_identities: List of channel identities for the recipient. Either contact_id or recipient_identities must be provided. + :type recipient_identities: Optional[List[ChannelRecipientIdentityDict]] + :param media_message: The media message content. + :type media_message: MediaPropertiesDict + :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. + :type ttl: Optional[Union[str, int]] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] + :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. + :type channel_priority_order: Optional[List[ConversationChannelType]] + :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. + :type channel_properties: Optional[Dict[str, str]] + :param message_metadata: Metadata that should be associated with the message. Up to 1024 characters long. + :type message_metadata: Optional[str] + :param conversation_metadata: Metadata that will be associated with the conversation. Up to 2048 characters long. + :type conversation_metadata: Optional[Dict[str, Any]] + :param queue: Select the priority type for the message. Can be 'NORMAL_PRIORITY' or 'HIGH_PRIORITY'. + :type queue: Optional[MessageQueueType] + :param processing_strategy: Overrides the app's Processing Mode. Can be 'DEFAULT' or 'DISPATCH_ONLY'. + :type processing_strategy: Optional[ProcessingStrategyType] + :param correlation_id: An arbitrary identifier that will be propagated to callbacks related to this message. Up to 128 characters long. + :type correlation_id: Optional[str] + :param conversation_metadata_update_strategy: Update strategy for the conversation_metadata field. Can be 'REPLACE' or 'MERGE_PATCH'. + :type conversation_metadata_update_strategy: Optional[MetadataUpdateStrategyType] + :param message_content_type: Classifies the message content for use with consent management. Can be 'CONTENT_UNKNOWN', 'CONTENT_MARKETING', or 'CONTENT_NOTIFICATION'. + :type message_content_type: Optional[MessageContentType] + :param **kwargs: Additional parameters for the message body (e.g., agent, etc.). + :type **kwargs: dict + + :returns: SendMessageResponse + :rtype: SendMessageResponse + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + return self._send_message_variant( + app_id=app_id, + contact_id=contact_id, + recipient_identities=recipient_identities, + message_field="media_message", + message=media_message, + message_cls=MediaProperties, + ttl=ttl, + event_destination_target=event_destination_target, + channel_priority_order=channel_priority_order, + channel_properties=channel_properties, + message_metadata=message_metadata, + conversation_metadata=conversation_metadata, + queue=queue, + processing_strategy=processing_strategy, + correlation_id=correlation_id, + conversation_metadata_update_strategy=conversation_metadata_update_strategy, + message_content_type=message_content_type, + **kwargs, + ) + + def send_template_message( + self, + app_id: str, + template_message: TemplateMessageDict, + contact_id: Optional[str] = None, + recipient_identities: Optional[ + List[ChannelRecipientIdentityDict] + ] = None, + ttl: Optional[Union[str, int]] = None, + event_destination_target: Optional[str] = None, + channel_priority_order: Optional[List[ConversationChannelType]] = None, + channel_properties: Optional[Dict[str, str]] = None, + message_metadata: Optional[str] = None, + conversation_metadata: Optional[Dict[str, Any]] = None, + queue: Optional[MessageQueueType] = None, + processing_strategy: Optional[ProcessingStrategyType] = None, + correlation_id: Optional[str] = None, + conversation_metadata_update_strategy: Optional[ + MetadataUpdateStrategyType + ] = None, + message_content_type: Optional[MessageContentType] = None, + **kwargs, + ) -> SendMessageResponse: + """ + Send a template message from a Conversation app to a contact associated with that app. + If the recipient is not associated with an existing contact, a new contact will be created. + The message is added to the active conversation with the contact if a conversation already exists. + If no active conversation exists a new one is started automatically. + + :param app_id: The ID of the Conversation API app sending the message. + :type app_id: str + :param contact_id: The contact ID of the recipient. Either contact_id or recipient_identities must be provided. + :type contact_id: Optional[str] + :param recipient_identities: List of channel identities for the recipient. Either contact_id or recipient_identities must be provided. + :type recipient_identities: Optional[List[ChannelRecipientIdentityDict]] + :param template_message: The template message content. + :type template_message: TemplateMessageDict + :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. + :type ttl: Optional[Union[str, int]] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] + :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. + :type channel_priority_order: Optional[List[ConversationChannelType]] + :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. + :type channel_properties: Optional[Dict[str, str]] + :param message_metadata: Metadata that should be associated with the message. Up to 1024 characters long. + :type message_metadata: Optional[str] + :param conversation_metadata: Metadata that will be associated with the conversation. Up to 2048 characters long. + :type conversation_metadata: Optional[Dict[str, Any]] + :param queue: Select the priority type for the message. Can be 'NORMAL_PRIORITY' or 'HIGH_PRIORITY'. + :type queue: Optional[MessageQueueType] + :param processing_strategy: Overrides the app's Processing Mode. Can be 'DEFAULT' or 'DISPATCH_ONLY'. + :type processing_strategy: Optional[ProcessingStrategyType] + :param correlation_id: An arbitrary identifier that will be propagated to callbacks related to this message. Up to 128 characters long. + :type correlation_id: Optional[str] + :param conversation_metadata_update_strategy: Update strategy for the conversation_metadata field. Can be 'REPLACE' or 'MERGE_PATCH'. + :type conversation_metadata_update_strategy: Optional[MetadataUpdateStrategyType] + :param message_content_type: Classifies the message content for use with consent management. Can be 'CONTENT_UNKNOWN', 'CONTENT_MARKETING', or 'CONTENT_NOTIFICATION'. + :type message_content_type: Optional[MessageContentType] + :param **kwargs: Additional parameters for the message body (e.g., agent, etc.). + :type **kwargs: dict + + :returns: SendMessageResponse + :rtype: SendMessageResponse + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + return self._send_message_variant( + app_id=app_id, + contact_id=contact_id, + recipient_identities=recipient_identities, + message_field="template_message", + message=template_message, + message_cls=TemplateMessage, + ttl=ttl, + event_destination_target=event_destination_target, + channel_priority_order=channel_priority_order, + channel_properties=channel_properties, + message_metadata=message_metadata, + conversation_metadata=conversation_metadata, + queue=queue, + processing_strategy=processing_strategy, + correlation_id=correlation_id, + conversation_metadata_update_strategy=conversation_metadata_update_strategy, + message_content_type=message_content_type, + **kwargs, + ) diff --git a/sinch/domains/conversation/api/v1/utils/__init__.py b/sinch/domains/conversation/api/v1/utils/__init__.py new file mode 100644 index 00000000..ef5df4d6 --- /dev/null +++ b/sinch/domains/conversation/api/v1/utils/__init__.py @@ -0,0 +1,15 @@ +""" +Utility functions for Conversation API message operations. +""" + +from sinch.domains.conversation.api.v1.utils.message_helpers import ( + build_recipient_dict, + coerce_recipient, + split_send_kwargs, +) + +__all__ = [ + "build_recipient_dict", + "coerce_recipient", + "split_send_kwargs", +] diff --git a/sinch/domains/conversation/api/v1/utils/message_helpers.py b/sinch/domains/conversation/api/v1/utils/message_helpers.py new file mode 100644 index 00000000..f0f62601 --- /dev/null +++ b/sinch/domains/conversation/api/v1/utils/message_helpers.py @@ -0,0 +1,122 @@ +""" +Helper functions for building and processing message requests. + +This module contains pure utility functions that handle common operations +for message sending, such as recipient validation, type coercion, and +parameter splitting. +""" + +from typing import List, Optional, Union + +from sinch.domains.conversation.models.v1.messages.internal.request.recipient import ( + ChannelRecipientIdentity, + IdentifiedBy, + Recipient, +) +from sinch.domains.conversation.models.v1.messages.internal.request.send_message_request_body import ( + SendMessageRequestBody, +) +from sinch.domains.conversation.models.v1.messages.types import ( + ChannelRecipientIdentityDict, + RecipientDict, +) + + +def build_recipient_dict( + contact_id: Optional[str] = None, + recipient_identities: Optional[List[ChannelRecipientIdentityDict]] = None, +) -> RecipientDict: + """ + Build a RecipientDict from optional contact_id or recipient_identities. + + Validates that exactly one of the parameters is provided and returns + the appropriate dictionary structure. + + :param contact_id: The contact ID of the recipient. + :type contact_id: Optional[str] + :param recipient_identities: List of channel identities for the recipient. + :type recipient_identities: Optional[List[ChannelRecipientIdentityDict]] + + :returns: A RecipientDict with either contact_id or channel_identities. + :rtype: RecipientDict + + :raises ValueError: If both or neither parameters are provided. + """ + has_contact_id = contact_id is not None + has_identities = recipient_identities is not None + + if has_contact_id and has_identities: + raise ValueError( + "Cannot specify both 'contact_id' and 'recipient_identities'. " + "Provide exactly one." + ) + if not has_contact_id and not has_identities: + raise ValueError( + "Must provide either 'contact_id' or 'recipient_identities'." + ) + + return ( + {"contact_id": contact_id} + if has_contact_id + else {"channel_identities": recipient_identities} + ) + + +def coerce_recipient(recipient: Union[Recipient, dict]) -> Recipient: + """ + Coerce a recipient input to a Recipient model instance. + + Handles multiple input formats: + - Recipient model instance (returns as-is) + - Simplified dict: {"channel_identities": [...]} + - Simplified dict: {"contact_id": "..."} + - Full form dict: {"identified_by": {"channel_identities": [...]}} + + :param recipient: The recipient as a Recipient model or dict. + :type recipient: Union[Recipient, dict] + + :returns: A Recipient model instance. + :rtype: Recipient + """ + if isinstance(recipient, dict): + # Allow passing recipient dict in simplified form: + # - {"channel_identities": [...]} -> converts to {"identified_by": {"channel_identities": [...]}} + # - {"contact_id": "..."} + # - Or full form: {"identified_by": {"channel_identities": [...]}} + if ( + "channel_identities" in recipient + and "identified_by" not in recipient + ): + channel_identities = [ + ChannelRecipientIdentity(**ci) if isinstance(ci, dict) else ci + for ci in recipient["channel_identities"] + ] + return Recipient( + identified_by=IdentifiedBy( + channel_identities=channel_identities + ) + ) + return Recipient(**recipient) + return recipient + + +def split_send_kwargs(kwargs: dict) -> tuple[dict, dict]: + """ + Split kwargs into message-level and request-level parameters. + + Separates keyword arguments into two groups: + - message_kwargs: Fields that belong under the `message` field + - request_kwargs: Fields that belong on the SendMessageRequest itself + + :param kwargs: Dictionary of keyword arguments to split. + :type kwargs: dict + + :returns: A tuple of (message_kwargs, request_kwargs). + :rtype: tuple[dict, dict] + """ + message_fields = set(SendMessageRequestBody.model_fields.keys()) + message_kwargs = {k: v for k, v in kwargs.items() if k in message_fields} + request_kwargs = { + k: v for k, v in kwargs.items() if k not in message_fields + } + return message_kwargs, request_kwargs diff --git a/sinch/domains/conversation/conversation.py b/sinch/domains/conversation/conversation.py new file mode 100644 index 00000000..f2eaa2b3 --- /dev/null +++ b/sinch/domains/conversation/conversation.py @@ -0,0 +1,28 @@ +from sinch.domains.conversation.api.v1 import ( + Messages, +) +from sinch.domains.conversation.sinch_events.v1 import ConversationSinchEvent + + +class Conversation: + """ + Documentation for Sinch Conversation is found at + https://developers.sinch.com/docs/conversation/. + """ + + def __init__(self, sinch): + self._sinch = sinch + self.messages = Messages(self._sinch) + + def sinch_events( + self, callback_secret: str = "" + ) -> ConversationSinchEvent: + """ + Create a Conversation API Sinch Events handler with the given callback secret. + + :param callback_secret: Secret used for Sinch Event signature validation. + :type callback_secret: str + :returns: A configured Sinch Events handler. + :rtype: ConversationSinchEvent + """ + return ConversationSinchEvent(callback_secret) diff --git a/sinch/domains/conversation/endpoints/app/create_app.py b/sinch/domains/conversation/endpoints/app/create_app.py deleted file mode 100644 index 7e839141..00000000 --- a/sinch/domains/conversation/endpoints/app/create_app.py +++ /dev/null @@ -1,39 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.app.responses import CreateConversationAppResponse -from sinch.domains.conversation.models.app.requests import CreateConversationAppRequest - - -class CreateConversationAppEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/apps" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: CreateConversationAppRequest): - super(CreateConversationAppEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> CreateConversationAppResponse: - super(CreateConversationAppEndpoint, self).handle_response(response) - return CreateConversationAppResponse( - id=response.body["id"], - channel_credentials=response.body["channel_credentials"], - processing_mode=response.body["processing_mode"], - conversation_metadata_report_view=response.body["conversation_metadata_report_view"], - display_name=response.body["display_name"], - rate_limits=response.body["rate_limits"], - retention_policy=response.body["retention_policy"], - dispatch_retention_policy=response.body["dispatch_retention_policy"], - smart_conversation=response.body["smart_conversation"] - ) diff --git a/sinch/domains/conversation/endpoints/app/delete_app.py b/sinch/domains/conversation/endpoints/app/delete_app.py deleted file mode 100644 index 09c1933c..00000000 --- a/sinch/domains/conversation/endpoints/app/delete_app.py +++ /dev/null @@ -1,27 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.app.responses import DeleteConversationAppResponse -from sinch.domains.conversation.models.app.requests import DeleteConversationAppRequest - - -class DeleteConversationAppEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/apps/{app_id}" - HTTP_METHOD = HTTPMethods.DELETE.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: DeleteConversationAppRequest): - super(DeleteConversationAppEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - app_id=self.request_data.app_id - ) - - def handle_response(self, response: HTTPResponse) -> DeleteConversationAppResponse: - super(DeleteConversationAppEndpoint, self).handle_response(response) - return DeleteConversationAppResponse() diff --git a/sinch/domains/conversation/endpoints/app/get_app.py b/sinch/domains/conversation/endpoints/app/get_app.py deleted file mode 100644 index d0ff1232..00000000 --- a/sinch/domains/conversation/endpoints/app/get_app.py +++ /dev/null @@ -1,37 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.app.responses import GetConversationAppResponse -from sinch.domains.conversation.models.app.requests import GetConversationAppRequest - - -class GetAppEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/apps/{app_id}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: GetConversationAppRequest): - super(GetAppEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - app_id=self.request_data.app_id - ) - - def handle_response(self, response: HTTPResponse) -> GetConversationAppResponse: - super(GetAppEndpoint, self).handle_response(response) - return GetConversationAppResponse( - id=response.body["id"], - channel_credentials=response.body["channel_credentials"], - processing_mode=response.body["processing_mode"], - conversation_metadata_report_view=response.body["conversation_metadata_report_view"], - display_name=response.body["display_name"], - rate_limits=response.body["rate_limits"], - retention_policy=response.body["retention_policy"], - dispatch_retention_policy=response.body["dispatch_retention_policy"], - smart_conversation=response.body["smart_conversation"] - ) diff --git a/sinch/domains/conversation/endpoints/app/list_apps.py b/sinch/domains/conversation/endpoints/app/list_apps.py deleted file mode 100644 index 5dbe5139..00000000 --- a/sinch/domains/conversation/endpoints/app/list_apps.py +++ /dev/null @@ -1,39 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.app.responses import ListConversationAppsResponse -from sinch.domains.conversation.models import SinchConversationApp - - -class ListAppsEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/apps" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str): - super(ListAppsEndpoint, self).__init__(project_id, request_data=None) - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def handle_response(self, response: HTTPResponse) -> ListConversationAppsResponse: - super(ListAppsEndpoint, self).handle_response(response) - return ListConversationAppsResponse( - apps=[ - SinchConversationApp( - id=contact["id"], - channel_credentials=contact["channel_credentials"], - processing_mode=contact["processing_mode"], - conversation_metadata_report_view=contact["conversation_metadata_report_view"], - display_name=contact["display_name"], - rate_limits=contact["rate_limits"], - retention_policy=contact["retention_policy"], - dispatch_retention_policy=contact["dispatch_retention_policy"], - smart_conversation=contact["smart_conversation"] - ) for contact in response.body["apps"] - ] - ) diff --git a/sinch/domains/conversation/endpoints/app/update_app.py b/sinch/domains/conversation/endpoints/app/update_app.py deleted file mode 100644 index 9cb7e11a..00000000 --- a/sinch/domains/conversation/endpoints/app/update_app.py +++ /dev/null @@ -1,46 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.app.responses import UpdateConversationAppResponse -from sinch.domains.conversation.models.app.requests import UpdateConversationAppRequest - - -class UpdateConversationAppEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/apps/{app_id}" - HTTP_METHOD = HTTPMethods.PATCH.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: UpdateConversationAppRequest): - super(UpdateConversationAppEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - app_id=self.request_data.app_id - ) - - def build_query_params(self): - if self.request_data.update_mask: - return {"update_mask.paths": self.request_data.update_mask} - - def request_body(self): - self.request_data.update_mask = None - self.request_data.app_id = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> UpdateConversationAppResponse: - super(UpdateConversationAppEndpoint, self).handle_response(response) - return UpdateConversationAppResponse( - id=response.body["id"], - channel_credentials=response.body["channel_credentials"], - processing_mode=response.body["processing_mode"], - conversation_metadata_report_view=response.body["conversation_metadata_report_view"], - display_name=response.body["display_name"], - rate_limits=response.body["rate_limits"], - retention_policy=response.body["retention_policy"], - dispatch_retention_policy=response.body["dispatch_retention_policy"], - smart_conversation=response.body["smart_conversation"] - ) diff --git a/sinch/domains/conversation/endpoints/capability.py b/sinch/domains/conversation/endpoints/capability.py deleted file mode 100644 index 46fab13c..00000000 --- a/sinch/domains/conversation/endpoints/capability.py +++ /dev/null @@ -1,33 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.capability.requests import QueryConversationCapabilityRequest -from sinch.domains.conversation.models.capability.responses import QueryConversationCapabilityResponse - - -class CapabilityQueryEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/capability:query" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: QueryConversationCapabilityRequest): - super(CapabilityQueryEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> QueryConversationCapabilityResponse: - super(CapabilityQueryEndpoint, self).handle_response(response) - return QueryConversationCapabilityResponse( - request_id=response.body["request_id"], - app_id=response.body["app_id"], - recipient=response.body["recipient"], - ) diff --git a/sinch/domains/conversation/endpoints/contact/create_contact.py b/sinch/domains/conversation/endpoints/contact/create_contact.py deleted file mode 100644 index c26d6f3f..00000000 --- a/sinch/domains/conversation/endpoints/contact/create_contact.py +++ /dev/null @@ -1,38 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.contact.responses import CreateConversationContactResponse -from sinch.domains.conversation.models.contact.requests import CreateConversationContactRequest - - -class CreateConversationContactEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/contacts" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: CreateConversationContactRequest): - super(CreateConversationContactEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> CreateConversationContactResponse: - super(CreateConversationContactEndpoint, self).handle_response(response) - return CreateConversationContactResponse( - id=response.body["id"], - channel_identities=response.body["channel_identities"], - channel_priority=response.body["channel_priority"], - display_name=response.body["display_name"], - email=response.body["email"], - external_id=response.body["external_id"], - metadata=response.body["metadata"], - language=response.body["language"] - ) diff --git a/sinch/domains/conversation/endpoints/contact/delete_contact.py b/sinch/domains/conversation/endpoints/contact/delete_contact.py deleted file mode 100644 index 138dd4bf..00000000 --- a/sinch/domains/conversation/endpoints/contact/delete_contact.py +++ /dev/null @@ -1,26 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.contact.responses import DeleteConversationContactResponse - - -class DeleteContactEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/contacts/{contact_id}" - HTTP_METHOD = HTTPMethods.DELETE.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id, request_data): - super(DeleteContactEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - contact_id=self.request_data.contact_id - ) - - def handle_response(self, response: HTTPResponse) -> DeleteConversationContactResponse: - super(DeleteContactEndpoint, self).handle_response(response) - return DeleteConversationContactResponse() diff --git a/sinch/domains/conversation/endpoints/contact/get_channel_profile.py b/sinch/domains/conversation/endpoints/contact/get_channel_profile.py deleted file mode 100644 index e655b42d..00000000 --- a/sinch/domains/conversation/endpoints/contact/get_channel_profile.py +++ /dev/null @@ -1,31 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.contact.requests import GetConversationChannelProfileRequest -from sinch.domains.conversation.models.contact.responses import GetConversationChannelProfileResponse - - -class GetChannelProfileEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/contacts:getChannelProfile" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: GetConversationChannelProfileRequest): - super(GetChannelProfileEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> GetConversationChannelProfileResponse: - super(GetChannelProfileEndpoint, self).handle_response(response) - return GetConversationChannelProfileResponse( - profile_name=response.body.get("profile_name") - ) diff --git a/sinch/domains/conversation/endpoints/contact/get_contact.py b/sinch/domains/conversation/endpoints/contact/get_contact.py deleted file mode 100644 index 6d2d915f..00000000 --- a/sinch/domains/conversation/endpoints/contact/get_contact.py +++ /dev/null @@ -1,36 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.contact.requests import GetConversationContactRequest -from sinch.domains.conversation.models.contact.responses import GetConversationContactResponse - - -class GetContactEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/contacts/{contact_id}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id, request_data: GetConversationContactRequest): - super(GetContactEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - contact_id=self.request_data.contact_id - ) - - def handle_response(self, response: HTTPResponse) -> GetConversationContactResponse: - super(GetContactEndpoint, self).handle_response(response) - return GetConversationContactResponse( - id=response.body["id"], - channel_identities=response.body["channel_identities"], - channel_priority=response.body["channel_priority"], - display_name=response.body["display_name"], - email=response.body["email"], - external_id=response.body["external_id"], - metadata=response.body["metadata"], - language=response.body["language"] - ) diff --git a/sinch/domains/conversation/endpoints/contact/list_contact.py b/sinch/domains/conversation/endpoints/contact/list_contact.py deleted file mode 100644 index b70c6e7f..00000000 --- a/sinch/domains/conversation/endpoints/contact/list_contact.py +++ /dev/null @@ -1,48 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.contact.responses import ListConversationContactsResponse -from sinch.domains.conversation.models import SinchConversationContact - - -class ListContactsEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/contacts" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id, request_data): - super(ListContactsEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def build_query_params(self): - params = {} - if self.request_data.page_size: - params["page_size"] = self.request_data.page_size - - if self.request_data.page_token: - params["page_token"] = self.request_data.page_token - - return params - - def handle_response(self, response: HTTPResponse) -> ListConversationContactsResponse: - super(ListContactsEndpoint, self).handle_response(response) - return ListConversationContactsResponse( - contacts=[SinchConversationContact( - id=contact["id"], - channel_identities=contact["channel_identities"], - channel_priority=contact["channel_priority"], - display_name=contact["display_name"], - email=contact["email"], - external_id=contact["external_id"], - metadata=contact["metadata"], - language=contact["language"] - ) for contact in response.body["contacts"]], - next_page_token=response.body.get("next_page_token") - ) diff --git a/sinch/domains/conversation/endpoints/contact/merge_contacts.py b/sinch/domains/conversation/endpoints/contact/merge_contacts.py deleted file mode 100644 index 28780076..00000000 --- a/sinch/domains/conversation/endpoints/contact/merge_contacts.py +++ /dev/null @@ -1,34 +0,0 @@ -import json -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.contact.responses import MergeConversationContactsResponse - - -class MergeConversationContactsEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/contacts/{destination_id}:merge" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id, request_data): - super(MergeConversationContactsEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - destination_id=self.request_data.destination_id - ) - - def request_body(self): - return json.dumps({ - "source_id": self.request_data.source_id - }) - - def handle_response(self, response: HTTPResponse) -> MergeConversationContactsResponse: - super(MergeConversationContactsEndpoint, self).handle_response(response) - return MergeConversationContactsResponse( - **response.body - ) diff --git a/sinch/domains/conversation/endpoints/contact/update_contact.py b/sinch/domains/conversation/endpoints/contact/update_contact.py deleted file mode 100644 index 73114bf1..00000000 --- a/sinch/domains/conversation/endpoints/contact/update_contact.py +++ /dev/null @@ -1,31 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.contact.requests import UpdateConversationContactRequest -from sinch.domains.conversation.models.contact.responses import UpdateConversationContactResponse - - -class UpdateConversationContactEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/contacts" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: UpdateConversationContactRequest): - super(UpdateConversationContactEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> UpdateConversationContactResponse: - super(UpdateConversationContactEndpoint, self).handle_response(response) - return UpdateConversationContactResponse( - **response.body - ) diff --git a/sinch/domains/conversation/endpoints/conversation/create_conversation.py b/sinch/domains/conversation/endpoints/conversation/create_conversation.py deleted file mode 100644 index 374611c0..00000000 --- a/sinch/domains/conversation/endpoints/conversation/create_conversation.py +++ /dev/null @@ -1,38 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.conversation.responses import SinchCreateConversationResponse -from sinch.domains.conversation.models.conversation.requests import CreateConversationRequest - - -class CreateConversationEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/conversations" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: CreateConversationRequest): - super(CreateConversationEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> SinchCreateConversationResponse: - super(CreateConversationEndpoint, self).handle_response(response) - return SinchCreateConversationResponse( - id=response.body["id"], - app_id=response.body["app_id"], - contact_id=response.body["contact_id"], - last_received=response.body["last_received"], - active_channel=response.body["active_channel"], - active=response.body["active"], - metadata=response.body["metadata"], - metadata_json=response.body["metadata_json"] - ) diff --git a/sinch/domains/conversation/endpoints/conversation/delete_conversation.py b/sinch/domains/conversation/endpoints/conversation/delete_conversation.py deleted file mode 100644 index eb4f53a7..00000000 --- a/sinch/domains/conversation/endpoints/conversation/delete_conversation.py +++ /dev/null @@ -1,27 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.conversation.responses import SinchDeleteConversationResponse -from sinch.domains.conversation.models.conversation.requests import DeleteConversationRequest - - -class DeleteConversationEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/conversations/{conversation_id}" - HTTP_METHOD = HTTPMethods.DELETE.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: DeleteConversationRequest): - super(DeleteConversationEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - conversation_id=self.request_data.conversation_id - ) - - def handle_response(self, response: HTTPResponse) -> SinchDeleteConversationResponse: - super(DeleteConversationEndpoint, self).handle_response(response) - return SinchDeleteConversationResponse() diff --git a/sinch/domains/conversation/endpoints/conversation/get_conversation.py b/sinch/domains/conversation/endpoints/conversation/get_conversation.py deleted file mode 100644 index 4f899c87..00000000 --- a/sinch/domains/conversation/endpoints/conversation/get_conversation.py +++ /dev/null @@ -1,36 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.conversation.responses import SinchGetConversationResponse -from sinch.domains.conversation.models.conversation.requests import GetConversationRequest - - -class GetConversationEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/conversations/{conversation_id}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: GetConversationRequest): - super(GetConversationEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - conversation_id=self.request_data.conversation_id - ) - - def handle_response(self, response: HTTPResponse) -> SinchGetConversationResponse: - super(GetConversationEndpoint, self).handle_response(response) - return SinchGetConversationResponse( - id=response.body["id"], - app_id=response.body["app_id"], - contact_id=response.body["contact_id"], - last_received=response.body["last_received"], - active_channel=response.body["active_channel"], - active=response.body["active"], - metadata=response.body["metadata"], - metadata_json=response.body["metadata_json"] - ) diff --git a/sinch/domains/conversation/endpoints/conversation/inject_message_to_conversation.py b/sinch/domains/conversation/endpoints/conversation/inject_message_to_conversation.py deleted file mode 100644 index d103e08f..00000000 --- a/sinch/domains/conversation/endpoints/conversation/inject_message_to_conversation.py +++ /dev/null @@ -1,30 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.conversation.responses import SinchInjectMessageResponse -from sinch.domains.conversation.models.conversation.requests import InjectMessageToConversationRequest - - -class InjectMessageToConversationEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/conversations/{conversation_id}:inject-message" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: InjectMessageToConversationRequest): - super(InjectMessageToConversationEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - conversation_id=self.request_data.conversation_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> SinchInjectMessageResponse: - super(InjectMessageToConversationEndpoint, self).handle_response(response) - return SinchInjectMessageResponse() diff --git a/sinch/domains/conversation/endpoints/conversation/list_conversations.py b/sinch/domains/conversation/endpoints/conversation/list_conversations.py deleted file mode 100644 index 9055b640..00000000 --- a/sinch/domains/conversation/endpoints/conversation/list_conversations.py +++ /dev/null @@ -1,58 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.domains.conversation.models.conversation.responses import SinchListConversationsResponse -from sinch.domains.conversation.models.conversation.requests import ListConversationsRequest -from sinch.domains.conversation.models.conversation import Conversation -from sinch.core.enums import HTTPAuthentication, HTTPMethods - - -class ListConversationsEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/conversations" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: ListConversationsRequest): - super(ListConversationsEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def build_query_params(self): - query_params = {} - if self.request_data.app_id: - query_params["app_id"] = self.request_data.app_id - - if self.request_data.contact_id: - query_params["contact_id"] = self.request_data.contact_id - - if self.request_data.page_size: - query_params["page_size"] = self.request_data.page_size - - if self.request_data.page_token: - query_params["page_token"] = self.request_data.page_token - - return query_params - - def handle_response(self, response: HTTPResponse) -> SinchListConversationsResponse: - super(ListConversationsEndpoint, self).handle_response(response) - return SinchListConversationsResponse( - conversations=[ - Conversation( - id=conversation["id"], - app_id=conversation["app_id"], - contact_id=conversation["contact_id"], - last_received=conversation["last_received"], - active_channel=conversation["active_channel"], - active=conversation["active"], - metadata=conversation["metadata"], - metadata_json=conversation["metadata_json"] - ) for conversation in response.body["conversations"] - ], - next_page_token=response.body["next_page_token"], - total_size=response.body["total_size"] - ) diff --git a/sinch/domains/conversation/endpoints/conversation/stop_conversation.py b/sinch/domains/conversation/endpoints/conversation/stop_conversation.py deleted file mode 100644 index b9c7be04..00000000 --- a/sinch/domains/conversation/endpoints/conversation/stop_conversation.py +++ /dev/null @@ -1,27 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.conversation.responses import SinchStopConversationResponse -from sinch.domains.conversation.models.conversation.requests import StopConversationRequest - - -class StopConversationEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/conversations/{conversation_id}:stop" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: StopConversationRequest): - super(StopConversationEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - conversation_id=self.request_data.conversation_id - ) - - def handle_response(self, response: HTTPResponse) -> SinchStopConversationResponse: - super(StopConversationEndpoint, self).handle_response(response) - return SinchStopConversationResponse() diff --git a/sinch/domains/conversation/endpoints/conversation/update_conversation.py b/sinch/domains/conversation/endpoints/conversation/update_conversation.py deleted file mode 100644 index 31c4748b..00000000 --- a/sinch/domains/conversation/endpoints/conversation/update_conversation.py +++ /dev/null @@ -1,40 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.conversation.responses import SinchUpdateConversationResponse -from sinch.domains.conversation.models.conversation.requests import UpdateConversationRequest - - -class UpdateConversationEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/conversations/{conversation_id}" - HTTP_METHOD = HTTPMethods.PATCH.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: UpdateConversationRequest): - super(UpdateConversationEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - conversation_id=self.request_data.conversation_id - ) - - def request_body(self): - self.request_data.conversation_id = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> SinchUpdateConversationResponse: - super(UpdateConversationEndpoint, self).handle_response(response) - return SinchUpdateConversationResponse( - id=response.body["id"], - app_id=response.body["app_id"], - contact_id=response.body["contact_id"], - last_received=response.body["last_received"], - active_channel=response.body["active_channel"], - active=response.body["active"], - metadata=response.body["metadata"], - metadata_json=response.body["metadata_json"] - ) diff --git a/sinch/domains/conversation/endpoints/conversation_endpoint.py b/sinch/domains/conversation/endpoints/conversation_endpoint.py deleted file mode 100644 index 76f9d4bd..00000000 --- a/sinch/domains/conversation/endpoints/conversation_endpoint.py +++ /dev/null @@ -1,13 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.core.endpoint import HTTPEndpoint -from sinch.domains.conversation.exceptions import ConversationException - - -class ConversationEndpoint(HTTPEndpoint): - def handle_response(self, response: HTTPResponse): - if response.status_code >= 400: - raise ConversationException( - message=response.body["error"].get("message"), - response=response, - is_from_server=True - ) diff --git a/sinch/domains/conversation/endpoints/events.py b/sinch/domains/conversation/endpoints/events.py deleted file mode 100644 index 90d76ff7..00000000 --- a/sinch/domains/conversation/endpoints/events.py +++ /dev/null @@ -1,32 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.event.requests import SendConversationEventRequest -from sinch.domains.conversation.models.event.responses import SendConversationEventResponse - - -class SendEventEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/events:send" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: SendConversationEventRequest): - super(SendEventEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> SendConversationEventResponse: - super(SendEventEndpoint, self).handle_response(response) - return SendConversationEventResponse( - accepted_time=response.body["accepted_time"], - event_id=response.body["event_id"] - ) diff --git a/sinch/domains/conversation/endpoints/message/delete_message.py b/sinch/domains/conversation/endpoints/message/delete_message.py deleted file mode 100644 index bcff7499..00000000 --- a/sinch/domains/conversation/endpoints/message/delete_message.py +++ /dev/null @@ -1,32 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.message.responses import DeleteConversationMessageResponse -from sinch.domains.conversation.models.message.requests import DeleteConversationMessageRequest - - -class DeleteConversationMessageEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages/{message_id}" - HTTP_METHOD = HTTPMethods.DELETE.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: DeleteConversationMessageRequest): - super(DeleteConversationMessageEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - message_id=self.request_data.message_id - ) - - def build_query_params(self): - if self.request_data.messages_source: - return { - "messages_source": self.request_data.messages_source - } - - def handle_response(self, response: HTTPResponse) -> DeleteConversationMessageResponse: - return DeleteConversationMessageResponse() diff --git a/sinch/domains/conversation/endpoints/message/get_message.py b/sinch/domains/conversation/endpoints/message/get_message.py deleted file mode 100644 index 4a10a083..00000000 --- a/sinch/domains/conversation/endpoints/message/get_message.py +++ /dev/null @@ -1,43 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.message.responses import GetConversationMessageResponse -from sinch.domains.conversation.models.message.requests import GetConversationMessageRequest - - -class GetConversationMessageEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages/{message_id}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: GetConversationMessageRequest): - super(GetConversationMessageEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - message_id=self.request_data.message_id - ) - - def build_query_params(self): - if self.request_data.messages_source: - return { - "messages_source": self.request_data.messages_source - } - - def handle_response(self, response: HTTPResponse) -> GetConversationMessageResponse: - return GetConversationMessageResponse( - id=response.body["id"], - direction=response.body["direction"], - channel_identity=response.body["channel_identity"], - app_message=response.body["app_message"], - conversation_id=response.body["conversation_id"], - contact_id=response.body["contact_id"], - metadata=response.body["metadata"], - accept_time=response.body["accept_time"], - sender_id=response.body["sender_id"], - processing_mode=response.body["processing_mode"], - ) diff --git a/sinch/domains/conversation/endpoints/message/list_message.py b/sinch/domains/conversation/endpoints/message/list_message.py deleted file mode 100644 index b4da35a0..00000000 --- a/sinch/domains/conversation/endpoints/message/list_message.py +++ /dev/null @@ -1,71 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models import SinchConversationMessage -from sinch.domains.conversation.models.message.responses import ListConversationMessagesResponse -from sinch.domains.conversation.models.message.requests import ListConversationMessagesRequest - - -class ListConversationMessagesEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: ListConversationMessagesRequest): - super(ListConversationMessagesEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def build_query_params(self): - query_params = {} - if self.request_data.conversation_id: - query_params["conversation_id"] = self.request_data.conversation_id - - if self.request_data.contact_id: - query_params["contact_id"] = self.request_data.contact_id - - if self.request_data.page_size: - query_params["page_size"] = self.request_data.page_size - - if self.request_data.page_token: - query_params["page_token"] = self.request_data.page_token - - if self.request_data.app_id: - query_params["app_id"] = self.request_data.app_id - - if self.request_data.view: - query_params["view"] = self.request_data.view - - if self.request_data.messages_source: - query_params["messages_source"] = self.request_data.messages_source - - if self.request_data.only_recipient_originated: - query_params["only_recipient_originated"] = self.request_data.only_recipient_originated - - return query_params - - def handle_response(self, response: HTTPResponse) -> ListConversationMessagesResponse: - super(ListConversationMessagesEndpoint, self).handle_response(response) - return ListConversationMessagesResponse( - messages=[ - SinchConversationMessage( - id=message["id"], - direction=message["direction"], - channel_identity=message["channel_identity"], - app_message=message["app_message"], - conversation_id=message["conversation_id"], - contact_id=message["contact_id"], - metadata=message["metadata"], - accept_time=message["accept_time"], - sender_id=message["sender_id"], - processing_mode=message["processing_mode"] - ) for message in response.body["messages"] - ], - next_page_token=response.body.get("next_page_token") - ) diff --git a/sinch/domains/conversation/endpoints/message/send_message.py b/sinch/domains/conversation/endpoints/message/send_message.py deleted file mode 100644 index dba2f61e..00000000 --- a/sinch/domains/conversation/endpoints/message/send_message.py +++ /dev/null @@ -1,31 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.message.responses import SendConversationMessageResponse -from sinch.domains.conversation.models.message.requests import SendConversationMessageRequest - - -class SendConversationMessageEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages:send" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: SendConversationMessageRequest): - super(SendConversationMessageEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> SendConversationMessageResponse: - super(SendConversationMessageEndpoint, self).handle_response(response) - return SendConversationMessageResponse( - **response.body - ) diff --git a/sinch/domains/conversation/endpoints/opt_in.py b/sinch/domains/conversation/endpoints/opt_in.py deleted file mode 100644 index 14dde1e7..00000000 --- a/sinch/domains/conversation/endpoints/opt_in.py +++ /dev/null @@ -1,39 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.opt_in_opt_out.requests import RegisterConversationOptInRequest -from sinch.domains.conversation.models.opt_in_opt_out.responses import RegisterConversationOptInResponse - - -class RegisterOptInEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/optins:register" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: RegisterConversationOptInRequest): - super(RegisterOptInEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def build_query_params(self): - if self.request_data.request_id: - return { - "request_id": self.request_data.request_id - } - - def request_body(self): - self.request_data.request_id = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> RegisterConversationOptInResponse: - super(RegisterOptInEndpoint, self).handle_response(response) - return RegisterConversationOptInResponse( - response.body["request_id"], - response.body["opt_in"] - ) diff --git a/sinch/domains/conversation/endpoints/opt_out.py b/sinch/domains/conversation/endpoints/opt_out.py deleted file mode 100644 index ade96da8..00000000 --- a/sinch/domains/conversation/endpoints/opt_out.py +++ /dev/null @@ -1,39 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.opt_in_opt_out.requests import RegisterConversationOptOutRequest -from sinch.domains.conversation.models.opt_in_opt_out.responses import RegisterConversationOptOutResponse - - -class RegisterOptOutEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/optouts:register" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: RegisterConversationOptOutRequest): - super(RegisterOptOutEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def build_query_params(self): - if self.request_data.request_id: - return { - "request_id": self.request_data.request_id - } - - def request_body(self): - self.request_data.request_id = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> RegisterConversationOptOutResponse: - super(RegisterOptOutEndpoint, self).handle_response(response) - return RegisterConversationOptOutResponse( - response.body["request_id"], - response.body["opt_out"] - ) diff --git a/sinch/domains/conversation/endpoints/templates/create_template.py b/sinch/domains/conversation/endpoints/templates/create_template.py deleted file mode 100644 index 9069243e..00000000 --- a/sinch/domains/conversation/endpoints/templates/create_template.py +++ /dev/null @@ -1,37 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.templates.responses import CreateConversationTemplateResponse -from sinch.domains.conversation.models.templates.requests import CreateConversationTemplateRequest - - -class CreateTemplateEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/templates" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: CreateConversationTemplateRequest): - super(CreateTemplateEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.templates_origin, - project_id=self.project_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> CreateConversationTemplateResponse: - super(CreateTemplateEndpoint, self).handle_response(response) - return CreateConversationTemplateResponse( - id=response.body["id"], - description=response.body["description"], - default_translation=response.body["default_translation"], - create_time=response.body["create_time"], - translations=response.body["translations"], - update_time=response.body["update_time"], - channel=response.body["channel"] - ) diff --git a/sinch/domains/conversation/endpoints/templates/delete_template.py b/sinch/domains/conversation/endpoints/templates/delete_template.py deleted file mode 100644 index 5706be16..00000000 --- a/sinch/domains/conversation/endpoints/templates/delete_template.py +++ /dev/null @@ -1,27 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.templates.responses import DeleteConversationTemplateResponse -from sinch.domains.conversation.models.templates.requests import DeleteConversationTemplateRequest - - -class DeleteTemplateEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/templates/{template_id}" - HTTP_METHOD = HTTPMethods.DELETE.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: DeleteConversationTemplateRequest): - super(DeleteTemplateEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.templates_origin, - project_id=self.project_id, - template_id=self.request_data.template_id - ) - - def handle_response(self, response: HTTPResponse) -> DeleteConversationTemplateResponse: - super(DeleteTemplateEndpoint, self).handle_response(response) - return DeleteConversationTemplateResponse() diff --git a/sinch/domains/conversation/endpoints/templates/get_template.py b/sinch/domains/conversation/endpoints/templates/get_template.py deleted file mode 100644 index d7a2a594..00000000 --- a/sinch/domains/conversation/endpoints/templates/get_template.py +++ /dev/null @@ -1,35 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.templates.responses import GetConversationTemplateResponse -from sinch.domains.conversation.models.templates.requests import GetConversationTemplateRequest - - -class GetTemplatesEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/templates/{template_id}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: GetConversationTemplateRequest): - super(GetTemplatesEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.templates_origin, - project_id=self.project_id, - template_id=self.request_data.template_id - ) - - def handle_response(self, response: HTTPResponse) -> GetConversationTemplateResponse: - super(GetTemplatesEndpoint, self).handle_response(response) - return GetConversationTemplateResponse( - id=response.body["id"], - description=response.body["description"], - default_translation=response.body["default_translation"], - create_time=response.body["create_time"], - translations=response.body["translations"], - update_time=response.body["update_time"], - channel=response.body["channel"] - ) diff --git a/sinch/domains/conversation/endpoints/templates/list_templates.py b/sinch/domains/conversation/endpoints/templates/list_templates.py deleted file mode 100644 index 19674d34..00000000 --- a/sinch/domains/conversation/endpoints/templates/list_templates.py +++ /dev/null @@ -1,38 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.templates.responses import ListConversationTemplatesResponse -from sinch.domains.conversation.models.templates import ConversationTemplate - - -class ListTemplatesEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/templates" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data=None): - super(ListTemplatesEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.templates_origin, - project_id=self.project_id - ) - - def handle_response(self, response: HTTPResponse) -> ListConversationTemplatesResponse: - super(ListTemplatesEndpoint, self).handle_response(response) - return ListConversationTemplatesResponse( - templates=[ - ConversationTemplate( - id=template["id"], - description=template["description"], - default_translation=template["default_translation"], - create_time=template["create_time"], - translations=template["translations"], - update_time=template["update_time"], - channel=template["channel"] - ) for template in response.body["templates"] - ] - ) diff --git a/sinch/domains/conversation/endpoints/templates/update_template.py b/sinch/domains/conversation/endpoints/templates/update_template.py deleted file mode 100644 index f4678934..00000000 --- a/sinch/domains/conversation/endpoints/templates/update_template.py +++ /dev/null @@ -1,39 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.templates.responses import UpdateConversationTemplateResponse -from sinch.domains.conversation.models.templates.requests import UpdateConversationTemplateRequest - - -class UpdateTemplateEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/templates/{template_id}" - HTTP_METHOD = HTTPMethods.PATCH.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: UpdateConversationTemplateRequest): - super(UpdateTemplateEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.templates_origin, - project_id=self.project_id, - template_id=self.request_data.template_id - ) - - def request_body(self): - self.request_data.template_id = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> UpdateConversationTemplateResponse: - super(UpdateTemplateEndpoint, self).handle_response(response) - return UpdateConversationTemplateResponse( - id=response.body["id"], - description=response.body["description"], - default_translation=response.body["default_translation"], - create_time=response.body["create_time"], - translations=response.body["translations"], - update_time=response.body["update_time"], - channel=response.body["channel"] - ) diff --git a/sinch/domains/conversation/endpoints/transcode.py b/sinch/domains/conversation/endpoints/transcode.py deleted file mode 100644 index b4e41684..00000000 --- a/sinch/domains/conversation/endpoints/transcode.py +++ /dev/null @@ -1,31 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.transcoding.requests import TranscodeConversationMessageRequest -from sinch.domains.conversation.models.transcoding.responses import TranscodeConversationMessageResponse - - -class TranscodeMessageEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages:transcode" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: TranscodeConversationMessageRequest): - super(TranscodeMessageEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> TranscodeConversationMessageResponse: - super(TranscodeMessageEndpoint, self).handle_response(response) - return TranscodeConversationMessageResponse( - transcoded_message=response.body["transcoded_message"] - ) diff --git a/sinch/domains/conversation/endpoints/webhooks/create_webhook.py b/sinch/domains/conversation/endpoints/webhooks/create_webhook.py deleted file mode 100644 index 5466ea0e..00000000 --- a/sinch/domains/conversation/endpoints/webhooks/create_webhook.py +++ /dev/null @@ -1,37 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.webhook.responses import CreateWebhookResponse -from sinch.domains.conversation.models.webhook.requests import CreateConversationWebhookRequest - - -class CreateWebhookEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/webhooks" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: CreateConversationWebhookRequest): - super(CreateWebhookEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> CreateWebhookResponse: - super(CreateWebhookEndpoint, self).handle_response(response) - return CreateWebhookResponse( - id=response.body["id"], - app_id=response.body["app_id"], - target=response.body["target"], - target_type=response.body["target_type"], - secret=response.body["secret"], - triggers=response.body["triggers"], - client_credentials=response.body["client_credentials"] - ) diff --git a/sinch/domains/conversation/endpoints/webhooks/delete_webhook.py b/sinch/domains/conversation/endpoints/webhooks/delete_webhook.py deleted file mode 100644 index 0c16da71..00000000 --- a/sinch/domains/conversation/endpoints/webhooks/delete_webhook.py +++ /dev/null @@ -1,27 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.webhook.responses import SinchDeleteWebhookResponse -from sinch.domains.conversation.models.webhook.requests import DeleteConversationWebhookRequest - - -class DeleteWebhookEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/webhooks/{webhook_id}" - HTTP_METHOD = HTTPMethods.DELETE.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: DeleteConversationWebhookRequest): - super(DeleteWebhookEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - webhook_id=self.request_data.webhook_id - ) - - def handle_response(self, response: HTTPResponse) -> SinchDeleteWebhookResponse: - super(DeleteWebhookEndpoint, self).handle_response(response) - return SinchDeleteWebhookResponse() diff --git a/sinch/domains/conversation/endpoints/webhooks/get_webhook.py b/sinch/domains/conversation/endpoints/webhooks/get_webhook.py deleted file mode 100644 index 3942a30b..00000000 --- a/sinch/domains/conversation/endpoints/webhooks/get_webhook.py +++ /dev/null @@ -1,35 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.webhook.responses import GetWebhookResponse -from sinch.domains.conversation.models.webhook.requests import GetConversationWebhookRequest - - -class GetWebhookEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/webhooks/{webhook_id}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: GetConversationWebhookRequest): - super(GetWebhookEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - webhook_id=self.request_data.webhook_id - ) - - def handle_response(self, response: HTTPResponse) -> GetWebhookResponse: - super(GetWebhookEndpoint, self).handle_response(response) - return GetWebhookResponse( - id=response.body["id"], - app_id=response.body["app_id"], - target=response.body["target"], - target_type=response.body["target_type"], - secret=response.body["secret"], - triggers=response.body["triggers"], - client_credentials=response.body["client_credentials"] - ) diff --git a/sinch/domains/conversation/endpoints/webhooks/list_webhooks.py b/sinch/domains/conversation/endpoints/webhooks/list_webhooks.py deleted file mode 100644 index 5f1c6d0a..00000000 --- a/sinch/domains/conversation/endpoints/webhooks/list_webhooks.py +++ /dev/null @@ -1,38 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.webhook.responses import SinchListWebhooksResponse -from sinch.domains.conversation.models.webhook.requests import ListConversationWebhookRequest -from sinch.domains.conversation.models.webhook import ConversationWebhook - - -class ListWebhooksEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/apps/{app_id}/webhooks" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: ListConversationWebhookRequest): - super(ListWebhooksEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - app_id=self.request_data.app_id - ) - - def handle_response(self, response: HTTPResponse) -> SinchListWebhooksResponse: - super(ListWebhooksEndpoint, self).handle_response(response) - return SinchListWebhooksResponse( - webhooks=[ConversationWebhook( - id=webhook["id"], - app_id=webhook["app_id"], - target=webhook["target"], - target_type=webhook["target_type"], - secret=webhook["secret"], - triggers=webhook["triggers"], - client_credentials=webhook["client_credentials"] - ) for webhook in response.body["webhooks"]] - ) diff --git a/sinch/domains/conversation/endpoints/webhooks/update_webhook.py b/sinch/domains/conversation/endpoints/webhooks/update_webhook.py deleted file mode 100644 index 9ba6d372..00000000 --- a/sinch/domains/conversation/endpoints/webhooks/update_webhook.py +++ /dev/null @@ -1,39 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.webhook.responses import UpdateWebhookResponse -from sinch.domains.conversation.models.webhook.requests import UpdateConversationWebhookRequest - - -class UpdateWebhookEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/webhooks/{webhook_id}" - HTTP_METHOD = HTTPMethods.PATCH.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: UpdateConversationWebhookRequest): - super(UpdateWebhookEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - webhook_id=self.request_data.webhook_id - ) - - def request_body(self): - self.request_data.webhook_id = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> UpdateWebhookResponse: - super(UpdateWebhookEndpoint, self).handle_response(response) - return UpdateWebhookResponse( - id=response.body["id"], - app_id=response.body["app_id"], - target=response.body["target"], - target_type=response.body["target_type"], - secret=response.body["secret"], - triggers=response.body["triggers"], - client_credentials=response.body["client_credentials"] - ) diff --git a/sinch/domains/conversation/models/__init__.py b/sinch/domains/conversation/models/__init__.py index b82d1b6e..e69de29b 100644 --- a/sinch/domains/conversation/models/__init__.py +++ b/sinch/domains/conversation/models/__init__.py @@ -1,82 +0,0 @@ -from dataclasses import dataclass -from typing import Optional - -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.conversation.enums import ( - ConversationChannel, - ConversationRetentionPolicyType -) - - -@dataclass -class SinchConversationRecipient(SinchBaseModel): - contact_id: str - - -@dataclass -class SinchConversationTextMessage(SinchBaseModel): - pass - - -@dataclass -class SinchConversationMessage(SinchBaseModel): - id: str - direction: str - channel_identity: str - app_message: dict - conversation_id: str - contact_id: str - metadata: str - accept_time: str - sender_id: str - processing_mode: str - - -@dataclass -class SinchConversationChannelIdentities(SinchBaseModel): - channel: ConversationChannel - identity: str - app_id: str - - -@dataclass -class SinchConversationContact(SinchBaseModel): - id: str - channel_identities: SinchConversationChannelIdentities - channel_priority: list - display_name: str - email: str - external_id: str - metadata: str - language: str - - -@dataclass -class SinchConversationRetentionPolicy(SinchBaseModel): - retention_type: ConversationRetentionPolicyType - ttl_days: int - - -@dataclass -class SinchConversationTelegramCredentials(SinchBaseModel): # TODO: add more communication channels - token: str - - -@dataclass -class SinchConversationChannelCredentials(SinchBaseModel): - channel: ConversationChannel - callback_secret: Optional[str] = None - telegram_credentials: Optional[SinchConversationTelegramCredentials] = None - - -@dataclass -class SinchConversationApp(SinchBaseModel): - id: str - channel_credentials: dict - processing_mode: str - conversation_metadata_report_view: str - display_name: str - rate_limits: dict - retention_policy: dict - dispatch_retention_policy: dict - smart_conversation: dict diff --git a/sinch/domains/conversation/models/app/requests.py b/sinch/domains/conversation/models/app/requests.py deleted file mode 100644 index 4cd39651..00000000 --- a/sinch/domains/conversation/models/app/requests.py +++ /dev/null @@ -1,36 +0,0 @@ -from dataclasses import dataclass -from typing import Optional -from sinch.core.models.base_model import SinchRequestBaseModel -from sinch.domains.conversation.models import ( - SinchConversationRetentionPolicy -) -from sinch.domains.conversation.enums import ( - ConversationMetadataReportView, - ConversationProcessingMode -) - - -@dataclass -class CreateConversationAppRequest(SinchRequestBaseModel): - display_name: str - channel_credentials: Optional[list] - processing_mode: Optional[ConversationProcessingMode] - conversation_metadata_report_view: Optional[ConversationMetadataReportView] - retention_policy: Optional[SinchConversationRetentionPolicy] - dispatch_retention_policy: Optional[SinchConversationRetentionPolicy] - - -@dataclass -class DeleteConversationAppRequest(SinchRequestBaseModel): - app_id: str - - -@dataclass -class GetConversationAppRequest(SinchRequestBaseModel): - app_id: str - - -@dataclass -class UpdateConversationAppRequest(CreateConversationAppRequest): - app_id: str - update_mask: list diff --git a/sinch/domains/conversation/models/app/responses.py b/sinch/domains/conversation/models/app/responses.py deleted file mode 100644 index 6029ef35..00000000 --- a/sinch/domains/conversation/models/app/responses.py +++ /dev/null @@ -1,31 +0,0 @@ -from dataclasses import dataclass -from typing import List -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.conversation.models import ( - SinchConversationApp -) - - -@dataclass -class CreateConversationAppResponse(SinchConversationApp): - pass - - -@dataclass -class DeleteConversationAppResponse(SinchBaseModel): - pass - - -@dataclass -class ListConversationAppsResponse(SinchBaseModel): - apps: List[SinchConversationApp] - - -@dataclass -class GetConversationAppResponse(SinchConversationApp): - pass - - -@dataclass -class UpdateConversationAppResponse(SinchConversationApp): - pass diff --git a/sinch/domains/conversation/models/capability/requests.py b/sinch/domains/conversation/models/capability/requests.py deleted file mode 100644 index 116f5038..00000000 --- a/sinch/domains/conversation/models/capability/requests.py +++ /dev/null @@ -1,9 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class QueryConversationCapabilityRequest(SinchRequestBaseModel): - app_id: str - recipient: dict - request_id: str diff --git a/sinch/domains/conversation/models/capability/responses.py b/sinch/domains/conversation/models/capability/responses.py deleted file mode 100644 index a5fcab7b..00000000 --- a/sinch/domains/conversation/models/capability/responses.py +++ /dev/null @@ -1,9 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class QueryConversationCapabilityResponse(SinchBaseModel): - request_id: str - app_id: str - recipient: dict diff --git a/sinch/domains/conversation/models/contact/requests.py b/sinch/domains/conversation/models/contact/requests.py deleted file mode 100644 index 04c74f8b..00000000 --- a/sinch/domains/conversation/models/contact/requests.py +++ /dev/null @@ -1,60 +0,0 @@ -from dataclasses import dataclass -from typing import List, Optional -from sinch.core.models.base_model import SinchRequestBaseModel -from sinch.domains.conversation.models import ( - SinchConversationChannelIdentities, - SinchConversationRecipient -) - -from sinch.domains.conversation.enums import ( - ConversationChannel -) - - -@dataclass -class CreateConversationContactRequest(SinchRequestBaseModel): - language: str - channel_identities: Optional[List[SinchConversationChannelIdentities]] - channel_priority: Optional[List[str]] - display_name: Optional[str] - email: Optional[str] - external_id: Optional[str] - metadata: Optional[str] - - -@dataclass -class UpdateConversationContactRequest(CreateConversationContactRequest): - id: str - - -@dataclass -class ListConversationContactRequest(SinchRequestBaseModel): - page_size: int - page_token: str - external_id: str - channel: str - identity: str - - -@dataclass -class DeleteConversationContactRequest(SinchRequestBaseModel): - contact_id: str - - -@dataclass -class GetConversationContactRequest(SinchRequestBaseModel): - contact_id: str - - -@dataclass -class MergeConversationContactsRequest(SinchRequestBaseModel): - destination_id: str - source_id: str - strategy: str - - -@dataclass -class GetConversationChannelProfileRequest(SinchRequestBaseModel): - app_id: str - recipient: SinchConversationRecipient - channel: ConversationChannel diff --git a/sinch/domains/conversation/models/contact/responses.py b/sinch/domains/conversation/models/contact/responses.py deleted file mode 100644 index 496c9a10..00000000 --- a/sinch/domains/conversation/models/contact/responses.py +++ /dev/null @@ -1,40 +0,0 @@ -from dataclasses import dataclass -from typing import List -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.conversation.models import SinchConversationContact - - -@dataclass -class ListConversationContactsResponse(SinchBaseModel): - contacts: List[SinchConversationContact] - next_page_token: str - - -@dataclass -class CreateConversationContactResponse(SinchConversationContact): - pass - - -@dataclass -class DeleteConversationContactResponse(SinchBaseModel): - pass - - -@dataclass -class GetConversationContactResponse(SinchConversationContact): - pass - - -@dataclass -class MergeConversationContactsResponse(SinchConversationContact): - pass - - -@dataclass -class GetConversationChannelProfileResponse(SinchBaseModel): - profile_name: str - - -@dataclass -class UpdateConversationContactResponse(SinchConversationContact): - pass diff --git a/sinch/domains/conversation/models/conversation/__init__.py b/sinch/domains/conversation/models/conversation/__init__.py deleted file mode 100644 index b34d1d92..00000000 --- a/sinch/domains/conversation/models/conversation/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -from dataclasses import dataclass - -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class Conversation(SinchBaseModel): - id: str - app_id: str - contact_id: str - last_received: str - active_channel: str - active: str - metadata: str - metadata_json: str diff --git a/sinch/domains/conversation/models/conversation/requests.py b/sinch/domains/conversation/models/conversation/requests.py deleted file mode 100644 index cd9ef2b2..00000000 --- a/sinch/domains/conversation/models/conversation/requests.py +++ /dev/null @@ -1,62 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class ConversationRequest(SinchRequestBaseModel): - app_id: str - contact_id: str - active: bool - active_channel: str - app_id: str - contact_id: str - metadata: str - conversation_metadata: dict - - -@dataclass -class CreateConversationRequest(ConversationRequest): - id: str - - -@dataclass -class ListConversationsRequest(SinchRequestBaseModel): - app_id: str - contact_id: str - only_active: bool - page_size: int - page_token: str - - -@dataclass -class GetConversationRequest(SinchRequestBaseModel): - conversation_id: str - - -@dataclass -class DeleteConversationRequest(SinchRequestBaseModel): - conversation_id: str - - -@dataclass -class UpdateConversationRequest(ConversationRequest): - update_mask: str - metadata_update_strategy: str - conversation_id: str - - -@dataclass -class StopConversationRequest(SinchRequestBaseModel): - conversation_id: str - - -@dataclass -class InjectMessageToConversationRequest(SinchRequestBaseModel): - conversation_id: str - accept_time: str - app_message: dict - channel_identity: dict - contact_id: str - contact_message: dict - direction: str - metadata: str diff --git a/sinch/domains/conversation/models/conversation/responses.py b/sinch/domains/conversation/models/conversation/responses.py deleted file mode 100644 index d3add28c..00000000 --- a/sinch/domains/conversation/models/conversation/responses.py +++ /dev/null @@ -1,41 +0,0 @@ -from dataclasses import dataclass -from typing import List -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.conversation.models.conversation import Conversation - - -@dataclass -class SinchCreateConversationResponse(Conversation): - pass - - -@dataclass -class SinchListConversationsResponse(SinchBaseModel): - conversations: List[Conversation] - next_page_token: str - total_size: int - - -@dataclass -class SinchGetConversationResponse(Conversation): - pass - - -@dataclass -class SinchDeleteConversationResponse(SinchBaseModel): - pass - - -@dataclass -class SinchUpdateConversationResponse(Conversation): - pass - - -@dataclass -class SinchStopConversationResponse(SinchBaseModel): - pass - - -@dataclass -class SinchInjectMessageResponse(SinchBaseModel): - pass diff --git a/sinch/domains/conversation/models/event/requests.py b/sinch/domains/conversation/models/event/requests.py deleted file mode 100644 index c6fc7d0c..00000000 --- a/sinch/domains/conversation/models/event/requests.py +++ /dev/null @@ -1,13 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class SendConversationEventRequest(SinchRequestBaseModel): - app_id: str - recipient: dict - event: dict - callback_url: str - channel_priority_order: str - event_metadata: str - queue: str diff --git a/sinch/domains/conversation/models/event/responses.py b/sinch/domains/conversation/models/event/responses.py deleted file mode 100644 index 567b2e73..00000000 --- a/sinch/domains/conversation/models/event/responses.py +++ /dev/null @@ -1,8 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class SendConversationEventResponse(SinchBaseModel): - accepted_time: str - event_id: str diff --git a/sinch/domains/conversation/models/message/requests.py b/sinch/domains/conversation/models/message/requests.py deleted file mode 100644 index e353c11b..00000000 --- a/sinch/domains/conversation/models/message/requests.py +++ /dev/null @@ -1,43 +0,0 @@ -from dataclasses import dataclass -from typing import Optional -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class ListConversationMessagesRequest(SinchRequestBaseModel): - conversation_id: Optional[str] - contact_id: Optional[str] - app_id: Optional[str] - page_size: Optional[int] - page_token: Optional[str] - view: Optional[str] - messages_source: Optional[str] - only_recipient_originated: Optional[bool] - - -@dataclass -class GetConversationMessageRequest(SinchRequestBaseModel): - message_id: str - messages_source: str - - -@dataclass -class DeleteConversationMessageRequest(SinchRequestBaseModel): - message_id: str - messages_source: str - - -@dataclass -class SendConversationMessageRequest(SinchRequestBaseModel): - app_id: str - recipient: dict - message: dict - callback_url: str - processing_strategy: Optional[str] - channel_priority_order: list - channel_properties: dict - message_metadata: str - conversation_metadata: dict - queue: str - ttl: str - processing_strategy: str diff --git a/sinch/domains/conversation/models/message/responses.py b/sinch/domains/conversation/models/message/responses.py deleted file mode 100644 index ca892152..00000000 --- a/sinch/domains/conversation/models/message/responses.py +++ /dev/null @@ -1,26 +0,0 @@ -from dataclasses import dataclass -from typing import List -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.conversation.models import SinchConversationMessage - - -@dataclass -class SendConversationMessageResponse(SinchBaseModel): - accepted_time: str - message_id: str - - -@dataclass -class ListConversationMessagesResponse(SinchBaseModel): - messages: List[SinchConversationMessage] - next_page_token: str - - -@dataclass -class GetConversationMessageResponse(SinchConversationMessage): - pass - - -@dataclass -class DeleteConversationMessageResponse(SinchBaseModel): - pass diff --git a/sinch/domains/conversation/models/opt_in_opt_out/requests.py b/sinch/domains/conversation/models/opt_in_opt_out/requests.py deleted file mode 100644 index 66d9fb90..00000000 --- a/sinch/domains/conversation/models/opt_in_opt_out/requests.py +++ /dev/null @@ -1,20 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class RegisterConversationOptInRequest(SinchRequestBaseModel): - request_id: str - app_id: str - channels: list - recipient: dict - processing_strategy: str - - -@dataclass -class RegisterConversationOptOutRequest(SinchRequestBaseModel): - request_id: str - app_id: str - channels: list - recipient: dict - processing_strategy: str diff --git a/sinch/domains/conversation/models/opt_in_opt_out/responses.py b/sinch/domains/conversation/models/opt_in_opt_out/responses.py deleted file mode 100644 index 386dbaf6..00000000 --- a/sinch/domains/conversation/models/opt_in_opt_out/responses.py +++ /dev/null @@ -1,14 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class RegisterConversationOptInResponse(SinchBaseModel): - request_id: str - opt_in: dict - - -@dataclass -class RegisterConversationOptOutResponse(SinchBaseModel): - request_id: str - opt_out: dict diff --git a/sinch/domains/conversation/models/templates/__init__.py b/sinch/domains/conversation/models/templates/__init__.py deleted file mode 100644 index 3b8872e1..00000000 --- a/sinch/domains/conversation/models/templates/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class ConversationTemplate(SinchBaseModel): - id: str - description: str - default_translation: str - create_time: str - translations: list - update_time: str - channel: str diff --git a/sinch/domains/conversation/models/templates/requests.py b/sinch/domains/conversation/models/templates/requests.py deleted file mode 100644 index 67e29fbe..00000000 --- a/sinch/domains/conversation/models/templates/requests.py +++ /dev/null @@ -1,29 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class CreateConversationTemplateRequest(SinchRequestBaseModel): - channel: str - create_time: str - description: str - id: str - translations: list - default_translation: str - update_time: str - - -@dataclass -class GetConversationTemplateRequest(SinchRequestBaseModel): - template_id: str - - -@dataclass -class DeleteConversationTemplateRequest(SinchRequestBaseModel): - template_id: str - - -@dataclass -class UpdateConversationTemplateRequest(CreateConversationTemplateRequest): - update_mask: str - template_id: str diff --git a/sinch/domains/conversation/models/templates/responses.py b/sinch/domains/conversation/models/templates/responses.py deleted file mode 100644 index 92dba38e..00000000 --- a/sinch/domains/conversation/models/templates/responses.py +++ /dev/null @@ -1,30 +0,0 @@ -from dataclasses import dataclass -from typing import List - -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.conversation.models.templates import ConversationTemplate - - -@dataclass -class CreateConversationTemplateResponse(ConversationTemplate): - pass - - -@dataclass -class ListConversationTemplatesResponse(SinchBaseModel): - templates: List[ConversationTemplate] - - -@dataclass -class GetConversationTemplateResponse(ConversationTemplate): - pass - - -@dataclass -class DeleteConversationTemplateResponse(SinchBaseModel): - pass - - -@dataclass -class UpdateConversationTemplateResponse(ConversationTemplate): - pass diff --git a/sinch/domains/conversation/models/transcoding/requests.py b/sinch/domains/conversation/models/transcoding/requests.py deleted file mode 100644 index 06e54036..00000000 --- a/sinch/domains/conversation/models/transcoding/requests.py +++ /dev/null @@ -1,11 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class TranscodeConversationMessageRequest(SinchRequestBaseModel): - app_id: str - app_message: dict - channels: list - from_: str - to: str diff --git a/sinch/domains/conversation/models/transcoding/responses.py b/sinch/domains/conversation/models/transcoding/responses.py deleted file mode 100644 index 230bd5bd..00000000 --- a/sinch/domains/conversation/models/transcoding/responses.py +++ /dev/null @@ -1,7 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class TranscodeConversationMessageResponse(SinchBaseModel): - transcoded_message: dict diff --git a/sinch/domains/conversation/models/capability/__init__.py b/sinch/domains/conversation/models/v1/__init__.py similarity index 100% rename from sinch/domains/conversation/models/capability/__init__.py rename to sinch/domains/conversation/models/v1/__init__.py diff --git a/sinch/domains/conversation/models/contact/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/__init__.py similarity index 100% rename from sinch/domains/conversation/models/contact/__init__.py rename to sinch/domains/conversation/models/v1/messages/categories/__init__.py diff --git a/sinch/domains/conversation/models/event/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/app/__init__.py similarity index 100% rename from sinch/domains/conversation/models/event/__init__.py rename to sinch/domains/conversation/models/v1/messages/categories/app/__init__.py diff --git a/sinch/domains/conversation/models/v1/messages/categories/app/app_message.py b/sinch/domains/conversation/models/v1/messages/categories/app/app_message.py new file mode 100644 index 00000000..4e0cc5ed --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/app/app_message.py @@ -0,0 +1,70 @@ +from typing import Optional +from sinch.domains.conversation.models.v1.messages.categories.card.card_message import ( + CardMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.carousel.carousel_message import ( + CarouselMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.choice.choice_message import ( + ChoiceMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.contactinfo.contact_info_message import ( + ContactInfoMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.list.list_message import ( + ListMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.location.location_message import ( + LocationMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.media.media_properties import ( + MediaProperties, +) +from sinch.domains.conversation.models.v1.messages.categories.template.template_message import ( + TemplateMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.text import ( + TextMessage, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) +from sinch.domains.conversation.models.v1.messages.shared.app_message_common_props import ( + AppMessageCommonProps, +) + + +class CardAppMessage(AppMessageCommonProps, BaseModelConfiguration): + card_message: Optional[CardMessage] = None + + +class CarouselAppMessage(AppMessageCommonProps, BaseModelConfiguration): + carousel_message: Optional[CarouselMessage] = None + + +class ChoiceAppMessage(AppMessageCommonProps, BaseModelConfiguration): + choice_message: Optional[ChoiceMessage] = None + + +class LocationAppMessage(AppMessageCommonProps, BaseModelConfiguration): + location_message: Optional[LocationMessage] = None + + +class MediaAppMessage(AppMessageCommonProps, BaseModelConfiguration): + media_message: Optional[MediaProperties] = None + + +class TemplateAppMessage(AppMessageCommonProps, BaseModelConfiguration): + template_message: Optional[TemplateMessage] = None + + +class TextAppMessage(AppMessageCommonProps, BaseModelConfiguration): + text_message: Optional[TextMessage] = None + + +class ListAppMessage(AppMessageCommonProps, BaseModelConfiguration): + list_message: Optional[ListMessage] = None + + +class ContactInfoAppMessage(AppMessageCommonProps, BaseModelConfiguration): + contact_info_message: Optional[ContactInfoMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/calendar/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/calendar/__init__.py new file mode 100644 index 00000000..0503b780 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/calendar/__init__.py @@ -0,0 +1,14 @@ +__all__ = [ + "CalendarMessage", +] + + +def __getattr__(name: str): + """Lazy import to avoid circular dependencies.""" + if name == "CalendarMessage": + from sinch.domains.conversation.models.v1.messages.categories.calendar.calendar_message import ( + CalendarMessage, + ) + + return CalendarMessage + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/conversation/models/v1/messages/categories/calendar/calendar_message.py b/sinch/domains/conversation/models/v1/messages/categories/calendar/calendar_message.py new file mode 100644 index 00000000..36e119de --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/calendar/calendar_message.py @@ -0,0 +1,29 @@ +from typing import Optional +from datetime import datetime +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class CalendarMessage(BaseModelConfiguration): + title: StrictStr = Field( + ..., + description="The title is shown close to the button that leads to open a user calendar.", + ) + event_start: datetime = Field( + ..., description="The timestamp defines start of a calendar event." + ) + event_end: datetime = Field( + ..., description="The timestamp defines end of a calendar event." + ) + event_title: StrictStr = Field( + ..., description="Title of a calendar event." + ) + event_description: Optional[StrictStr] = Field( + default=None, description="Description of a calendar event." + ) + fallback_url: StrictStr = Field( + ..., + description="The URL that is opened when the user cannot open a calendar event directly or channel does not have support for this type.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/call/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/call/__init__.py new file mode 100644 index 00000000..77b7fe4e --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/call/__init__.py @@ -0,0 +1,14 @@ +__all__ = [ + "CallMessage", +] + + +def __getattr__(name: str): + """Lazy import to avoid circular dependencies.""" + if name == "CallMessage": + from sinch.domains.conversation.models.v1.messages.categories.call.call_message import ( + CallMessage, + ) + + return CallMessage + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/conversation/models/v1/messages/categories/call/call_message.py b/sinch/domains/conversation/models/v1/messages/categories/call/call_message.py new file mode 100644 index 00000000..0969866f --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/call/call_message.py @@ -0,0 +1,14 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class CallMessage(BaseModelConfiguration): + phone_number: StrictStr = Field( + default=..., description="Phone number in E.164 with leading +." + ) + title: StrictStr = Field( + default=..., + description="Title shown close to the phone number. The title is clickable in some cases.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/card/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/card/__init__.py new file mode 100644 index 00000000..ec9792a5 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/card/__init__.py @@ -0,0 +1,21 @@ +__all__ = [ + "CardMessage", + "CardMessageField", +] + + +def __getattr__(name: str): + """Lazy import to avoid circular dependencies.""" + if name == "CardMessage": + from sinch.domains.conversation.models.v1.messages.categories.card.card_message import ( + CardMessage, + ) + + return CardMessage + if name == "CardMessageField": + from sinch.domains.conversation.models.v1.messages.categories.card.card_message_field import ( + CardMessageField, + ) + + return CardMessageField + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/conversation/models/v1/messages/categories/card/card_message.py b/sinch/domains/conversation/models/v1/messages/categories/card/card_message.py new file mode 100644 index 00000000..c8de5a25 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/card/card_message.py @@ -0,0 +1,36 @@ +from typing import Optional +from pydantic import Field, StrictStr, conlist +from sinch.domains.conversation.models.v1.messages.types.card_height_type import ( + CardHeightType, +) +from sinch.domains.conversation.models.v1.messages.categories.media import ( + MediaProperties, +) +from sinch.domains.conversation.models.v1.messages.categories.choice.choice_option import ( + ChoiceOption, +) +from sinch.domains.conversation.models.v1.messages.categories.card.message_properties import ( + MessageProperties, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class CardMessage(BaseModelConfiguration): + choices: Optional[conlist(ChoiceOption)] = Field( + default=None, + description="You may include choices in your Card Message. The number of choices is limited to 10.", + ) + description: Optional[StrictStr] = Field( + default=None, + description="This is an optional description field that is displayed below the title on the card.", + ) + height: Optional[CardHeightType] = None + title: Optional[StrictStr] = Field( + default=None, description="The title of the card message." + ) + media_message: Optional[MediaProperties] = Field( + default=None, description="A message containing a media component." + ) + message_properties: Optional[MessageProperties] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/card/card_message_field.py b/sinch/domains/conversation/models/v1/messages/categories/card/card_message_field.py new file mode 100644 index 00000000..0e80ad30 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/card/card_message_field.py @@ -0,0 +1,11 @@ +from typing import Optional +from sinch.domains.conversation.models.v1.messages.categories.card.card_message import ( + CardMessage, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class CardMessageField(BaseModelConfiguration): + card_message: Optional[CardMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/card/message_properties.py b/sinch/domains/conversation/models/v1/messages/categories/card/message_properties.py new file mode 100644 index 00000000..78912593 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/card/message_properties.py @@ -0,0 +1,15 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class MessageProperties(BaseModelConfiguration): + whatsapp_header: Optional[StrictStr] = Field( + default=None, + description=( + "Optional. Sets the header text for a WhatsApp reply button message when there is no media. " + "Ignored for other channels or when not transcoded to native WhatsApp reply buttons." + ), + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/carousel/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/carousel/__init__.py new file mode 100644 index 00000000..a33819a0 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/carousel/__init__.py @@ -0,0 +1,21 @@ +__all__ = [ + "CarouselMessage", + "CarouselMessageField", +] + + +def __getattr__(name: str): + """Lazy import to avoid circular dependencies.""" + if name == "CarouselMessage": + from sinch.domains.conversation.models.v1.messages.categories.carousel.carousel_message import ( + CarouselMessage, + ) + + return CarouselMessage + if name == "CarouselMessageField": + from sinch.domains.conversation.models.v1.messages.categories.carousel.carousel_message_field import ( + CarouselMessageField, + ) + + return CarouselMessageField + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/conversation/models/v1/messages/categories/carousel/carousel_message.py b/sinch/domains/conversation/models/v1/messages/categories/carousel/carousel_message.py new file mode 100644 index 00000000..7026560d --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/carousel/carousel_message.py @@ -0,0 +1,21 @@ +from typing import Optional +from pydantic import Field, conlist +from sinch.domains.conversation.models.v1.messages.categories.card.card_message import ( + CardMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.choice.choice_option import ( + ChoiceOption, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class CarouselMessage(BaseModelConfiguration): + cards: conlist(CardMessage) = Field( + default=..., description="A list of up to 10 cards." + ) + choices: Optional[conlist(ChoiceOption)] = Field( + default=None, + description="Optional. Outer choices on the carousel level. The number of outer choices is limited to 3.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/carousel/carousel_message_field.py b/sinch/domains/conversation/models/v1/messages/categories/carousel/carousel_message_field.py new file mode 100644 index 00000000..4f671d1d --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/carousel/carousel_message_field.py @@ -0,0 +1,11 @@ +from typing import Optional +from sinch.domains.conversation.models.v1.messages.categories.carousel.carousel_message import ( + CarouselMessage, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class CarouselMessageField(BaseModelConfiguration): + carousel_message: Optional[CarouselMessage] = None diff --git a/sinch/domains/conversation/models/message/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/__init__.py similarity index 100% rename from sinch/domains/conversation/models/message/__init__.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/__init__.py diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_contact_message_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_contact_message_message.py new file mode 100644 index 00000000..3131c8df --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_contact_message_message.py @@ -0,0 +1,17 @@ +from typing import Literal +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.nfmreply.whatsapp_interactive_nfm_reply_message import ( + WhatsAppInteractiveNfmReplyMessage, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class ChannelSpecificContactMessageMessage(BaseModelConfiguration): + message_type: Literal["nfm_reply"] = Field( + ..., description="The message type." + ) + message: WhatsAppInteractiveNfmReplyMessage = Field( + ..., description="The message content." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_message.py new file mode 100644 index 00000000..fd939112 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_message.py @@ -0,0 +1,17 @@ +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.types.channel_specific_message_type import ( + ChannelSpecificMessageType, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.channel_specific_message_content import ( + ChannelSpecificMessageContent, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class ChannelSpecificMessage(BaseModelConfiguration): + message_type: ChannelSpecificMessageType = Field( + ..., description="The type of the channel specific message." + ) + message: ChannelSpecificMessageContent = Field(...) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_message_content.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_message_content.py new file mode 100644 index 00000000..06aa2e1c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_message_content.py @@ -0,0 +1,29 @@ +from typing import Union + +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_carousel_commerce_channel_specific_message import ( + KakaoTalkCarouselCommerceChannelSpecificMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_commerce_channel_specific_message import ( + KakaoTalkCommerceChannelSpecificMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.line.line_notification_message_template_message import ( + LineNotificationMessageTemplateMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.flow_channel_specific_message import ( + FlowChannelSpecificMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_order_details_channel_specific_message import ( + PaymentOrderDetailsChannelSpecificMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_order_status_channel_specific_message import ( + PaymentOrderStatusChannelSpecificMessage, +) + +ChannelSpecificMessageContent = Union[ + FlowChannelSpecificMessage, + PaymentOrderDetailsChannelSpecificMessage, + PaymentOrderStatusChannelSpecificMessage, + KakaoTalkCommerceChannelSpecificMessage, + KakaoTalkCarouselCommerceChannelSpecificMessage, + LineNotificationMessageTemplateMessage, +] diff --git a/sinch/domains/conversation/models/opt_in_opt_out/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/__init__.py similarity index 100% rename from sinch/domains/conversation/models/opt_in_opt_out/__init__.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/__init__.py diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/__init__.py new file mode 100644 index 00000000..6fe3454b --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.buttons.kakaotalk_button import ( + KakaoTalkButton, +) + +__all__ = [ + "KakaoTalkButton", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/kakaotalk_app_link_button.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/kakaotalk_app_link_button.py new file mode 100644 index 00000000..675c3dfc --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/kakaotalk_app_link_button.py @@ -0,0 +1,17 @@ +from typing import Literal +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.buttons import ( + KakaoTalkButton, +) + + +class KakaoTalkAppLinkButton(KakaoTalkButton): + type: Literal["AL"] = Field("AL", description="Button type") + scheme_ios: StrictStr = Field( + ..., + description="App link opened on an iOS device (e.g. `tel://PHONE_NUMBER`)", + ) + scheme_android: StrictStr = Field( + ..., + description="App link opened on an Android device (e.g. `tel://PHONE_NUMBER`)", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/kakaotalk_bot_keyword_button.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/kakaotalk_bot_keyword_button.py new file mode 100644 index 00000000..1cac0dec --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/kakaotalk_bot_keyword_button.py @@ -0,0 +1,9 @@ +from typing import Literal +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.buttons import ( + KakaoTalkButton, +) + + +class KakaoTalkBotKeywordButton(KakaoTalkButton): + type: Literal["BK"] = Field("BK", description="Button type") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/kakaotalk_button.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/kakaotalk_button.py new file mode 100644 index 00000000..4e898ef4 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/kakaotalk_button.py @@ -0,0 +1,8 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class KakaoTalkButton(BaseModelConfiguration): + name: StrictStr = Field(..., description="Text displayed on the button") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/kakaotalk_web_link_button.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/kakaotalk_web_link_button.py new file mode 100644 index 00000000..e49e8e85 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/kakaotalk_web_link_button.py @@ -0,0 +1,15 @@ +from typing import Literal, Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.buttons import ( + KakaoTalkButton, +) + + +class KakaoTalkWebLinkButton(KakaoTalkButton): + type: Literal["WL"] = Field("WL", description="Button type") + link_mo: StrictStr = Field( + ..., description="URL opened on a mobile device" + ) + link_pc: Optional[StrictStr] = Field( + default=None, description="URL opened on a desktop device" + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/__init__.py new file mode 100644 index 00000000..84ddbabd --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/__init__.py @@ -0,0 +1,42 @@ +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_channel_specific_message import ( + KakaoTalkChannelSpecificMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_commerce_image import ( + KakaoTalkCommerceImage, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_carousel_head import ( + KakaoTalkCarouselHead, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_carousel_tail import ( + KakaoTalkCarouselTail, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_regular_price_commerce import ( + KakaoTalkRegularPriceCommerce, +) + + +def __getattr__(name: str): + if name == "KakaoTalkCommerceMessage": + from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_commerce_message import ( + KakaoTalkCommerceMessage, + ) + + return KakaoTalkCommerceMessage + if name == "KakaoTalkCarousel": + from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_carousel import ( + KakaoTalkCarousel, + ) + + return KakaoTalkCarousel + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +__all__ = [ + "KakaoTalkChannelSpecificMessage", + "KakaoTalkCommerceImage", + "KakaoTalkCarouselHead", + "KakaoTalkCarouselTail", + "KakaoTalkRegularPriceCommerce", + "KakaoTalkCommerceMessage", + "KakaoTalkCarousel", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel.py new file mode 100644 index 00000000..2b6fd81c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel.py @@ -0,0 +1,22 @@ +from typing import Optional +from pydantic import Field, conlist +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce import ( + KakaoTalkCarouselHead, + KakaoTalkCarouselTail, + KakaoTalkCommerceMessage, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class KakaoTalkCarousel(BaseModelConfiguration): + head: Optional[KakaoTalkCarouselHead] = Field( + default=None, description="Carousel introduction" + ) + list: conlist(KakaoTalkCommerceMessage) = Field( + ..., description="List of carousel cards" + ) + tail: Optional[KakaoTalkCarouselTail] = Field( + default=None, description="More button" + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel_commerce_channel_specific_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel_commerce_channel_specific_message.py new file mode 100644 index 00000000..9a2d45f7 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel_commerce_channel_specific_message.py @@ -0,0 +1,11 @@ +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce import ( + KakaoTalkCarousel, + KakaoTalkChannelSpecificMessage, +) + + +class KakaoTalkCarouselCommerceChannelSpecificMessage( + KakaoTalkChannelSpecificMessage +): + carousel: KakaoTalkCarousel = Field(..., description="Carousel content") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel_head.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel_head.py new file mode 100644 index 00000000..7870c02b --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel_head.py @@ -0,0 +1,31 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class KakaoTalkCarouselHead(BaseModelConfiguration): + header: StrictStr = Field( + ..., description="Carousel introduction title", max_length=20 + ) + content: StrictStr = Field( + ..., description="Carousel introduction description", max_length=50 + ) + image_url: StrictStr = Field( + ..., description="URL to the image displayed in the introduction" + ) + link_mo: Optional[StrictStr] = Field( + default=None, description="URL opened on a mobile device" + ) + link_pc: Optional[StrictStr] = Field( + default=None, description="URL opened on a desktop device" + ) + scheme_ios: Optional[StrictStr] = Field( + default=None, + description="App link opened on an iOS device (e.g. `tel://PHONE_NUMBER`)", + ) + scheme_android: Optional[StrictStr] = Field( + default=None, + description="App link opened on an Android device (e.g. `tel://PHONE_NUMBER`)", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel_tail.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel_tail.py new file mode 100644 index 00000000..5a02ecda --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel_tail.py @@ -0,0 +1,22 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class KakaoTalkCarouselTail(BaseModelConfiguration): + link_mo: StrictStr = Field( + ..., description="URL opened on a mobile device" + ) + link_pc: Optional[StrictStr] = Field( + default=None, description="URL opened on a desktop device" + ) + scheme_ios: Optional[StrictStr] = Field( + default=None, + description="App link opened on an iOS device (e.g. `tel://PHONE_NUMBER`)", + ) + scheme_android: Optional[StrictStr] = Field( + default=None, + description="App link opened on an Android device (e.g. `tel://PHONE_NUMBER`)", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_channel_specific_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_channel_specific_message.py new file mode 100644 index 00000000..674e7fdf --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_channel_specific_message.py @@ -0,0 +1,16 @@ +from typing import Optional +from pydantic import Field, StrictBool +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class KakaoTalkChannelSpecificMessage(BaseModelConfiguration): + push_alarm: Optional[StrictBool] = Field( + default=True, + description="Set to `true` if a push alarm should be sent to a device.", + ) + adult: Optional[StrictBool] = Field( + default=False, + description="Set to `true` if a message contains adult content. Set to `false` by default.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_commerce_channel_specific_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_commerce_channel_specific_message.py new file mode 100644 index 00000000..6b8a0eca --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_commerce_channel_specific_message.py @@ -0,0 +1,27 @@ +from typing import Optional +from pydantic import Field, StrictStr, conlist +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce import ( + KakaoTalkChannelSpecificMessage, + KakaoTalkCommerceImage, +) +from sinch.domains.conversation.models.v1.messages.response.types.kakaotalk_button import ( + KakaoTalkButton, +) +from sinch.domains.conversation.models.v1.messages.response.types.kakaotalk_commerce import ( + KakaoTalkCommerce, +) +from sinch.domains.conversation.models.v1.messages.response.types.kakaotalk_coupon import ( + KakaoTalkCoupon, +) + + +class KakaoTalkCommerceChannelSpecificMessage(KakaoTalkChannelSpecificMessage): + buttons: conlist(KakaoTalkButton) = Field(..., description="Buttons list") + additional_content: Optional[StrictStr] = Field( + default=None, description="Additional information" + ) + image: KakaoTalkCommerceImage = Field(..., description="Product image") + commerce: KakaoTalkCommerce = Field(..., description="Product information") + coupon: Optional[KakaoTalkCoupon] = Field( + default=None, description="Discount coupon" + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_commerce_image.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_commerce_image.py new file mode 100644 index 00000000..ca762987 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_commerce_image.py @@ -0,0 +1,12 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class KakaoTalkCommerceImage(BaseModelConfiguration): + image_url: StrictStr = Field(..., description="URL to the product image") + image_link: Optional[StrictStr] = Field( + default=None, description="URL opened when a user clicks on the image" + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_commerce_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_commerce_message.py new file mode 100644 index 00000000..fe386706 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_commerce_message.py @@ -0,0 +1,29 @@ +from typing import Optional +from pydantic import Field, StrictStr, conlist +from sinch.domains.conversation.models.v1.messages.response.types.kakaotalk_button import ( + KakaoTalkButton, +) +from sinch.domains.conversation.models.v1.messages.response.types.kakaotalk_commerce import ( + KakaoTalkCommerce, +) +from sinch.domains.conversation.models.v1.messages.response.types.kakaotalk_coupon import ( + KakaoTalkCoupon, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce import ( + KakaoTalkCommerceImage, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class KakaoTalkCommerceMessage(BaseModelConfiguration): + buttons: conlist(KakaoTalkButton) = Field(..., description="Buttons list") + additional_content: Optional[StrictStr] = Field( + default=None, description="Additional information", max_length=34 + ) + image: KakaoTalkCommerceImage = Field(..., description="Product image") + commerce: KakaoTalkCommerce = Field(..., description="Product information") + coupon: Optional[KakaoTalkCoupon] = Field( + default=None, description="Discount coupon" + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_discount_fixed_commerce.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_discount_fixed_commerce.py new file mode 100644 index 00000000..38e9cdf4 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_discount_fixed_commerce.py @@ -0,0 +1,15 @@ +from typing import Literal +from pydantic import Field, StrictInt +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce import ( + KakaoTalkRegularPriceCommerce, +) + + +class KakaoTalkDiscountFixedCommerce(KakaoTalkRegularPriceCommerce): + type: Literal["FIXED_DISCOUNT_COMMERCE"] = Field( + "FIXED_DISCOUNT_COMMERCE", description="Commerce with fixed discount" + ) + discount_price: StrictInt = Field( + ..., description="Discounted price of the product" + ) + discount_fixed: StrictInt = Field(..., description="Fixed discount") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_discount_rate_commerce.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_discount_rate_commerce.py new file mode 100644 index 00000000..0975dc0a --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_discount_rate_commerce.py @@ -0,0 +1,16 @@ +from typing import Literal +from pydantic import Field, StrictInt +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce import ( + KakaoTalkRegularPriceCommerce, +) + + +class KakaoTalkDiscountRateCommerce(KakaoTalkRegularPriceCommerce): + type: Literal["PERCENTAGE_DISCOUNT_COMMERCE"] = Field( + "PERCENTAGE_DISCOUNT_COMMERCE", + description="Commerce with percentage discount", + ) + discount_price: StrictInt = Field( + ..., description="Discounted price of the product" + ) + discount_rate: StrictInt = Field(..., description="Discount rate (%)") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_regular_price_commerce.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_regular_price_commerce.py new file mode 100644 index 00000000..4fd71fe3 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_regular_price_commerce.py @@ -0,0 +1,15 @@ +from typing import Literal +from pydantic import Field, StrictStr, StrictInt +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class KakaoTalkRegularPriceCommerce(BaseModelConfiguration): + type: Literal["REGULAR_PRICE_COMMERCE"] = Field( + "REGULAR_PRICE_COMMERCE", description="Commerce with regular price" + ) + title: StrictStr = Field(..., description="Product title") + regular_price: StrictInt = Field( + ..., description="Regular price of the product" + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/__init__.py new file mode 100644 index 00000000..73992ad2 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.coupons.kakaotalk_coupon import ( + KakaoTalkCoupon, +) + +__all__ = [ + "KakaoTalkCoupon", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_coupon.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_coupon.py new file mode 100644 index 00000000..e9db07ae --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_coupon.py @@ -0,0 +1,25 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class KakaoTalkCoupon(BaseModelConfiguration): + description: Optional[StrictStr] = Field( + default=None, description="Coupon description" + ) + link_mo: Optional[StrictStr] = Field( + default=None, description="Coupon URL opened on a mobile device" + ) + link_pc: Optional[StrictStr] = Field( + default=None, description="Coupon URL opened on a desktop device" + ) + scheme_android: Optional[StrictStr] = Field( + default=None, + description="Channel coupon URL (format: `alimtalk=coupon://...`)", + ) + scheme_ios: Optional[StrictStr] = Field( + default=None, + description="Channel coupon URL (format: `alimtalk=coupon://...`)", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_discount_rate_coupon.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_discount_rate_coupon.py new file mode 100644 index 00000000..687a45bb --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_discount_rate_coupon.py @@ -0,0 +1,12 @@ +from typing import Literal +from pydantic import Field, StrictInt +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.coupons import ( + KakaoTalkCoupon, +) + + +class KakaoTalkDiscountRateCoupon(KakaoTalkCoupon): + type: Literal["PERCENTAGE_DISCOUNT_COUPON"] = Field( + "PERCENTAGE_DISCOUNT_COUPON", description="Percentage discount coupon" + ) + discount_rate: StrictInt = Field(..., description="Discount rate (%)") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_fixed_discount_coupon.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_fixed_discount_coupon.py new file mode 100644 index 00000000..05f9c28f --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_fixed_discount_coupon.py @@ -0,0 +1,12 @@ +from typing import Literal +from pydantic import Field, StrictInt +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.coupons import ( + KakaoTalkCoupon, +) + + +class KakaoTalkFixedDiscountCoupon(KakaoTalkCoupon): + type: Literal["FIXED_DISCOUNT_COUPON"] = Field( + "FIXED_DISCOUNT_COUPON", description="Fixed discount coupon" + ) + discount_fixed: StrictInt = Field(..., description="Fixed discount") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_free_coupon.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_free_coupon.py new file mode 100644 index 00000000..1feefca6 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_free_coupon.py @@ -0,0 +1,12 @@ +from typing import Literal +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.coupons import ( + KakaoTalkCoupon, +) + + +class KakaoTalkFreeCoupon(KakaoTalkCoupon): + type: Literal["FREE_COUPON"] = Field( + "FREE_COUPON", description="Free coupon" + ) + title: StrictStr = Field(..., description="Coupon title") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_shipping_discount_coupon.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_shipping_discount_coupon.py new file mode 100644 index 00000000..517e0bac --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_shipping_discount_coupon.py @@ -0,0 +1,11 @@ +from typing import Literal +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.coupons import ( + KakaoTalkCoupon, +) + + +class KakaoTalkShippingDiscountCoupon(KakaoTalkCoupon): + type: Literal["SHIPPING_DISCOUNT_COUPON"] = Field( + "SHIPPING_DISCOUNT_COUPON", description="Shipping discount coupon" + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_up_coupon.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_up_coupon.py new file mode 100644 index 00000000..62d87fad --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_up_coupon.py @@ -0,0 +1,10 @@ +from typing import Literal +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.coupons import ( + KakaoTalkCoupon, +) + + +class KakaoTalkUpCoupon(KakaoTalkCoupon): + type: Literal["UP_COUPON"] = Field("UP_COUPON", description="UP coupon") + title: StrictStr = Field(..., description="Coupon title") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/__init__.py new file mode 100644 index 00000000..ad72d09b --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/__init__.py @@ -0,0 +1,23 @@ +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.line.line_notification_message_template_emphasized_item import ( + LineNotificationMessageTemplateEmphasizedItem, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.line.line_notification_message_template_item import ( + LineNotificationMessageTemplateItem, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.line.buttons import ( + LineNotificationMessageTemplateButton, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.line.line_notification_message_template_body import ( + LineNotificationMessageTemplateBody, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.line.line_notification_message_template_message import ( + LineNotificationMessageTemplateMessage, +) + +__all__ = [ + "LineNotificationMessageTemplateEmphasizedItem", + "LineNotificationMessageTemplateItem", + "LineNotificationMessageTemplateButton", + "LineNotificationMessageTemplateBody", + "LineNotificationMessageTemplateMessage", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/buttons/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/buttons/__init__.py new file mode 100644 index 00000000..93ca8ffe --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/buttons/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.line.buttons.line_notification_message_template_button import ( + LineNotificationMessageTemplateButton, +) + +__all__ = [ + "LineNotificationMessageTemplateButton", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/buttons/line_notification_message_template_button.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/buttons/line_notification_message_template_button.py new file mode 100644 index 00000000..7ef0fd1b --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/buttons/line_notification_message_template_button.py @@ -0,0 +1,12 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class LineNotificationMessageTemplateButton(BaseModelConfiguration): + button_key: StrictStr = Field( + ..., + description="Button key. See LINE documentation for available keys.", + ) + url: StrictStr = Field(..., description="Button URL.") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_body.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_body.py new file mode 100644 index 00000000..d71a9bc6 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_body.py @@ -0,0 +1,26 @@ +from typing import Optional +from pydantic import Field, conlist +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.line.line_notification_message_template_emphasized_item import ( + LineNotificationMessageTemplateEmphasizedItem, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.line.line_notification_message_template_item import ( + LineNotificationMessageTemplateItem, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.line.buttons import ( + LineNotificationMessageTemplateButton, +) + + +class LineNotificationMessageTemplateBody(BaseModelConfiguration): + emphasized_item: Optional[ + LineNotificationMessageTemplateEmphasizedItem + ] = Field(default=None, description="Template emphasized item.") + items: Optional[conlist(LineNotificationMessageTemplateItem)] = Field( + default=None, description="List of template items." + ) + buttons: Optional[conlist(LineNotificationMessageTemplateButton)] = Field( + default=None, description="List of template buttons." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_emphasized_item.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_emphasized_item.py new file mode 100644 index 00000000..66adc2f3 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_emphasized_item.py @@ -0,0 +1,12 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class LineNotificationMessageTemplateEmphasizedItem(BaseModelConfiguration): + item_key: StrictStr = Field( + ..., + description="Item key. See LINE documentation for available keys.", + ) + content: StrictStr = Field(..., description="Item value.") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_item.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_item.py new file mode 100644 index 00000000..a6a10e42 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_item.py @@ -0,0 +1,12 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class LineNotificationMessageTemplateItem(BaseModelConfiguration): + item_key: StrictStr = Field( + ..., + description="Item key. See LINE documentation for available keys.", + ) + content: StrictStr = Field(..., description="Item value.") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_message.py new file mode 100644 index 00000000..4a02ed0a --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_message.py @@ -0,0 +1,18 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.line.line_notification_message_template_body import ( + LineNotificationMessageTemplateBody, +) + + +class LineNotificationMessageTemplateMessage(BaseModelConfiguration): + template_key: StrictStr = Field( + ..., + description="Template key. See LINE documentation for available keys.", + ) + body: Optional[LineNotificationMessageTemplateBody] = Field( + default=None, description="Template body." + ) diff --git a/sinch/domains/conversation/models/transcoding/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/__init__.py similarity index 100% rename from sinch/domains/conversation/models/transcoding/__init__.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/__init__.py diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/buttons/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/buttons/__init__.py new file mode 100644 index 00000000..a7c8fee5 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/buttons/__init__.py @@ -0,0 +1,15 @@ +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.buttons.whatsapp_payment_settings_boleto_button import ( + WhatsAppPaymentSettingsBoletoButton, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.buttons.whatsapp_payment_settings_payment_link_button import ( + WhatsAppPaymentSettingsPaymentLinkButton, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.buttons.whatsapp_payment_settings_pix_button import ( + WhatsAppPaymentSettingsPixButton, +) + +__all__ = [ + "WhatsAppPaymentSettingsBoletoButton", + "WhatsAppPaymentSettingsPaymentLinkButton", + "WhatsAppPaymentSettingsPixButton", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/buttons/whatsapp_payment_settings_boleto_button.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/buttons/whatsapp_payment_settings_boleto_button.py new file mode 100644 index 00000000..da9404ca --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/buttons/whatsapp_payment_settings_boleto_button.py @@ -0,0 +1,16 @@ +from typing import Literal +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class WhatsAppPaymentSettingsBoletoButton(BaseModelConfiguration): + type: Literal["boleto"] = Field( + ..., + description="The Boleto button identifier", + ) + digitable_line: StrictStr = Field( + ..., + description="The Boleto digitable line which will be copied to the clipboard when the user taps the Boleto button.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/buttons/whatsapp_payment_settings_payment_link_button.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/buttons/whatsapp_payment_settings_payment_link_button.py new file mode 100644 index 00000000..c07ea17d --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/buttons/whatsapp_payment_settings_payment_link_button.py @@ -0,0 +1,15 @@ +from typing import Literal +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class WhatsAppPaymentSettingsPaymentLinkButton(BaseModelConfiguration): + type: Literal["payment_link"] = Field( + ..., + description="The payment link button identifier", + ) + uri: StrictStr = Field( + ..., description="The payment link to be used by the buyer to pay." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/buttons/whatsapp_payment_settings_pix_button.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/buttons/whatsapp_payment_settings_pix_button.py new file mode 100644 index 00000000..9775ce87 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/buttons/whatsapp_payment_settings_pix_button.py @@ -0,0 +1,21 @@ +from typing import Literal +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.types.pix_key_type import ( + PixKeyType, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class WhatsAppPaymentSettingsPixButton(BaseModelConfiguration): + type: Literal["pix_dynamic_code"] = Field( + ..., + description="The dynamic Pix code button identifier", + ) + code: StrictStr = Field( + ..., description="The dynamic Pix code to be used by the buyer to pay." + ) + merchant_name: StrictStr = Field(..., description="Account holder name.") + key: StrictStr = Field(..., description="Pix key.") + key_type: PixKeyType = Field(..., description="Pix key type.") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/__init__.py new file mode 100644 index 00000000..92044534 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/__init__.py @@ -0,0 +1,31 @@ +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.flow_action_payload import ( + FlowActionPayload, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.whatsapp_interactive_body import ( + WhatsAppInteractiveBody, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.whatsapp_interactive_footer import ( + WhatsAppInteractiveFooter, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.whatsapp_interactive_header_media import ( + WhatsAppInteractiveHeaderMedia, +) + + +def __getattr__(name: str): + if name == "FlowChannelSpecificMessage": + from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.flow_channel_specific_message import ( + FlowChannelSpecificMessage, + ) + + return FlowChannelSpecificMessage + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +__all__ = [ + "FlowActionPayload", + "WhatsAppInteractiveBody", + "WhatsAppInteractiveFooter", + "WhatsAppInteractiveHeaderMedia", + "FlowChannelSpecificMessage", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/flow_action_payload.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/flow_action_payload.py new file mode 100644 index 00000000..0c4c624c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/flow_action_payload.py @@ -0,0 +1,15 @@ +from typing import Any, Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class FlowActionPayload(BaseModelConfiguration): + screen: Optional[StrictStr] = Field( + default=None, + description="The ID of the screen displayed first. This must be an entry screen.", + ) + data: Optional[Any] = Field( + default=None, description="Data for the first screen." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/flow_channel_specific_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/flow_channel_specific_message.py new file mode 100644 index 00000000..b54a0672 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/flow_channel_specific_message.py @@ -0,0 +1,26 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.whatsapp_common_props import ( + WhatsAppCommonProps, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows import ( + FlowActionPayload, +) + + +class FlowChannelSpecificMessage(WhatsAppCommonProps): + flow_id: StrictStr = Field(..., description="ID of the Flow.") + flow_cta: StrictStr = Field( + ..., + description="Text which is displayed on the Call To Action button (20 characters maximum, emoji not supported).", + ) + flow_token: Optional[StrictStr] = Field( + default=None, description="Generated token which is an identifier." + ) + flow_mode: Optional[StrictStr] = Field( + default="published", description="The mode in which the flow is." + ) + flow_action: Optional[StrictStr] = Field( + default="navigate", description="The flow action." + ) + flow_action_payload: Optional[FlowActionPayload] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_body.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_body.py new file mode 100644 index 00000000..50ab1ff9 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_body.py @@ -0,0 +1,11 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class WhatsAppInteractiveBody(BaseModelConfiguration): + text: StrictStr = Field( + ..., + description="The content of the message (1024 characters maximum). Emojis and Markdown are supported.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_document_header.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_document_header.py new file mode 100644 index 00000000..11d8be41 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_document_header.py @@ -0,0 +1,17 @@ +from typing import Literal +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows import ( + WhatsAppInteractiveHeaderMedia, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class WhatsAppInteractiveDocumentHeader(BaseModelConfiguration): + type: Literal["document"] = Field( + ..., description="The document associated with the header." + ) + document: WhatsAppInteractiveHeaderMedia = Field( + ..., description="The document media object." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_footer.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_footer.py new file mode 100644 index 00000000..0c7f570a --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_footer.py @@ -0,0 +1,11 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class WhatsAppInteractiveFooter(BaseModelConfiguration): + text: StrictStr = Field( + ..., + description="The footer content (60 characters maximum). Emojis, Markdown and links are supported.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_header_media.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_header_media.py new file mode 100644 index 00000000..a16d83b8 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_header_media.py @@ -0,0 +1,8 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class WhatsAppInteractiveHeaderMedia(BaseModelConfiguration): + link: StrictStr = Field(..., description="URL for the media.") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_image_header.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_image_header.py new file mode 100644 index 00000000..2c9c45d1 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_image_header.py @@ -0,0 +1,17 @@ +from typing import Literal +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows import ( + WhatsAppInteractiveHeaderMedia, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class WhatsAppInteractiveImageHeader(BaseModelConfiguration): + type: Literal["image"] = Field( + ..., description="The image associated with the header." + ) + image: WhatsAppInteractiveHeaderMedia = Field( + ..., description="The image media object." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_text_header.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_text_header.py new file mode 100644 index 00000000..994dcc71 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_text_header.py @@ -0,0 +1,13 @@ +from typing import Literal +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class WhatsAppInteractiveTextHeader(BaseModelConfiguration): + type: Literal["text"] = Field(..., description="The text of the header.") + text: StrictStr = Field( + ..., + description="Text for the header. Formatting allows emojis, but not Markdown.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_video_header.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_video_header.py new file mode 100644 index 00000000..de16a9c1 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_video_header.py @@ -0,0 +1,17 @@ +from typing import Literal +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows import ( + WhatsAppInteractiveHeaderMedia, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class WhatsAppInteractiveVideoHeader(BaseModelConfiguration): + type: Literal["video"] = Field( + ..., description="The video associated with the header." + ) + video: WhatsAppInteractiveHeaderMedia = Field( + ..., description="The video media object." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/__init__.py new file mode 100644 index 00000000..cd920029 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.nfmreply.whatsapp_interactive_nfm_reply import ( + WhatsAppInteractiveNfmReply, +) + +__all__ = [ + "WhatsAppInteractiveNfmReply", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply.py new file mode 100644 index 00000000..225115d7 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply.py @@ -0,0 +1,17 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.types.whatsapp_interactive_nfm_reply_name_type import ( + WhatsAppInteractiveNfmReplyNameType, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class WhatsAppInteractiveNfmReply(BaseModelConfiguration): + name: WhatsAppInteractiveNfmReplyNameType = Field( + ..., description="The nfm reply message type." + ) + response_json: StrictStr = Field( + ..., description="The JSON specific data." + ) + body: StrictStr = Field(..., description="The message body.") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply_message.py new file mode 100644 index 00000000..9f6b72c1 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply_message.py @@ -0,0 +1,17 @@ +from typing import Literal +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.nfmreply import ( + WhatsAppInteractiveNfmReply, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class WhatsAppInteractiveNfmReplyMessage(BaseModelConfiguration): + type: Literal["nfm_reply"] = Field( + description="The interactive message type." + ) + nfm_reply: WhatsAppInteractiveNfmReply = Field( + ..., description="The nfm reply message." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/__init__.py new file mode 100644 index 00000000..bb6359c2 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/__init__.py @@ -0,0 +1,51 @@ +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.order_item import ( + OrderItem, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_order_status_order import ( + PaymentOrderStatusOrder, +) + + +def __getattr__(name: str): + if name == "PaymentOrder": + from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_order import ( + PaymentOrder, + ) + + return PaymentOrder + if name == "PaymentOrderDetailsContent": + from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_order_details_content import ( + PaymentOrderDetailsContent, + ) + + return PaymentOrderDetailsContent + if name == "PaymentOrderStatusContent": + from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_order_status_content import ( + PaymentOrderStatusContent, + ) + + return PaymentOrderStatusContent + if name == "PaymentOrderDetailsChannelSpecificMessage": + from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_order_details_channel_specific_message import ( + PaymentOrderDetailsChannelSpecificMessage, + ) + + return PaymentOrderDetailsChannelSpecificMessage + if name == "PaymentOrderStatusChannelSpecificMessage": + from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_order_status_channel_specific_message import ( + PaymentOrderStatusChannelSpecificMessage, + ) + + return PaymentOrderStatusChannelSpecificMessage + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +__all__ = [ + "OrderItem", + "PaymentOrder", + "PaymentOrderDetailsChannelSpecificMessage", + "PaymentOrderDetailsContent", + "PaymentOrderStatusChannelSpecificMessage", + "PaymentOrderStatusContent", + "PaymentOrderStatusOrder", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/order_item.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/order_item.py new file mode 100644 index 00000000..a3d9a732 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/order_item.py @@ -0,0 +1,21 @@ +from typing import Optional +from pydantic import Field, StrictStr, StrictInt +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class OrderItem(BaseModelConfiguration): + retailer_id: StrictStr = Field( + ..., description="Unique ID of the retailer." + ) + name: StrictStr = Field( + ..., description="Item's name as displayed to the user." + ) + amount_value: StrictInt = Field(..., description="Price per item.") + quantity: StrictInt = Field( + ..., description="Number of items in this order." + ) + sale_amount_value: Optional[StrictInt] = Field( + default=None, description="Discounted price per item." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order.py new file mode 100644 index 00000000..96401378 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order.py @@ -0,0 +1,52 @@ +from datetime import datetime +from typing import Optional +from pydantic import Field, StrictStr, StrictInt, conlist +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment import ( + OrderItem, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class PaymentOrder(BaseModelConfiguration): + items: conlist(OrderItem) = Field( + ..., description="The items list for this order." + ) + subtotal_value: StrictInt = Field( + ..., + description="Value representing the subtotal amount of this order.", + ) + tax_value: StrictInt = Field( + ..., description="Value representing the tax amount for this order." + ) + catalog_id: Optional[StrictStr] = Field( + default=None, + description="Unique ID of the Facebook catalog being used by the business.", + ) + expiration_time: Optional[datetime] = Field( + default=None, + description="UTC timestamp indicating when the order should expire.", + ) + expiration_description: Optional[StrictStr] = Field( + default=None, description="Description of the expiration." + ) + tax_description: Optional[StrictStr] = Field( + default=None, description="Description of the tax for this order." + ) + shipping_value: Optional[StrictInt] = Field( + default=None, + description="Value representing the shipping amount for this order.", + ) + shipping_description: Optional[StrictStr] = Field( + default=None, description="Shipping description for this order." + ) + discount_value: Optional[StrictInt] = Field( + default=None, description="Value of the discount for this order." + ) + discount_description: Optional[StrictStr] = Field( + default=None, description="Description of the discount for this order." + ) + discount_program_name: Optional[StrictStr] = Field( + default=None, description="Discount program name for this order." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_channel_specific_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_channel_specific_message.py new file mode 100644 index 00000000..66271782 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_channel_specific_message.py @@ -0,0 +1,13 @@ +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.whatsapp_common_props import ( + WhatsAppCommonProps, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment import ( + PaymentOrderDetailsContent, +) + + +class PaymentOrderDetailsChannelSpecificMessage(WhatsAppCommonProps): + payment: PaymentOrderDetailsContent = Field( + ..., description="The payment order details content." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_content.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_content.py new file mode 100644 index 00000000..1ea4d66f --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_content.py @@ -0,0 +1,35 @@ +from typing import Optional +from pydantic import Field, StrictStr, StrictInt, conlist +from sinch.domains.conversation.models.v1.messages.types import ( + PaymentOrderType, + PaymentOrderGoodsType, +) +from sinch.domains.conversation.models.v1.messages.response.types.whatsapp_payment_button import ( + WhatsAppPaymentButton, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment import ( + PaymentOrder, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class PaymentOrderDetailsContent(BaseModelConfiguration): + type: PaymentOrderType = Field( + ..., + description="The country/currency associated with the payment message.", + ) + reference_id: StrictStr = Field(..., description="Unique reference ID.") + type_of_goods: PaymentOrderGoodsType = Field( + ..., description="The type of good associated with this order." + ) + total_amount_value: StrictInt = Field( + ..., + description="Integer representing the total amount of the transaction.", + ) + order: PaymentOrder = Field(..., description="The payment order.") + payment_buttons: Optional[conlist(WhatsAppPaymentButton)] = Field( + default=None, + description="Array of payment buttons (1 to 2 items).", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_channel_specific_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_channel_specific_message.py new file mode 100644 index 00000000..3d31c01e --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_channel_specific_message.py @@ -0,0 +1,13 @@ +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.whatsapp_common_props import ( + WhatsAppCommonProps, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment import ( + PaymentOrderStatusContent, +) + + +class PaymentOrderStatusChannelSpecificMessage(WhatsAppCommonProps): + payment: PaymentOrderStatusContent = Field( + ..., description="The payment order status message content" + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_content.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_content.py new file mode 100644 index 00000000..544a62a2 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_content.py @@ -0,0 +1,16 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment import ( + PaymentOrderStatusOrder, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class PaymentOrderStatusContent(BaseModelConfiguration): + reference_id: StrictStr = Field( + ..., description="Unique ID used to query the current payment status." + ) + order: PaymentOrderStatusOrder = Field( + ..., description="The payment order." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_order.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_order.py new file mode 100644 index 00000000..ee91a90a --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_order.py @@ -0,0 +1,18 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.types.payment_order_status_type import ( + PaymentOrderStatusType, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class PaymentOrderStatusOrder(BaseModelConfiguration): + status: PaymentOrderStatusType = Field( + ..., description="The new payment message status." + ) + description: Optional[StrictStr] = Field( + default=None, + description="The description of payment message status update (120 characters maximum).", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/whatsapp_common_props.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/whatsapp_common_props.py new file mode 100644 index 00000000..6433db6b --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/whatsapp_common_props.py @@ -0,0 +1,24 @@ +from typing import Optional +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.response.types.whatsapp_interactive_header import ( + WhatsAppInteractiveHeader, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows import ( + WhatsAppInteractiveBody, + WhatsAppInteractiveFooter, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class WhatsAppCommonProps(BaseModelConfiguration): + header: Optional[WhatsAppInteractiveHeader] = Field( + default=None, description="The header of the interactive message." + ) + body: Optional[WhatsAppInteractiveBody] = Field( + default=None, description="Body of the interactive message." + ) + footer: Optional[WhatsAppInteractiveFooter] = Field( + default=None, description="Footer of the interactive message." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/choice/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/choice/__init__.py new file mode 100644 index 00000000..5ba2c49e --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/choice/__init__.py @@ -0,0 +1,21 @@ +__all__ = [ + "ChoiceMessage", + "ChoiceMessageField", +] + + +def __getattr__(name: str): + """Lazy import to avoid circular dependencies.""" + if name == "ChoiceMessage": + from sinch.domains.conversation.models.v1.messages.categories.choice.choice_message import ( + ChoiceMessage, + ) + + return ChoiceMessage + if name == "ChoiceMessageField": + from sinch.domains.conversation.models.v1.messages.categories.choice.choice_message_field import ( + ChoiceMessageField, + ) + + return ChoiceMessageField + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/conversation/models/v1/messages/categories/choice/choice_message.py b/sinch/domains/conversation/models/v1/messages/categories/choice/choice_message.py new file mode 100644 index 00000000..789c3af7 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/choice/choice_message.py @@ -0,0 +1,22 @@ +from typing import Optional +from pydantic import Field, conlist +from sinch.domains.conversation.models.v1.messages.categories.choice.choice_message_properties import ( + ChoiceMessageProperties, +) +from sinch.domains.conversation.models.v1.messages.categories.choice.choice_option import ( + ChoiceOption, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) +from sinch.domains.conversation.models.v1.messages.categories.text import ( + TextMessage, +) + + +class ChoiceMessage(BaseModelConfiguration): + choices: conlist(ChoiceOption) = Field( + default=..., description="The number of choices is limited to 10." + ) + text_message: Optional[TextMessage] = None + message_properties: Optional[ChoiceMessageProperties] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/choice/choice_message_field.py b/sinch/domains/conversation/models/v1/messages/categories/choice/choice_message_field.py new file mode 100644 index 00000000..0ed3fc0c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/choice/choice_message_field.py @@ -0,0 +1,11 @@ +from typing import Optional +from sinch.domains.conversation.models.v1.messages.categories.choice.choice_message import ( + ChoiceMessage, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class ChoiceMessageField(BaseModelConfiguration): + choice_message: Optional[ChoiceMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/choice/choice_message_properties.py b/sinch/domains/conversation/models/v1/messages/categories/choice/choice_message_properties.py new file mode 100644 index 00000000..14e61940 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/choice/choice_message_properties.py @@ -0,0 +1,15 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class ChoiceMessageProperties(BaseModelConfiguration): + whatsapp_footer: Optional[StrictStr] = Field( + default=None, + description=( + "Optional. Sets the text for the footer of a WhatsApp reply button or URL button message. " + "Ignored for other channels." + ), + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/choice/choice_option.py b/sinch/domains/conversation/models/v1/messages/categories/choice/choice_option.py new file mode 100644 index 00000000..0110db24 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/choice/choice_option.py @@ -0,0 +1,52 @@ +from typing import Annotated, Union, get_args +from pydantic import BeforeValidator + +from sinch.domains.conversation.models.v1.messages.categories.choice.choice_options import ( + CalendarChoiceMessage, + CallChoiceMessage, + ChoiceMessageWithPostback, + LocationChoiceMessage, + ShareLocationChoiceMessage, + TextChoiceMessage, + UrlChoiceMessage, +) + +ChoiceOptionUnion = Union[ + CallChoiceMessage, + LocationChoiceMessage, + TextChoiceMessage, + UrlChoiceMessage, + CalendarChoiceMessage, + ShareLocationChoiceMessage, +] + + +def _choice_message_type_keys() -> frozenset[str]: + """Message-type keys derived from Union members (spec: choiceTypes oneOf).""" + base_fields = set(ChoiceMessageWithPostback.model_fields) + keys = set() + for model in get_args(ChoiceOptionUnion): + keys.update(model.model_fields.keys() - base_fields) + return frozenset(keys) + + +_CHOICE_MESSAGE_TYPE_KEYS = _choice_message_type_keys() + + +def _validate_exactly_one_choice_message_key(value: object) -> object: + """Ensure each choice dict has exactly one message-type key.""" + if not isinstance(value, dict): + return value + keys = _CHOICE_MESSAGE_TYPE_KEYS + count = sum(1 for k in keys if value.get(k) is not None) + if count != 1: + raise ValueError( + f"Each choice must have exactly one of: {', '.join(sorted(keys))}." + ) + return value + + +ChoiceOption = Annotated[ + ChoiceOptionUnion, + BeforeValidator(_validate_exactly_one_choice_message_key), +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/choice/choice_options.py b/sinch/domains/conversation/models/v1/messages/categories/choice/choice_options.py new file mode 100644 index 00000000..a8c7f0b1 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/choice/choice_options.py @@ -0,0 +1,54 @@ +from typing import Any, Optional +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.call.call_message import ( + CallMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.location.location_message import ( + LocationMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.url.url_message import ( + UrlMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.calendar.calendar_message import ( + CalendarMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.sharelocation.share_location_message import ( + ShareLocationMessage, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) +from sinch.domains.conversation.models.v1.messages.categories.text import ( + TextMessage, +) + + +class ChoiceMessageWithPostback(BaseModelConfiguration): + postback_data: Optional[Any] = Field( + default=None, + description="An optional field. This data will be returned in the ChoiceResponseMessage. The default is message_id_{text, title}.", + ) + + +class CallChoiceMessage(ChoiceMessageWithPostback): + call_message: Optional[CallMessage] = None + + +class LocationChoiceMessage(ChoiceMessageWithPostback): + location_message: Optional[LocationMessage] = None + + +class TextChoiceMessage(ChoiceMessageWithPostback): + text_message: Optional[TextMessage] = None + + +class UrlChoiceMessage(ChoiceMessageWithPostback): + url_message: Optional[UrlMessage] = None + + +class CalendarChoiceMessage(ChoiceMessageWithPostback): + calendar_message: Optional[CalendarMessage] = None + + +class ShareLocationChoiceMessage(ChoiceMessageWithPostback): + share_location_message: Optional[ShareLocationMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/choiceresponse/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/choiceresponse/__init__.py new file mode 100644 index 00000000..f574170b --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/choiceresponse/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.conversation.models.v1.messages.categories.choiceresponse.choice_response_message import ( + ChoiceResponseMessage, +) + +__all__ = [ + "ChoiceResponseMessage", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/choiceresponse/choice_response_message.py b/sinch/domains/conversation/models/v1/messages/categories/choiceresponse/choice_response_message.py new file mode 100644 index 00000000..4b447a63 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/choiceresponse/choice_response_message.py @@ -0,0 +1,13 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class ChoiceResponseMessage(BaseModelConfiguration): + message_id: StrictStr = Field( + ..., description="The message id containing the choice." + ) + postback_data: StrictStr = Field( + ..., description="The postback_data defined in the selected choice." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/common/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/common/__init__.py new file mode 100644 index 00000000..8e548c2c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/common/__init__.py @@ -0,0 +1,15 @@ +from sinch.domains.conversation.models.v1.messages.categories.fallback.fallback_message import ( + FallbackMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.productresponse.product_response_message import ( + ProductResponseMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.common.reply_to import ( + ReplyTo, +) + +__all__ = [ + "FallbackMessage", + "ProductResponseMessage", + "ReplyTo", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/common/reply_to.py b/sinch/domains/conversation/models/v1/messages/categories/common/reply_to.py new file mode 100644 index 00000000..f3a6c582 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/common/reply_to.py @@ -0,0 +1,11 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class ReplyTo(BaseModelConfiguration): + message_id: StrictStr = Field( + default=..., + description="Required. The Id of the message that this is a response to", + ) diff --git a/sinch/domains/numbers/endpoints/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/contact/__init__.py similarity index 100% rename from sinch/domains/numbers/endpoints/__init__.py rename to sinch/domains/conversation/models/v1/messages/categories/contact/__init__.py diff --git a/sinch/domains/conversation/models/v1/messages/categories/contact/contact_message.py b/sinch/domains/conversation/models/v1/messages/categories/contact/contact_message.py new file mode 100644 index 00000000..f427fe2f --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/contact/contact_message.py @@ -0,0 +1,79 @@ +from typing import Optional +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.choiceresponse import ( + ChoiceResponseMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.channel_specific_contact_message_message import ( + ChannelSpecificContactMessageMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.fallback import ( + FallbackMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.location.location_message import ( + LocationMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.media import ( + MediaProperties, +) +from sinch.domains.conversation.models.v1.messages.categories.mediacard import ( + MediaCardMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.productresponse import ( + ProductResponseMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.text import ( + TextMessage, +) +from sinch.domains.conversation.models.v1.messages.shared.contact_message_common_props import ( + ContactMessageCommonProps, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class ChannelSpecificContactMessage( + ContactMessageCommonProps, BaseModelConfiguration +): + channel_specific_message: ChannelSpecificContactMessageMessage = Field( + ..., + description="A contact message containing a channel specific message (not supported by OMNI types).", + ) + + +class ChoiceResponseContactMessage( + ContactMessageCommonProps, BaseModelConfiguration +): + choice_response_message: Optional[ChoiceResponseMessage] = None + + +class FallbackContactMessage( + ContactMessageCommonProps, BaseModelConfiguration +): + fallback_message: Optional[FallbackMessage] = None + + +class LocationContactMessage( + ContactMessageCommonProps, BaseModelConfiguration +): + location_message: Optional[LocationMessage] = None + + +class MediaCardContactMessage( + ContactMessageCommonProps, BaseModelConfiguration +): + media_card_message: Optional[MediaCardMessage] = None + + +class MediaContactMessage(ContactMessageCommonProps, BaseModelConfiguration): + media_message: Optional[MediaProperties] = None + + +class ProductResponseContactMessage( + ContactMessageCommonProps, BaseModelConfiguration +): + product_response_message: Optional[ProductResponseMessage] = None + + +class TextContactMessage(ContactMessageCommonProps, BaseModelConfiguration): + text_message: Optional[TextMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/contactinfo/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/contactinfo/__init__.py new file mode 100644 index 00000000..02d9d495 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/contactinfo/__init__.py @@ -0,0 +1,11 @@ +from sinch.domains.conversation.models.v1.messages.categories.contactinfo.contact_info_message import ( + ContactInfoMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.contactinfo.contact_info_message_field import ( + ContactInfoMessageField, +) + +__all__ = [ + "ContactInfoMessage", + "ContactInfoMessageField", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/contactinfo/contact_info_message.py b/sinch/domains/conversation/models/v1/messages/categories/contactinfo/contact_info_message.py new file mode 100644 index 00000000..66bf447c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/contactinfo/contact_info_message.py @@ -0,0 +1,46 @@ +from typing import Optional +from datetime import date +from pydantic import Field, conlist +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) +from sinch.domains.conversation.models.v1.messages.shared.name_info import ( + NameInfo, +) +from sinch.domains.conversation.models.v1.messages.shared.phone_number_info import ( + PhoneNumberInfo, +) +from sinch.domains.conversation.models.v1.messages.shared.address_info import ( + AddressInfo, +) +from sinch.domains.conversation.models.v1.messages.shared.email_info import ( + EmailInfo, +) +from sinch.domains.conversation.models.v1.messages.shared.organization_info import ( + OrganizationInfo, +) +from sinch.domains.conversation.models.v1.messages.shared.url_info import ( + UrlInfo, +) + + +class ContactInfoMessage(BaseModelConfiguration): + name: NameInfo = Field(..., description="Name information of the contact.") + phone_numbers: conlist(PhoneNumberInfo) = Field( + description="Phone numbers of the contact (at least one required).", + ) + addresses: Optional[conlist(AddressInfo)] = Field( + default=None, description="Physical addresses of the contact." + ) + email_addresses: Optional[conlist(EmailInfo)] = Field( + default=None, description="Email addresses of the contact." + ) + organization: Optional[OrganizationInfo] = Field( + default=None, description="Organization info of the contact." + ) + urls: Optional[conlist(UrlInfo)] = Field( + default=None, description="URLs/websites associated with the contact." + ) + birthday: Optional[date] = Field( + default=None, description="Date of birth in YYYY-MM-DD format." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/contactinfo/contact_info_message_field.py b/sinch/domains/conversation/models/v1/messages/categories/contactinfo/contact_info_message_field.py new file mode 100644 index 00000000..9c25f070 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/contactinfo/contact_info_message_field.py @@ -0,0 +1,11 @@ +from typing import Optional +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) +from sinch.domains.conversation.models.v1.messages.categories.contactinfo.contact_info_message import ( + ContactInfoMessage, +) + + +class ContactInfoMessageField(BaseModelConfiguration): + contact_info_message: Optional[ContactInfoMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/fallback/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/fallback/__init__.py new file mode 100644 index 00000000..a58da977 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/fallback/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.conversation.models.v1.messages.categories.fallback.fallback_message import ( + FallbackMessage, +) + +__all__ = [ + "FallbackMessage", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/fallback/fallback_message.py b/sinch/domains/conversation/models/v1/messages/categories/fallback/fallback_message.py new file mode 100644 index 00000000..ab556e9c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/fallback/fallback_message.py @@ -0,0 +1,14 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.shared.reason import Reason +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class FallbackMessage(BaseModelConfiguration): + raw_message: Optional[StrictStr] = Field( + default=None, + description="Optional. The raw fallback message if provided by the channel.", + ) + reason: Optional[Reason] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/list/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/list/__init__.py new file mode 100644 index 00000000..34b34021 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/list/__init__.py @@ -0,0 +1,21 @@ +__all__ = [ + "ListMessage", + "ListMessageField", +] + + +def __getattr__(name: str): + """Lazy import to avoid circular dependencies.""" + if name == "ListMessage": + from sinch.domains.conversation.models.v1.messages.categories.list.list_message import ( + ListMessage, + ) + + return ListMessage + if name == "ListMessageField": + from sinch.domains.conversation.models.v1.messages.categories.list.list_message_field import ( + ListMessageField, + ) + + return ListMessageField + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/conversation/models/v1/messages/categories/list/list_item.py b/sinch/domains/conversation/models/v1/messages/categories/list/list_item.py new file mode 100644 index 00000000..51455a49 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/list/list_item.py @@ -0,0 +1,10 @@ +from typing import Union + +from sinch.domains.conversation.models.v1.messages.categories.list.list_item_choice import ( + ListItemChoice, +) +from sinch.domains.conversation.models.v1.messages.categories.list.list_item_product import ( + ListItemProduct, +) + +ListItem = Union[ListItemChoice, ListItemProduct] diff --git a/sinch/domains/conversation/models/v1/messages/categories/list/list_item_choice.py b/sinch/domains/conversation/models/v1/messages/categories/list/list_item_choice.py new file mode 100644 index 00000000..33d99d28 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/list/list_item_choice.py @@ -0,0 +1,11 @@ +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.shared.choice_item import ( + ChoiceItem, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class ListItemChoice(BaseModelConfiguration): + choice: ChoiceItem = Field(...) diff --git a/sinch/domains/conversation/models/v1/messages/categories/list/list_item_product.py b/sinch/domains/conversation/models/v1/messages/categories/list/list_item_product.py new file mode 100644 index 00000000..4322937f --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/list/list_item_product.py @@ -0,0 +1,11 @@ +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.shared.product_item import ( + ProductItem, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class ListItemProduct(BaseModelConfiguration): + product: ProductItem = Field(...) diff --git a/sinch/domains/conversation/models/v1/messages/categories/list/list_message.py b/sinch/domains/conversation/models/v1/messages/categories/list/list_message.py new file mode 100644 index 00000000..eff78dd2 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/list/list_message.py @@ -0,0 +1,31 @@ +from typing import Optional +from pydantic import Field, StrictStr, conlist +from sinch.domains.conversation.models.v1.messages.shared.list_section import ( + ListSection, +) +from sinch.domains.conversation.models.v1.messages.categories.media import ( + MediaProperties, +) +from sinch.domains.conversation.models.v1.messages.categories.list.list_message_properties import ( + ListMessageProperties, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class ListMessage(BaseModelConfiguration): + title: StrictStr = Field( + default=..., + description="A title for the message that is displayed near the products or choices.", + ) + description: Optional[StrictStr] = Field( + default=None, + description="This is an optional field, containing a description for the message.", + ) + media: Optional[MediaProperties] = None + sections: conlist(ListSection) = Field( + default=..., + description="List of ListSection objects containing choices to be presented in the list message.", + ) + message_properties: Optional[ListMessageProperties] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/list/list_message_field.py b/sinch/domains/conversation/models/v1/messages/categories/list/list_message_field.py new file mode 100644 index 00000000..27d0ee84 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/list/list_message_field.py @@ -0,0 +1,11 @@ +from typing import Optional +from sinch.domains.conversation.models.v1.messages.categories.list.list_message import ( + ListMessage, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class ListMessageField(BaseModelConfiguration): + list_message: Optional[ListMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/list/list_message_properties.py b/sinch/domains/conversation/models/v1/messages/categories/list/list_message_properties.py new file mode 100644 index 00000000..4066c000 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/list/list_message_properties.py @@ -0,0 +1,20 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class ListMessageProperties(BaseModelConfiguration): + catalog_id: Optional[StrictStr] = Field( + default=None, + description="Required if sending a product list message. The ID of the catalog to which the products belong.", + ) + menu: Optional[StrictStr] = Field( + default=None, + description="Optional. Sets the text for the menu of a choice list message.", + ) + whatsapp_header: Optional[StrictStr] = Field( + default=None, + description="Optional. Sets the text for the header of a WhatsApp choice list message. Ignored for other channels.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/location/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/location/__init__.py new file mode 100644 index 00000000..9330f8ad --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/location/__init__.py @@ -0,0 +1,21 @@ +__all__ = [ + "LocationMessage", + "LocationMessageField", +] + + +def __getattr__(name: str): + """Lazy import to avoid circular dependencies.""" + if name == "LocationMessage": + from sinch.domains.conversation.models.v1.messages.categories.location.location_message import ( + LocationMessage, + ) + + return LocationMessage + if name == "LocationMessageField": + from sinch.domains.conversation.models.v1.messages.categories.location.location_message_field import ( + LocationMessageField, + ) + + return LocationMessageField + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/conversation/models/v1/messages/categories/location/location_message.py b/sinch/domains/conversation/models/v1/messages/categories/location/location_message.py new file mode 100644 index 00000000..f8b71a3b --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/location/location_message.py @@ -0,0 +1,19 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.shared.coordinates import ( + Coordinates, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class LocationMessage(BaseModelConfiguration): + coordinates: Coordinates = Field(...) + label: Optional[StrictStr] = Field( + default=None, description="Label or name for the position." + ) + title: StrictStr = Field( + default=..., + description="The title is shown close to the button or link that leads to a map showing the location. The title can be clickable in some cases.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/location/location_message_field.py b/sinch/domains/conversation/models/v1/messages/categories/location/location_message_field.py new file mode 100644 index 00000000..3e18afaa --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/location/location_message_field.py @@ -0,0 +1,11 @@ +from typing import Optional +from sinch.domains.conversation.models.v1.messages.categories.location.location_message import ( + LocationMessage, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class LocationMessageField(BaseModelConfiguration): + location_message: Optional[LocationMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/media/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/media/__init__.py new file mode 100644 index 00000000..e74101f7 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/media/__init__.py @@ -0,0 +1,11 @@ +from sinch.domains.conversation.models.v1.messages.categories.media.media_message_field import ( + MediaMessageField, +) +from sinch.domains.conversation.models.v1.messages.categories.media.media_properties import ( + MediaProperties, +) + +__all__ = [ + "MediaMessageField", + "MediaProperties", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/media/media_message_field.py b/sinch/domains/conversation/models/v1/messages/categories/media/media_message_field.py new file mode 100644 index 00000000..fb4653b7 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/media/media_message_field.py @@ -0,0 +1,11 @@ +from typing import Optional +from sinch.domains.conversation.models.v1.messages.categories.media.media_properties import ( + MediaProperties, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class MediaMessageField(BaseModelConfiguration): + media_message: Optional[MediaProperties] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/media/media_properties.py b/sinch/domains/conversation/models/v1/messages/categories/media/media_properties.py new file mode 100644 index 00000000..298ca6aa --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/media/media_properties.py @@ -0,0 +1,16 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class MediaProperties(BaseModelConfiguration): + thumbnail_url: Optional[StrictStr] = Field( + default=None, + description="An optional parameter. Will be used where it is natively supported.", + ) + url: StrictStr = Field(default=..., description="Url to the media file.") + filename_override: Optional[StrictStr] = Field( + default=None, description="Overrides the media file name." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/mediacard/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/mediacard/__init__.py new file mode 100644 index 00000000..bc78e410 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/mediacard/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.conversation.models.v1.messages.categories.mediacard.media_card_message import ( + MediaCardMessage, +) + +__all__ = [ + "MediaCardMessage", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/mediacard/media_card_message.py b/sinch/domains/conversation/models/v1/messages/categories/mediacard/media_card_message.py new file mode 100644 index 00000000..411b2ec6 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/mediacard/media_card_message.py @@ -0,0 +1,13 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class MediaCardMessage(BaseModelConfiguration): + caption: Optional[StrictStr] = Field( + default=None, + description="Caption for the media on supported channels.", + ) + url: StrictStr = Field(default=..., description="Url to the media file.") diff --git a/sinch/domains/conversation/models/v1/messages/categories/productresponse/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/productresponse/__init__.py new file mode 100644 index 00000000..abac8b94 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/productresponse/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.conversation.models.v1.messages.categories.productresponse.product_response_message import ( + ProductResponseMessage, +) + +__all__ = [ + "ProductResponseMessage", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/productresponse/product_response_message.py b/sinch/domains/conversation/models/v1/messages/categories/productresponse/product_response_message.py new file mode 100644 index 00000000..c93ec77a --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/productresponse/product_response_message.py @@ -0,0 +1,22 @@ +from typing import Optional +from pydantic import Field, StrictStr, conlist +from sinch.domains.conversation.models.v1.messages.shared.product_item import ( + ProductItem, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class ProductResponseMessage(BaseModelConfiguration): + products: Optional[conlist(ProductItem)] = Field( + default=None, description="The selected products." + ) + title: Optional[StrictStr] = Field( + default=None, + description="Optional parameter. Text that may be sent with selected products.", + ) + catalog_id: Optional[StrictStr] = Field( + default=None, + description="Optional parameter. The catalog id that the selected products belong to.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/sharelocation/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/sharelocation/__init__.py new file mode 100644 index 00000000..e0f98ed2 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/sharelocation/__init__.py @@ -0,0 +1,14 @@ +__all__ = [ + "ShareLocationMessage", +] + + +def __getattr__(name: str): + """Lazy import to avoid circular dependencies.""" + if name == "ShareLocationMessage": + from sinch.domains.conversation.models.v1.messages.categories.sharelocation.share_location_message import ( + ShareLocationMessage, + ) + + return ShareLocationMessage + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/conversation/models/v1/messages/categories/sharelocation/share_location_message.py b/sinch/domains/conversation/models/v1/messages/categories/sharelocation/share_location_message.py new file mode 100644 index 00000000..58a366f5 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/sharelocation/share_location_message.py @@ -0,0 +1,15 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class ShareLocationMessage(BaseModelConfiguration): + title: StrictStr = Field( + ..., + description="The title is shown close to the button that leads to open a map to share a location.", + ) + fallback_url: StrictStr = Field( + ..., + description="The URL that is opened when channel does not have support for this type.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/template/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/template/__init__.py new file mode 100644 index 00000000..6fb43934 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/template/__init__.py @@ -0,0 +1,35 @@ +__all__ = [ + "TemplateMessage", + "TemplateReferenceChannelSpecific", + "TemplateReferenceField", + "TemplateReferenceOmniChannel", +] + + +def __getattr__(name: str): + """Lazy import to avoid circular dependencies.""" + if name == "TemplateMessage": + from sinch.domains.conversation.models.v1.messages.categories.template.template_message import ( + TemplateMessage, + ) + + return TemplateMessage + if name == "TemplateReferenceChannelSpecific": + from sinch.domains.conversation.models.v1.messages.categories.template.template_reference_channel_specific import ( + TemplateReferenceChannelSpecific, + ) + + return TemplateReferenceChannelSpecific + if name == "TemplateReferenceField": + from sinch.domains.conversation.models.v1.messages.categories.template.template_reference_field import ( + TemplateReferenceField, + ) + + return TemplateReferenceField + if name == "TemplateReferenceOmniChannel": + from sinch.domains.conversation.models.v1.messages.categories.template.template_reference_omni_channel import ( + TemplateReferenceOmniChannel, + ) + + return TemplateReferenceOmniChannel + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/conversation/models/v1/messages/categories/template/template_message.py b/sinch/domains/conversation/models/v1/messages/categories/template/template_message.py new file mode 100644 index 00000000..fe003f71 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/template/template_message.py @@ -0,0 +1,19 @@ +from typing import Dict, Optional +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.template import ( + TemplateReferenceChannelSpecific, + TemplateReferenceOmniChannel, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class TemplateMessage(BaseModelConfiguration): + channel_template: Optional[Dict[str, TemplateReferenceChannelSpecific]] = ( + Field( + default=None, + description="Optional. Channel specific template reference with parameters per channel. The channel template if exists overrides the omnichannel template. At least one of `channel_template` or `omni_template` needs to be present. The key in the map must point to a valid conversation channel as defined by the enum ConversationChannel.", + ) + ) + omni_template: Optional[TemplateReferenceOmniChannel] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/template/template_reference_channel_specific.py b/sinch/domains/conversation/models/v1/messages/categories/template/template_reference_channel_specific.py new file mode 100644 index 00000000..404f39e0 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/template/template_reference_channel_specific.py @@ -0,0 +1,24 @@ +from typing import Dict, Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class TemplateReferenceChannelSpecific(BaseModelConfiguration): + version: Optional[StrictStr] = Field( + default=None, + description="Used to specify what version of a template to use. Required when using `omni_channel_override` and `omni_template` fields. This will be used in conjunction with `language_code`. Note that, when referencing omni-channel templates using the [Sinch Customer Dashboard](https://dashboard.sinch.com/), the latest version of a given omni-template can be identified by populating this field with `latest`.", + ) + language_code: Optional[StrictStr] = Field( + default=None, + description="The BCP-47 language code, such as `en_US` or `sr_Latn`. For more information, see http://www.unicode.org/reports/tr35/#Unicode_locale_identifier. English is the default `language_code`. Note that, while many API calls involving templates accept either the dashed format (`en-US`) or the underscored format (`en_US`), some channel specific templates (for example, WhatsApp channel-specific templates) only accept the underscored format. Note that this field is required for WhatsApp channel-specific templates.", + ) + parameters: Optional[Dict[str, StrictStr]] = Field( + default=None, + description="Required if the template has parameters. Concrete values must be present for all defined parameters in the template. Parameters can be different for different versions and/or languages of the template.", + ) + template_id: StrictStr = Field( + default=..., + description="The ID of the template. Note that, in the case of WhatsApp channel-specific templates, this field must be populated by the name of the template.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/template/template_reference_field.py b/sinch/domains/conversation/models/v1/messages/categories/template/template_reference_field.py new file mode 100644 index 00000000..35fb765c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/template/template_reference_field.py @@ -0,0 +1,11 @@ +from typing import Optional +from sinch.domains.conversation.models.v1.messages.categories.template import ( + TemplateReferenceOmniChannel, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class TemplateReferenceField(BaseModelConfiguration): + template_reference: Optional[TemplateReferenceOmniChannel] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/template/template_reference_omni_channel.py b/sinch/domains/conversation/models/v1/messages/categories/template/template_reference_omni_channel.py new file mode 100644 index 00000000..97f378cb --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/template/template_reference_omni_channel.py @@ -0,0 +1,11 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.categories.template import ( + TemplateReferenceChannelSpecific, +) + + +class TemplateReferenceOmniChannel(TemplateReferenceChannelSpecific): + version: StrictStr = Field( + ..., + description="Used to specify what version of a template to use. Required when using `omni_channel_override` and `omni_template` fields. This will be used in conjunction with `language_code`. Note that, when referencing omni-channel templates using the [Sinch Customer Dashboard](https://dashboard.sinch.com/), the latest version of a given omni-template can be identified by populating this field with `latest`.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/text/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/text/__init__.py new file mode 100644 index 00000000..b60b473e --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/text/__init__.py @@ -0,0 +1,11 @@ +from sinch.domains.conversation.models.v1.messages.categories.text.text_message import ( + TextMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.text.text_message_field import ( + TextMessageField, +) + +__all__ = [ + "TextMessage", + "TextMessageField", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/text/text_message.py b/sinch/domains/conversation/models/v1/messages/categories/text/text_message.py new file mode 100644 index 00000000..fb8a266b --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/text/text_message.py @@ -0,0 +1,10 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class TextMessage(BaseModelConfiguration): + text: StrictStr = Field( + ..., description="The text content of the message." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/text/text_message_field.py b/sinch/domains/conversation/models/v1/messages/categories/text/text_message_field.py new file mode 100644 index 00000000..f85f620c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/text/text_message_field.py @@ -0,0 +1,11 @@ +from typing import Optional +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) +from sinch.domains.conversation.models.v1.messages.categories.text import ( + TextMessage, +) + + +class TextMessageField(BaseModelConfiguration): + text_message: Optional[TextMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/url/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/url/__init__.py new file mode 100644 index 00000000..436869a9 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/url/__init__.py @@ -0,0 +1,14 @@ +__all__ = [ + "UrlMessage", +] + + +def __getattr__(name: str): + """Lazy import to avoid circular dependencies.""" + if name == "UrlMessage": + from sinch.domains.conversation.models.v1.messages.categories.url.url_message import ( + UrlMessage, + ) + + return UrlMessage + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/conversation/models/v1/messages/categories/url/url_message.py b/sinch/domains/conversation/models/v1/messages/categories/url/url_message.py new file mode 100644 index 00000000..a6ca73d0 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/url/url_message.py @@ -0,0 +1,12 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class UrlMessage(BaseModelConfiguration): + title: StrictStr = Field( + default=..., + description="The title shown close to the URL. The title can be clickable in some cases.", + ) + url: StrictStr = Field(default=..., description="The url to show.") diff --git a/sinch/domains/conversation/models/v1/messages/internal/__init__.py b/sinch/domains/conversation/models/v1/messages/internal/__init__.py new file mode 100644 index 00000000..56c121c5 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/internal/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.conversation.models.v1.messages.internal.list_messages_response import ( + ListMessagesResponse, +) + +__all__ = [ + "ListMessagesResponse", +] diff --git a/sinch/domains/conversation/models/v1/messages/internal/base/__init__.py b/sinch/domains/conversation/models/v1/messages/internal/base/__init__.py new file mode 100644 index 00000000..c6908a4e --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/internal/base/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.conversation.models.v1.messages.internal.base.base_model_configuration import ( + BaseModelConfiguration, +) + +__all__ = [ + "BaseModelConfiguration", +] diff --git a/sinch/domains/conversation/models/v1/messages/internal/base/base_model_configuration.py b/sinch/domains/conversation/models/v1/messages/internal/base/base_model_configuration.py new file mode 100644 index 00000000..200cf35e --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/internal/base/base_model_configuration.py @@ -0,0 +1,32 @@ +import re +from typing import Any +from pydantic import BaseModel, ConfigDict + + +class BaseModelConfiguration(BaseModel): + """ + Base model for all conversation message models. + Both request and response use snake_case in the Conversation API. + """ + + model_config = ConfigDict( + # Allows using both alias (camelCase) and field name (snake_case) + populate_by_name=True, + # Allows extra values in input + extra="allow", + ) + + @staticmethod + def _to_snake_case(camel_str: str) -> str: + """Helper to convert camelCase string to snake_case.""" + return re.sub(r"(? None: + """Converts unknown fields from camelCase to snake_case.""" + if self.__pydantic_extra__: + converted_extra = { + self._to_snake_case(key): value + for key, value in self.__pydantic_extra__.items() + } + self.__pydantic_extra__.clear() + self.__pydantic_extra__.update(converted_extra) diff --git a/sinch/domains/conversation/models/v1/messages/internal/list_messages_response.py b/sinch/domains/conversation/models/v1/messages/internal/list_messages_response.py new file mode 100644 index 00000000..d9604750 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/internal/list_messages_response.py @@ -0,0 +1,24 @@ +from typing import List, Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) +from sinch.domains.conversation.models.v1.messages.response.types import ( + ConversationMessageResponse, +) + + +class ListMessagesResponse(BaseModelConfiguration): + messages: Optional[List[ConversationMessageResponse]] = Field( + default=None, + description="List of messages associated to the referenced conversation.", + ) + next_page_token: Optional[StrictStr] = Field( + default=None, + description="Token that should be included in the next request to fetch the next page.", + ) + + @property + def content(self): + """Returns the messages as part of the response object for pagination compatibility.""" + return self.messages or [] diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/__init__.py b/sinch/domains/conversation/models/v1/messages/internal/request/__init__.py new file mode 100644 index 00000000..29b96be6 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/internal/request/__init__.py @@ -0,0 +1,35 @@ +from sinch.domains.conversation.models.v1.messages.internal.request.list_messages_request import ( + ListMessagesRequest, +) +from sinch.domains.conversation.models.v1.messages.internal.request.message_id_request import ( + MessageIdRequest, +) +from sinch.domains.conversation.models.v1.messages.internal.request.update_message_metadata_request import ( + UpdateMessageMetadataRequest, +) +from sinch.domains.conversation.models.v1.messages.internal.request.recipient import ( + Recipient, + IdentifiedBy, + ChannelRecipientIdentity, +) +from sinch.domains.conversation.models.v1.messages.internal.request.send_message_request_body import ( + SendMessageRequestBody, +) +from sinch.domains.conversation.models.v1.messages.internal.request.send_message_request import ( + SendMessageRequest, +) +from sinch.domains.conversation.models.v1.messages.internal.request.list_messages_by_channel_identity_request import ( + ListLastMessagesByChannelIdentityRequest, +) + +__all__ = [ + "ListMessagesRequest", + "ListLastMessagesByChannelIdentityRequest", + "MessageIdRequest", + "UpdateMessageMetadataRequest", + "Recipient", + "IdentifiedBy", + "ChannelRecipientIdentity", + "SendMessageRequestBody", + "SendMessageRequest", +] diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_by_channel_identity_request.py b/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_by_channel_identity_request.py new file mode 100644 index 00000000..6ea3689c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_by_channel_identity_request.py @@ -0,0 +1,59 @@ +from datetime import datetime +from typing import Optional +from pydantic import Field, StrictInt, StrictStr, conlist +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) +from sinch.domains.conversation.models.v1.messages.types import ( + ConversationChannelType, + ConversationDirectionType, + ConversationMessagesViewType, + MessageSourceType, +) + + +class ListLastMessagesByChannelIdentityRequest(BaseModelConfiguration): + channel_identities: Optional[conlist(StrictStr)] = Field( + default=None, + description="Optional. Filter messages by channel_identity.", + ) + contact_ids: Optional[conlist(StrictStr)] = Field( + default=None, + description="Optional. Resource name (id) of the contact. In CONVERSATION_SOURCE: Can list last messages by contact_id. In DISPATCH_SOURCE: The field is unsupported and cannot be set.", + ) + app_id: Optional[StrictStr] = Field( + default=None, + description="Optional. Resource name (id) of the app.", + ) + messages_source: Optional[MessageSourceType] = Field( + default=None, + description="Specifies the message source for the request.", + ) + page_size: Optional[StrictInt] = Field( + default=None, + description="Optional. Maximum number of messages to fetch. Defaults to 10 and the maximum is 1000.", + ) + page_token: Optional[StrictStr] = Field( + default=None, + description="Optional. Next page token previously returned if any.", + ) + view: Optional[ConversationMessagesViewType] = Field( + default=None, + description="Optional. Specifies the representation in which messages should be returned. Defaults to WITH_METADATA.", + ) + start_time: Optional[datetime] = Field( + default=None, + description="Optional. Only fetch messages with accept_time after this date.", + ) + end_time: Optional[datetime] = Field( + default=None, + description="Optional. Only fetch messages with accept_time before this date.", + ) + channel: Optional[ConversationChannelType] = Field( + default=None, + description="Optional. Only fetch messages from the channel.", + ) + direction: Optional[ConversationDirectionType] = Field( + default=None, + description="Optional. Only fetch messages with the specified direction. If direction is not specified, it will list both TO_APP and TO_CONTACT messages.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_request.py b/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_request.py new file mode 100644 index 00000000..a0c6398d --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_request.py @@ -0,0 +1,69 @@ +from datetime import datetime +from typing import Optional +from pydantic import Field, StrictInt, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) +from sinch.domains.conversation.models.v1.messages.types import ( + ConversationChannelType, + ConversationDirectionType, + ConversationMessagesViewType, + MessageSourceType, +) + + +class ListMessagesRequest(BaseModelConfiguration): + """Request model for listing messages.""" + + conversation_id: Optional[StrictStr] = Field( + default=None, + description="Filter messages by conversation ID.", + ) + contact_id: Optional[StrictStr] = Field( + default=None, + description="Filter messages by contact ID.", + ) + app_id: Optional[StrictStr] = Field( + default=None, + description="Filter messages by app ID.", + ) + channel_identity: Optional[StrictStr] = Field( + default=None, + description="Channel identity of the contact.", + ) + start_time: Optional[datetime] = Field( + default=None, + description="Filter messages with accept_time after this timestamp. Must be before end_time if that is specified.", + ) + end_time: Optional[datetime] = Field( + default=None, + description="Filter messages with accept_time before this timestamp.", + ) + page_size: Optional[StrictInt] = Field( + default=None, + description="Maximum number of messages to fetch. Defaults to 10 and the maximum is 1000.", + ) + page_token: Optional[StrictStr] = Field( + default=None, + description="Next page token previously returned if any. When specifying this token, use the same values for the other parameters from the request that originated the token, otherwise the paged results may be inconsistent.", + ) + view: Optional[ConversationMessagesViewType] = Field( + default=None, + description="Messages view type. WITH_METADATA or WITHOUT_METADATA.", + ) + messages_source: Optional[MessageSourceType] = Field( + default=None, + description="Specifies the message source for the request.", + ) + only_recipient_originated: Optional[bool] = Field( + default=None, + description="Only fetch recipient-originated messages.", + ) + channel: Optional[ConversationChannelType] = Field( + default=None, + description="Only fetch messages from the specified channel.", + ) + direction: Optional[ConversationDirectionType] = Field( + default=None, + description="Optional. Only fetch messages with the specified direction. If direction is not specified, it will list both TO_APP and TO_CONTACT messages.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/message_id_request.py b/sinch/domains/conversation/models/v1/messages/internal/request/message_id_request.py new file mode 100644 index 00000000..2e623643 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/internal/request/message_id_request.py @@ -0,0 +1,16 @@ +from typing import Optional +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.types import ( + MessageSourceType, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class MessageIdRequest(BaseModelConfiguration): + message_id: str = Field(..., description="The unique ID of the message.") + messages_source: Optional[MessageSourceType] = Field( + default=None, + description="Specifies the message source for which the request will be processed. Used for operations on messages in Dispatch Mode. For more information, see [Processing Modes](https://developers.sinch.com/docs/conversation/processing-modes/).", + ) diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/recipient.py b/sinch/domains/conversation/models/v1/messages/internal/request/recipient.py new file mode 100644 index 00000000..3b42eadc --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/internal/request/recipient.py @@ -0,0 +1,38 @@ +from typing import List, Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) +from sinch.domains.conversation.models.v1.messages.types.conversation_channel_type import ( + ConversationChannelType, +) + + +class ChannelRecipientIdentity(BaseModelConfiguration): + channel: ConversationChannelType = Field( + ..., description="The conversation channel." + ) + identity: StrictStr = Field( + ..., description="The channel recipient identity." + ) + + +class IdentifiedBy(BaseModelConfiguration): + channel_identities: List[ChannelRecipientIdentity] = Field( + ..., + description=( + "A list of specific channel identities. " + "The API will use these identities when sending to specific channels." + ), + ) + + +class Recipient(BaseModelConfiguration): + identified_by: Optional[IdentifiedBy] = Field( + default=None, + description="The identity as specified by the channel. Required if using Dispatch Mode.", + ) + contact_id: Optional[StrictStr] = Field( + default=None, + description="The ID of the contact.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/send_message_request.py b/sinch/domains/conversation/models/v1/messages/internal/request/send_message_request.py new file mode 100644 index 00000000..96eafc99 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/internal/request/send_message_request.py @@ -0,0 +1,105 @@ +from typing import Any, Dict, List, Optional, Union + +from pydantic import Field, StrictInt, StrictStr, field_serializer +from sinch.domains.conversation.models.v1.messages.internal.request.recipient import ( + Recipient, +) +from sinch.domains.conversation.models.v1.messages.internal.request.send_message_request_body import ( + SendMessageRequestBody, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) +from sinch.domains.conversation.models.v1.messages.types.conversation_channel_type import ( + ConversationChannelType, +) +from sinch.domains.conversation.models.v1.messages.types.processing_strategy_type import ( + ProcessingStrategyType, +) +from sinch.domains.conversation.models.v1.messages.types.metadata_update_strategy_type import ( + MetadataUpdateStrategyType, +) +from sinch.domains.conversation.models.v1.messages.types.message_queue_type import ( + MessageQueueType, +) +from sinch.domains.conversation.models.v1.messages.types.message_content_type import ( + MessageContentType, +) + + +class SendMessageRequest(BaseModelConfiguration): + app_id: StrictStr = Field( + ..., + description="The ID of the Conversation API app sending the message.", + ) + recipient: Recipient = Field( + ..., + description="The recipient of the message.", + ) + message: SendMessageRequestBody = Field( + ..., + description="The message content to send.", + ) + ttl: Optional[Union[StrictStr, StrictInt]] = Field( + default=None, + description="The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'.", + ) + event_destination_target: Optional[StrictStr] = Field( + default=None, + alias="callback_url", + description="Overwrites the default event destination target for delivery receipts for this message.", + ) + channel_priority_order: Optional[List[ConversationChannelType]] = Field( + default=None, + description="Explicitly define the channels and order in which they are tried when sending the message.", + ) + channel_properties: Optional[Dict[str, str]] = Field( + default=None, + description="Channel-specific properties. The key in the map must point to a valid channel property key.", + ) + message_metadata: Optional[StrictStr] = Field( + default=None, + description="Metadata that should be associated with the message. Up to 1024 characters long.", + ) + conversation_metadata: Optional[Dict[str, Any]] = Field( + default=None, + description="Metadata that will be associated with the conversation. Up to 2048 characters long.", + ) + queue: Optional[MessageQueueType] = Field( + default=None, + description="Select the priority type for the message. Can be 'NORMAL_PRIORITY' or 'HIGH_PRIORITY'.", + ) + processing_strategy: Optional[ProcessingStrategyType] = Field( + default=None, + description="Overrides the app's Processing Mode. Can be 'DEFAULT' or 'DISPATCH_ONLY'.", + ) + correlation_id: Optional[StrictStr] = Field( + default=None, + description="An arbitrary identifier that will be propagated to callbacks related to this message. Up to 128 characters long.", + ) + conversation_metadata_update_strategy: Optional[ + MetadataUpdateStrategyType + ] = Field( + default=None, + description="Update strategy for the conversation_metadata field. Can be 'REPLACE' or 'MERGE_PATCH'.", + ) + message_content_type: Optional[MessageContentType] = Field( + default=None, + description="Classifies the message content for use with consent management. Can be 'CONTENT_UNKNOWN', 'CONTENT_MARKETING', or 'CONTENT_NOTIFICATION'.", + ) + + @field_serializer("ttl") + def serialize_ttl( + self, value: Optional[Union[StrictStr, StrictInt]] + ) -> Optional[str]: + """ + Serialize ttl field to the format expected by the API (string with 's' suffix). + Converts int to string with 's' suffix, or ensures string has 's' suffix. + """ + if value is None: + return None + if isinstance(value, int): + return f"{value}s" + if isinstance(value, str) and not value.endswith("s"): + return f"{value}s" + return value diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/send_message_request_body.py b/sinch/domains/conversation/models/v1/messages/internal/request/send_message_request_body.py new file mode 100644 index 00000000..b5bb2698 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/internal/request/send_message_request_body.py @@ -0,0 +1,43 @@ +from typing import Optional +from sinch.domains.conversation.models.v1.messages.categories.text import ( + TextMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.card.card_message import ( + CardMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.carousel.carousel_message import ( + CarouselMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.choice.choice_message import ( + ChoiceMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.contactinfo.contact_info_message import ( + ContactInfoMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.list.list_message import ( + ListMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.location.location_message import ( + LocationMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.media.media_properties import ( + MediaProperties, +) +from sinch.domains.conversation.models.v1.messages.categories.template.template_message import ( + TemplateMessage, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class SendMessageRequestBody(BaseModelConfiguration): + text_message: Optional[TextMessage] = None + card_message: Optional[CardMessage] = None + carousel_message: Optional[CarouselMessage] = None + choice_message: Optional[ChoiceMessage] = None + contact_info_message: Optional[ContactInfoMessage] = None + list_message: Optional[ListMessage] = None + location_message: Optional[LocationMessage] = None + media_message: Optional[MediaProperties] = None + template_message: Optional[TemplateMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/update_message_metadata_request.py b/sinch/domains/conversation/models/v1/messages/internal/request/update_message_metadata_request.py new file mode 100644 index 00000000..93376ef0 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/internal/request/update_message_metadata_request.py @@ -0,0 +1,19 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.types import ( + MessageSourceType, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class UpdateMessageMetadataRequest(BaseModelConfiguration): + message_id: str = Field(..., description="The unique ID of the message.") + metadata: StrictStr = Field( + ..., description="Metadata that should be associated with the message." + ) + messages_source: Optional[MessageSourceType] = Field( + default=None, + description="Specifies the message source for which the request will be processed. Used for operations on messages in Dispatch Mode.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/response/__init__.py b/sinch/domains/conversation/models/v1/messages/response/__init__.py new file mode 100644 index 00000000..21417094 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/response/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.conversation.models.v1.messages.response.send_message_response import ( + SendMessageResponse, +) + +__all__ = ["SendMessageResponse"] diff --git a/sinch/domains/conversation/models/v1/messages/response/message_response.py b/sinch/domains/conversation/models/v1/messages/response/message_response.py new file mode 100644 index 00000000..75393428 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/response/message_response.py @@ -0,0 +1,20 @@ +from sinch.domains.conversation.models.v1.messages.shared import ( + MessageCommonProps, +) +from sinch.domains.conversation.models.v1.messages.response.types.app_message import ( + AppMessage, +) +from sinch.domains.conversation.models.v1.messages.response.types.contact_message import ( + ContactMessage, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class AppMessageResponse(MessageCommonProps, BaseModelConfiguration): + app_message: AppMessage + + +class ContactMessageResponse(MessageCommonProps, BaseModelConfiguration): + contact_message: ContactMessage diff --git a/sinch/domains/conversation/models/v1/messages/response/send_message_response.py b/sinch/domains/conversation/models/v1/messages/response/send_message_response.py new file mode 100644 index 00000000..ae727e69 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/response/send_message_response.py @@ -0,0 +1,17 @@ +from datetime import datetime +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class SendMessageResponse(BaseModelConfiguration): + accepted_time: Optional[datetime] = Field( + default=None, + description="Timestamp when the Conversation API accepted the message for delivery to the referenced contact.", + ) + message_id: StrictStr = Field( + ..., + description="The ID of the sent message.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/response/types/__init__.py b/sinch/domains/conversation/models/v1/messages/response/types/__init__.py new file mode 100644 index 00000000..6fabbc12 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/response/types/__init__.py @@ -0,0 +1,35 @@ +from sinch.domains.conversation.models.v1.messages.response.types.app_message import ( + AppMessage, +) +from sinch.domains.conversation.models.v1.messages.response.types.contact_message import ( + ContactMessage, +) +from sinch.domains.conversation.models.v1.messages.response.types.conversation_message_response import ( + ConversationMessageResponse, +) +from sinch.domains.conversation.models.v1.messages.response.types.kakaotalk_button import ( + KakaoTalkButton, +) +from sinch.domains.conversation.models.v1.messages.response.types.kakaotalk_commerce import ( + KakaoTalkCommerce, +) +from sinch.domains.conversation.models.v1.messages.response.types.kakaotalk_coupon import ( + KakaoTalkCoupon, +) +from sinch.domains.conversation.models.v1.messages.response.types.whatsapp_interactive_header import ( + WhatsAppInteractiveHeader, +) +from sinch.domains.conversation.models.v1.messages.response.types.whatsapp_payment_button import ( + WhatsAppPaymentButton, +) + +__all__ = [ + "AppMessage", + "ContactMessage", + "ConversationMessageResponse", + "KakaoTalkButton", + "KakaoTalkCommerce", + "KakaoTalkCoupon", + "WhatsAppPaymentButton", + "WhatsAppInteractiveHeader", +] diff --git a/sinch/domains/conversation/models/v1/messages/response/types/app_message.py b/sinch/domains/conversation/models/v1/messages/response/types/app_message.py new file mode 100644 index 00000000..60564b66 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/response/types/app_message.py @@ -0,0 +1,24 @@ +from typing import Union +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + CardAppMessage, + CarouselAppMessage, + ChoiceAppMessage, + ContactInfoAppMessage, + ListAppMessage, + LocationAppMessage, + MediaAppMessage, + TemplateAppMessage, + TextAppMessage, +) + +AppMessage = Union[ + CardAppMessage, + CarouselAppMessage, + ChoiceAppMessage, + ContactInfoAppMessage, + ListAppMessage, + LocationAppMessage, + MediaAppMessage, + TemplateAppMessage, + TextAppMessage, +] diff --git a/sinch/domains/conversation/models/v1/messages/response/types/contact_message.py b/sinch/domains/conversation/models/v1/messages/response/types/contact_message.py new file mode 100644 index 00000000..7dfb8f84 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/response/types/contact_message.py @@ -0,0 +1,22 @@ +from typing import Union +from sinch.domains.conversation.models.v1.messages.categories.contact.contact_message import ( + ChannelSpecificContactMessage, + ChoiceResponseContactMessage, + FallbackContactMessage, + LocationContactMessage, + MediaCardContactMessage, + MediaContactMessage, + ProductResponseContactMessage, + TextContactMessage, +) + +ContactMessage = Union[ + ChannelSpecificContactMessage, + ChoiceResponseContactMessage, + FallbackContactMessage, + LocationContactMessage, + MediaCardContactMessage, + MediaContactMessage, + ProductResponseContactMessage, + TextContactMessage, +] diff --git a/sinch/domains/conversation/models/v1/messages/response/types/conversation_message_response.py b/sinch/domains/conversation/models/v1/messages/response/types/conversation_message_response.py new file mode 100644 index 00000000..fd331013 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/response/types/conversation_message_response.py @@ -0,0 +1,11 @@ +from typing import Union +from sinch.domains.conversation.models.v1.messages.response.message_response import ( + AppMessageResponse, + ContactMessageResponse, +) + + +ConversationMessageResponse = Union[ + AppMessageResponse, + ContactMessageResponse, +] diff --git a/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_button.py b/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_button.py new file mode 100644 index 00000000..d84a85db --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_button.py @@ -0,0 +1,17 @@ +from typing import Union +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.buttons.kakaotalk_web_link_button import ( + KakaoTalkWebLinkButton, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.buttons.kakaotalk_app_link_button import ( + KakaoTalkAppLinkButton, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.buttons.kakaotalk_bot_keyword_button import ( + KakaoTalkBotKeywordButton, +) + + +KakaoTalkButton = Union[ + KakaoTalkWebLinkButton, + KakaoTalkAppLinkButton, + KakaoTalkBotKeywordButton, +] diff --git a/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_commerce.py b/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_commerce.py new file mode 100644 index 00000000..2c8593e4 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_commerce.py @@ -0,0 +1,17 @@ +from typing import Union +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_regular_price_commerce import ( + KakaoTalkRegularPriceCommerce, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_discount_fixed_commerce import ( + KakaoTalkDiscountFixedCommerce, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_discount_rate_commerce import ( + KakaoTalkDiscountRateCommerce, +) + + +KakaoTalkCommerce = Union[ + KakaoTalkRegularPriceCommerce, + KakaoTalkDiscountFixedCommerce, + KakaoTalkDiscountRateCommerce, +] diff --git a/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_coupon.py b/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_coupon.py new file mode 100644 index 00000000..6331efbc --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_coupon.py @@ -0,0 +1,28 @@ +from typing import Annotated, Union +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.coupons.kakaotalk_fixed_discount_coupon import ( + KakaoTalkFixedDiscountCoupon, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.coupons.kakaotalk_discount_rate_coupon import ( + KakaoTalkDiscountRateCoupon, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.coupons.kakaotalk_shipping_discount_coupon import ( + KakaoTalkShippingDiscountCoupon, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.coupons.kakaotalk_free_coupon import ( + KakaoTalkFreeCoupon, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.coupons.kakaotalk_up_coupon import ( + KakaoTalkUpCoupon, +) + + +_KakaoTalkCouponUnion = Union[ + KakaoTalkFixedDiscountCoupon, + KakaoTalkDiscountRateCoupon, + KakaoTalkShippingDiscountCoupon, + KakaoTalkFreeCoupon, + KakaoTalkUpCoupon, +] + +KakaoTalkCoupon = Annotated[_KakaoTalkCouponUnion, Field(discriminator="type")] diff --git a/sinch/domains/conversation/models/v1/messages/response/types/whatsapp_interactive_header.py b/sinch/domains/conversation/models/v1/messages/response/types/whatsapp_interactive_header.py new file mode 100644 index 00000000..ccaa44d2 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/response/types/whatsapp_interactive_header.py @@ -0,0 +1,26 @@ +from typing import Annotated, Union +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.whatsapp_interactive_text_header import ( + WhatsAppInteractiveTextHeader, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.whatsapp_interactive_image_header import ( + WhatsAppInteractiveImageHeader, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.whatsapp_interactive_document_header import ( + WhatsAppInteractiveDocumentHeader, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.whatsapp_interactive_video_header import ( + WhatsAppInteractiveVideoHeader, +) + + +_WhatsAppInteractiveHeaderUnion = Union[ + WhatsAppInteractiveTextHeader, + WhatsAppInteractiveImageHeader, + WhatsAppInteractiveDocumentHeader, + WhatsAppInteractiveVideoHeader, +] + +WhatsAppInteractiveHeader = Annotated[ + _WhatsAppInteractiveHeaderUnion, Field(discriminator="type") +] diff --git a/sinch/domains/conversation/models/v1/messages/response/types/whatsapp_payment_button.py b/sinch/domains/conversation/models/v1/messages/response/types/whatsapp_payment_button.py new file mode 100644 index 00000000..24d03f7f --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/response/types/whatsapp_payment_button.py @@ -0,0 +1,16 @@ +from typing import Union +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.buttons.whatsapp_payment_settings_pix_button import ( + WhatsAppPaymentSettingsPixButton, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.buttons.whatsapp_payment_settings_payment_link_button import ( + WhatsAppPaymentSettingsPaymentLinkButton, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.buttons.whatsapp_payment_settings_boleto_button import ( + WhatsAppPaymentSettingsBoletoButton, +) + +WhatsAppPaymentButton = Union[ + WhatsAppPaymentSettingsPixButton, + WhatsAppPaymentSettingsPaymentLinkButton, + WhatsAppPaymentSettingsBoletoButton, +] diff --git a/sinch/domains/conversation/models/v1/messages/shared/__init__.py b/sinch/domains/conversation/models/v1/messages/shared/__init__.py new file mode 100644 index 00000000..2bf6844a --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/__init__.py @@ -0,0 +1,53 @@ +from sinch.domains.conversation.models.v1.messages.shared.address_info import ( + AddressInfo, +) +from sinch.domains.conversation.models.v1.messages.shared.agent import Agent +from sinch.domains.conversation.models.v1.messages.shared.channel_identity import ( + ChannelIdentity, +) +from sinch.domains.conversation.models.v1.messages.shared.choice_item import ( + ChoiceItem, +) +from sinch.domains.conversation.models.v1.messages.shared.contact_message_common_props import ( + ContactMessageCommonProps, +) +from sinch.domains.conversation.models.v1.messages.shared.message_common_props import ( + MessageCommonProps, +) +from sinch.domains.conversation.models.v1.messages.shared.coordinates import ( + Coordinates, +) +from sinch.domains.conversation.models.v1.messages.shared.product_item import ( + ProductItem, +) +from sinch.domains.conversation.models.v1.messages.shared.reason import Reason + +__all__ = [ + "AddressInfo", + "Agent", + "AppMessageCommonProps", + "ChannelIdentity", + "ChoiceItem", + "ContactMessageCommonProps", + "MessageCommonProps", + "Coordinates", + "OmniMessageOverride", + "ProductItem", + "Reason", +] + + +def __getattr__(name: str): + if name == "OmniMessageOverride": + from sinch.domains.conversation.models.v1.messages.shared.override.omni_message_override import ( + OmniMessageOverride, + ) + + return OmniMessageOverride + if name == "AppMessageCommonProps": + from sinch.domains.conversation.models.v1.messages.shared.app_message_common_props import ( + AppMessageCommonProps, + ) + + return AppMessageCommonProps + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/conversation/models/v1/messages/shared/address_info.py b/sinch/domains/conversation/models/v1/messages/shared/address_info.py new file mode 100644 index 00000000..a7bbc8e0 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/address_info.py @@ -0,0 +1,24 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class AddressInfo(BaseModelConfiguration): + city: Optional[StrictStr] = Field(default=None, description="City Name") + country: Optional[StrictStr] = Field( + default=None, description="Country Name" + ) + state: Optional[StrictStr] = Field( + default=None, description="Name of a state or region of a country." + ) + zip: Optional[StrictStr] = Field( + default=None, description="Zip/postal code" + ) + type: Optional[StrictStr] = Field( + default=None, description="Address type, e.g. WORK or HOME" + ) + country_code: Optional[StrictStr] = Field( + default=None, description="Two letter country code." + ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/agent.py b/sinch/domains/conversation/models/v1/messages/shared/agent.py new file mode 100644 index 00000000..f18a3c7b --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/agent.py @@ -0,0 +1,16 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.types import AgentType +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class Agent(BaseModelConfiguration): + display_name: Optional[StrictStr] = Field( + default=None, description="Agent's display name" + ) + type: Optional[AgentType] = None + picture_url: Optional[StrictStr] = Field( + default=None, description="The Agent's picture url." + ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/app_message_common_props.py b/sinch/domains/conversation/models/v1/messages/shared/app_message_common_props.py new file mode 100644 index 00000000..17091a95 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/app_message_common_props.py @@ -0,0 +1,32 @@ +from typing import Dict, Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.channel_specific_message import ( + ChannelSpecificMessage, +) +from sinch.domains.conversation.models.v1.messages.shared import Agent +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) +from sinch.domains.conversation.models.v1.messages.shared.override.omni_message_override import ( + OmniMessageOverride, +) + + +class AppMessageCommonProps(BaseModelConfiguration): + explicit_channel_message: Optional[Dict[str, StrictStr]] = Field( + default=None, + description="Allows you to specify a channel and define a corresponding channel specific message payload that will override the standard Conversation API message types. The key in the map must point to a valid conversation channel as defined in the enum `ConversationChannel`. The message content must be provided in string format. You may use the [transcoding endpoint](https://developers.sinch.com/docs/conversation/api-reference/conversation/tag/Transcoding/) to help create your message. For more information about how to construct an explicit channel message for a particular channel, see that [channel's corresponding documentation](https://developers.sinch.com/docs/conversation/channel-support/) (for example, using explicit channel messages with [the WhatsApp channel](https://developers.sinch.com/docs/conversation/channel-support/whatsapp/message-support/#explicit-channel-messages)).", + ) + explicit_channel_omni_message: Optional[Dict[str, OmniMessageOverride]] = ( + Field( + default=None, + description="Override the message's content for specified channels. The key in the map must point to a valid conversation channel as defined in the enum `ConversationChannel`. The content defined under the specified channel will be sent on that channel.", + ) + ) + channel_specific_message: Optional[Dict[str, ChannelSpecificMessage]] = ( + Field( + default=None, + description="Channel specific messages, overriding any transcoding. The structure of this property is more well-defined than the open structure of the `explicit_channel_message` property, and may be easier to use. The key in the map must point to a valid conversation channel as defined in the enum `ConversationChannel`.", + ) + ) + agent: Optional[Agent] = None diff --git a/sinch/domains/conversation/models/v1/messages/shared/channel_identity.py b/sinch/domains/conversation/models/v1/messages/shared/channel_identity.py new file mode 100644 index 00000000..ecf4daa1 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/channel_identity.py @@ -0,0 +1,20 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.types.conversation_channel_type import ( + ConversationChannelType, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class ChannelIdentity(BaseModelConfiguration): + app_id: Optional[StrictStr] = Field( + default=None, + description="Required if using a channel that uses app-scoped channel identities. Currently, FB Messenger, Instagram, LINE, and WeChat use app-scoped channel identities, which means contacts will have different channel identities on different Conversation API apps. These can be thought of as virtual identities that are app-specific and, therefore, the app_id must be included in the API call.", + ) + channel: ConversationChannelType = Field(...) + identity: StrictStr = Field( + default=..., + description="The channel identity. This will differ from channel to channel. For example, a phone number for SMS, WhatsApp, and Viber Business.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/choice_item.py b/sinch/domains/conversation/models/v1/messages/shared/choice_item.py new file mode 100644 index 00000000..9e5c0459 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/choice_item.py @@ -0,0 +1,27 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.categories.media.media_properties import ( + MediaProperties, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class ChoiceItem(BaseModelConfiguration): + title: StrictStr = Field( + default=..., + description="Required parameter. Title for the choice item.", + ) + description: Optional[StrictStr] = Field( + default=None, + description="Optional parameter. The description (or subtitle) of this choice item.", + ) + media: Optional[MediaProperties] = Field( + default=None, + description="Optional parameter. The media of this choice item.", + ) + postback_data: Optional[StrictStr] = Field( + default=None, + description="Optional parameter. Postback data that will be returned in the MO if the user selects this option.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/contact_message_common_props.py b/sinch/domains/conversation/models/v1/messages/shared/contact_message_common_props.py new file mode 100644 index 00000000..e67161c8 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/contact_message_common_props.py @@ -0,0 +1,11 @@ +from typing import Optional +from sinch.domains.conversation.models.v1.messages.categories.common.reply_to import ( + ReplyTo, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class ContactMessageCommonProps(BaseModelConfiguration): + reply_to: Optional[ReplyTo] = None diff --git a/sinch/domains/conversation/models/v1/messages/shared/coordinates.py b/sinch/domains/conversation/models/v1/messages/shared/coordinates.py new file mode 100644 index 00000000..94dbbccb --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/coordinates.py @@ -0,0 +1,14 @@ +from typing import Union +from pydantic import Field, StrictFloat, StrictInt +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class Coordinates(BaseModelConfiguration): + latitude: Union[StrictFloat, StrictInt] = Field( + default=..., description="The latitude." + ) + longitude: Union[StrictFloat, StrictInt] = Field( + default=..., description="The longitude." + ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/email_info.py b/sinch/domains/conversation/models/v1/messages/shared/email_info.py new file mode 100644 index 00000000..ea83866e --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/email_info.py @@ -0,0 +1,12 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class EmailInfo(BaseModelConfiguration): + email_address: StrictStr = Field(default=..., description="Email address.") + type: Optional[StrictStr] = Field( + default=None, description="Email address type. e.g. WORK or HOME." + ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/list_section.py b/sinch/domains/conversation/models/v1/messages/shared/list_section.py new file mode 100644 index 00000000..e6163fd9 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/list_section.py @@ -0,0 +1,15 @@ +from typing import Optional +from pydantic import Field, StrictStr, conlist +from sinch.domains.conversation.models.v1.messages.categories.list.list_item import ( + ListItem, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class ListSection(BaseModelConfiguration): + title: Optional[StrictStr] = Field( + default=None, description="Optional parameter. Title for list section." + ) + items: conlist(ListItem) = Field(...) diff --git a/sinch/domains/conversation/models/v1/messages/shared/message_common_props.py b/sinch/domains/conversation/models/v1/messages/shared/message_common_props.py new file mode 100644 index 00000000..b79df300 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/message_common_props.py @@ -0,0 +1,51 @@ +from typing import Optional +from datetime import datetime +from pydantic import Field, StrictBool, StrictStr +from sinch.domains.conversation.models.v1.messages.shared.channel_identity import ( + ChannelIdentity, +) +from sinch.domains.conversation.models.v1.messages.types.conversation_direction_type import ( + ConversationDirectionType, +) +from sinch.domains.conversation.models.v1.messages.types.processing_mode_type import ( + ProcessingModeType, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class MessageCommonProps(BaseModelConfiguration): + accept_time: Optional[datetime] = Field( + default=None, + description="The time Conversation API processed the message.", + ) + channel_identity: Optional[ChannelIdentity] = Field( + default=None, + description="A unique identity of message recipient on a particular channel. For example, the channel identity on SMS, WHATSAPP or VIBERBM is a MSISDN phone number.", + ) + contact_id: Optional[StrictStr] = Field( + default=None, description="The ID of the contact." + ) + conversation_id: Optional[StrictStr] = Field( + default=None, description="The ID of the conversation." + ) + direction: Optional[ConversationDirectionType] = None + id: Optional[StrictStr] = Field( + default=None, description="The ID of the message." + ) + metadata: Optional[StrictStr] = Field( + default=None, + description="Optional. Metadata associated with the contact. Up to 1024 characters long.", + ) + injected: Optional[StrictBool] = Field( + default=None, description="Flag for whether this message was injected." + ) + sender_id: Optional[StrictStr] = Field( + default=None, + description="For Contact Messages (MO messages), the sender ID represents the recipient to which the message was sent. This may be a phone number (in the case of SMS and MMS) or a unique ID (in the case of WhatsApp). This is field is not supported on all channels, nor is it supported for MT messages.", + ) + processing_mode: Optional[ProcessingModeType] = Field( + default=None, + description="Whether or not Conversation API should store contacts and conversations for the app. For more information, see [Processing Modes](https://developers.sinch.com/docs/conversation/processing-modes/).", + ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/name_info.py b/sinch/domains/conversation/models/v1/messages/shared/name_info.py new file mode 100644 index 00000000..006e7137 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/name_info.py @@ -0,0 +1,27 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class NameInfo(BaseModelConfiguration): + full_name: StrictStr = Field( + default=..., description="Full name of the contact" + ) + first_name: Optional[StrictStr] = Field( + default=None, description="First name." + ) + last_name: Optional[StrictStr] = Field( + default=None, description="Last name." + ) + middle_name: Optional[StrictStr] = Field( + default=None, description="Middle name." + ) + prefix: Optional[StrictStr] = Field( + default=None, + description="Prefix before the name. e.g. Mr, Mrs, Dr etc.", + ) + suffix: Optional[StrictStr] = Field( + default=None, description="Suffix after the name." + ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/organization_info.py b/sinch/domains/conversation/models/v1/messages/shared/organization_info.py new file mode 100644 index 00000000..39deed8f --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/organization_info.py @@ -0,0 +1,17 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class OrganizationInfo(BaseModelConfiguration): + company: Optional[StrictStr] = Field( + default=None, description="Company name" + ) + department: Optional[StrictStr] = Field( + default=None, description="Department at the company" + ) + title: Optional[StrictStr] = Field( + default=None, description="Corporate title, e.g. Software engineer" + ) diff --git a/sinch/domains/numbers/endpoints/active/__init__.py b/sinch/domains/conversation/models/v1/messages/shared/override/__init__.py similarity index 100% rename from sinch/domains/numbers/endpoints/active/__init__.py rename to sinch/domains/conversation/models/v1/messages/shared/override/__init__.py diff --git a/sinch/domains/conversation/models/v1/messages/shared/override/omni_message_override.py b/sinch/domains/conversation/models/v1/messages/shared/override/omni_message_override.py new file mode 100644 index 00000000..c2c43dfa --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/override/omni_message_override.py @@ -0,0 +1,50 @@ +from typing import Union + + +def _get_omni_message_override_union(): + """Lazy import to avoid circular dependencies.""" + from sinch.domains.conversation.models.v1.messages.categories.card.card_message_field import ( + CardMessageField, + ) + from sinch.domains.conversation.models.v1.messages.categories.carousel.carousel_message_field import ( + CarouselMessageField, + ) + from sinch.domains.conversation.models.v1.messages.categories.choice.choice_message_field import ( + ChoiceMessageField, + ) + from sinch.domains.conversation.models.v1.messages.categories.contactinfo.contact_info_message_field import ( + ContactInfoMessageField, + ) + from sinch.domains.conversation.models.v1.messages.categories.list.list_message_field import ( + ListMessageField, + ) + from sinch.domains.conversation.models.v1.messages.categories.location.location_message_field import ( + LocationMessageField, + ) + from sinch.domains.conversation.models.v1.messages.categories.media.media_message_field import ( + MediaMessageField, + ) + from sinch.domains.conversation.models.v1.messages.categories.template.template_reference_field import ( + TemplateReferenceField, + ) + from sinch.domains.conversation.models.v1.messages.categories.text.text_message_field import ( + TextMessageField, + ) + + return Union[ + TextMessageField, + MediaMessageField, + TemplateReferenceField, + ChoiceMessageField, + CardMessageField, + CarouselMessageField, + LocationMessageField, + ContactInfoMessageField, + ListMessageField, + ] + + +def __getattr__(name: str): + if name == "OmniMessageOverride": + return _get_omni_message_override_union() + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/conversation/models/v1/messages/shared/phone_number_info.py b/sinch/domains/conversation/models/v1/messages/shared/phone_number_info.py new file mode 100644 index 00000000..c5cb58f2 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/phone_number_info.py @@ -0,0 +1,14 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class PhoneNumberInfo(BaseModelConfiguration): + phone_number: StrictStr = Field( + default=..., description="Phone number with country code included." + ) + type: Optional[StrictStr] = Field( + default=None, description="Phone number type, e.g. WORK or HOME." + ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/product_item.py b/sinch/domains/conversation/models/v1/messages/shared/product_item.py new file mode 100644 index 00000000..3a5ed876 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/product_item.py @@ -0,0 +1,27 @@ +from typing import Optional, Union +from pydantic import Field, StrictFloat, StrictInt, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class ProductItem(BaseModelConfiguration): + id: StrictStr = Field( + default=..., description="Required parameter. The ID for the product." + ) + marketplace: StrictStr = Field( + default=..., + description="Required parameter. The marketplace to which the product belongs.", + ) + quantity: Optional[StrictInt] = Field( + default=None, + description="Output only. The quantity of the chosen product.", + ) + item_price: Optional[Union[StrictFloat, StrictInt]] = Field( + default=None, + description="Output only. The price for one unit of the chosen product.", + ) + currency: Optional[StrictStr] = Field( + default=None, + description="Output only. The currency of the item_price.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/reason.py b/sinch/domains/conversation/models/v1/messages/shared/reason.py new file mode 100644 index 00000000..66048aea --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/reason.py @@ -0,0 +1,23 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.types.reason_code_type import ( + ReasonCodeType, +) +from sinch.domains.conversation.models.v1.messages.types.reason_sub_code_type import ( + ReasonSubCodeType, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class Reason(BaseModelConfiguration): + code: Optional[ReasonCodeType] = None + description: Optional[StrictStr] = Field( + default=None, description="A textual description of the reason." + ) + sub_code: Optional[ReasonSubCodeType] = None + channel_code: Optional[StrictStr] = Field( + default=None, + description="Error code forwarded directly from the channel. Useful in case of unmapped or channel specific errors. Currently only supported on the WhatsApp channel.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/url_info.py b/sinch/domains/conversation/models/v1/messages/shared/url_info.py new file mode 100644 index 00000000..d0c425b5 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/url_info.py @@ -0,0 +1,12 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class UrlInfo(BaseModelConfiguration): + url: StrictStr = Field(default=..., description="The URL to be referenced") + type: Optional[StrictStr] = Field( + default=None, description="Optional. URL type, e.g. Org or Social" + ) diff --git a/sinch/domains/conversation/models/v1/messages/types/__init__.py b/sinch/domains/conversation/models/v1/messages/types/__init__.py new file mode 100644 index 00000000..facc18bf --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/__init__.py @@ -0,0 +1,121 @@ +from sinch.domains.conversation.models.v1.messages.types.agent_type import ( + AgentType, +) +from sinch.domains.conversation.models.v1.messages.types.channel_specific_message_type import ( + ChannelSpecificMessageType, +) +from sinch.domains.conversation.models.v1.messages.types.conversation_channel_type import ( + ConversationChannelType, +) +from sinch.domains.conversation.models.v1.messages.types.conversation_messages_view_type import ( + ConversationMessagesViewType, +) +from sinch.domains.conversation.models.v1.messages.types.conversation_direction_type import ( + ConversationDirectionType, +) +from sinch.domains.conversation.models.v1.messages.types.processing_mode_type import ( + ProcessingModeType, +) +from sinch.domains.conversation.models.v1.messages.types.card_height_type import ( + CardHeightType, +) +from sinch.domains.conversation.models.v1.messages.types.messages_source_type import ( + MessageSourceType, +) +from sinch.domains.conversation.models.v1.messages.types.payment_order_goods_type import ( + PaymentOrderGoodsType, +) +from sinch.domains.conversation.models.v1.messages.types.payment_order_status_type import ( + PaymentOrderStatusType, +) +from sinch.domains.conversation.models.v1.messages.types.payment_order_type import ( + PaymentOrderType, +) +from sinch.domains.conversation.models.v1.messages.types.pix_key_type import ( + PixKeyType, +) +from sinch.domains.conversation.models.v1.messages.types.reason_code_type import ( + ReasonCodeType, +) +from sinch.domains.conversation.models.v1.messages.types.reason_sub_code_type import ( + ReasonSubCodeType, +) +from sinch.domains.conversation.models.v1.messages.types.whatsapp_interactive_nfm_reply_name_type import ( + WhatsAppInteractiveNfmReplyNameType, +) +from sinch.domains.conversation.models.v1.messages.types.processing_strategy_type import ( + ProcessingStrategyType, +) +from sinch.domains.conversation.models.v1.messages.types.metadata_update_strategy_type import ( + MetadataUpdateStrategyType, +) +from sinch.domains.conversation.models.v1.messages.types.message_queue_type import ( + MessageQueueType, +) +from sinch.domains.conversation.models.v1.messages.types.message_content_type import ( + MessageContentType, +) +from sinch.domains.conversation.models.v1.messages.types.list_message_dict import ( + ListMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.media_properties_dict import ( + MediaPropertiesDict, +) +from sinch.domains.conversation.models.v1.messages.types.card_message_dict import ( + CardMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.carousel_message_dict import ( + CarouselMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.choice_message_dict import ( + ChoiceMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.contact_info_message_dict import ( + ContactInfoMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.location_message_dict import ( + LocationMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.template_message_dict import ( + TemplateMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.recipient_dict import ( + RecipientDict, + ChannelRecipientIdentityDict, +) +from sinch.domains.conversation.models.v1.messages.types.send_message_request_body_dict import ( + SendMessageRequestBodyDict, +) + +__all__ = [ + "AgentType", + "ConversationChannelType", + "ConversationMessagesViewType", + "ConversationDirectionType", + "ProcessingModeType", + "CardHeightType", + "ChannelSpecificMessageType", + "ListMessageDict", + "MediaPropertiesDict", + "CardMessageDict", + "CarouselMessageDict", + "ChoiceMessageDict", + "ContactInfoMessageDict", + "LocationMessageDict", + "TemplateMessageDict", + "RecipientDict", + "ChannelRecipientIdentityDict", + "SendMessageRequestBodyDict", + "MessageSourceType", + "PaymentOrderGoodsType", + "PaymentOrderStatusType", + "PaymentOrderType", + "PixKeyType", + "ReasonCodeType", + "ReasonSubCodeType", + "WhatsAppInteractiveNfmReplyNameType", + "ProcessingStrategyType", + "MetadataUpdateStrategyType", + "MessageQueueType", + "MessageContentType", +] diff --git a/sinch/domains/conversation/models/v1/messages/types/agent_type.py b/sinch/domains/conversation/models/v1/messages/types/agent_type.py new file mode 100644 index 00000000..22f685e2 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/agent_type.py @@ -0,0 +1,5 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +AgentType = Union[Literal["UNKNOWN_AGENT_TYPE", "HUMAN", "BOT"], StrictStr] diff --git a/sinch/domains/conversation/models/v1/messages/types/calendar_message_dict.py b/sinch/domains/conversation/models/v1/messages/types/calendar_message_dict.py new file mode 100644 index 00000000..77e67dc4 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/calendar_message_dict.py @@ -0,0 +1,12 @@ +from datetime import datetime +from typing import TypedDict +from typing_extensions import NotRequired + + +class CalendarMessageDict(TypedDict): + title: str + event_start: datetime + event_end: datetime + event_title: str + fallback_url: str + event_description: NotRequired[str] diff --git a/sinch/domains/conversation/models/v1/messages/types/call_message_dict.py b/sinch/domains/conversation/models/v1/messages/types/call_message_dict.py new file mode 100644 index 00000000..977bfd5a --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/call_message_dict.py @@ -0,0 +1,6 @@ +from typing import TypedDict + + +class CallMessageDict(TypedDict): + phone_number: str + title: str diff --git a/sinch/domains/conversation/models/v1/messages/types/card_height_type.py b/sinch/domains/conversation/models/v1/messages/types/card_height_type.py new file mode 100644 index 00000000..22f16af6 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/card_height_type.py @@ -0,0 +1,7 @@ +from typing import Literal, Union +from pydantic import StrictStr + +CardHeightType = Union[ + Literal["UNSPECIFIED_HEIGHT", "SHORT", "MEDIUM", "TALL"], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/card_message_dict.py b/sinch/domains/conversation/models/v1/messages/types/card_message_dict.py new file mode 100644 index 00000000..0a9803e7 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/card_message_dict.py @@ -0,0 +1,24 @@ +from typing import List, TypedDict +from typing_extensions import NotRequired + +from sinch.domains.conversation.models.v1.messages.types.card_height_type import ( + CardHeightType, +) +from sinch.domains.conversation.models.v1.messages.types.choice_option_dict import ( + ChoiceOptionDict, +) +from sinch.domains.conversation.models.v1.messages.types.media_properties_dict import ( + MediaPropertiesDict, +) +from sinch.domains.conversation.models.v1.messages.types.message_properties_dict import ( + MessagePropertiesDict, +) + + +class CardMessageDict(TypedDict): + choices: NotRequired[List[ChoiceOptionDict]] + description: NotRequired[str] + height: NotRequired[CardHeightType] + title: NotRequired[str] + media_message: NotRequired[MediaPropertiesDict] + message_properties: NotRequired[MessagePropertiesDict] diff --git a/sinch/domains/conversation/models/v1/messages/types/carousel_message_dict.py b/sinch/domains/conversation/models/v1/messages/types/carousel_message_dict.py new file mode 100644 index 00000000..39613be5 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/carousel_message_dict.py @@ -0,0 +1,14 @@ +from typing import List, TypedDict +from typing_extensions import NotRequired + +from sinch.domains.conversation.models.v1.messages.types.card_message_dict import ( + CardMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.choice_option_dict import ( + ChoiceOptionDict, +) + + +class CarouselMessageDict(TypedDict): + cards: List[CardMessageDict] + choices: NotRequired[List[ChoiceOptionDict]] diff --git a/sinch/domains/conversation/models/v1/messages/types/channel_specific_message_type.py b/sinch/domains/conversation/models/v1/messages/types/channel_specific_message_type.py new file mode 100644 index 00000000..b4c41a7c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/channel_specific_message_type.py @@ -0,0 +1,15 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +ChannelSpecificMessageType = Union[ + Literal[ + "FLOWS", + "ORDER_DETAILS", + "ORDER_STATUS", + "COMMERCE", + "CAROUSEL_COMMERCE", + "NOTIFICATION_MESSAGE_TEMPLATE", + ], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/choice_message_dict.py b/sinch/domains/conversation/models/v1/messages/types/choice_message_dict.py new file mode 100644 index 00000000..8bf24db8 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/choice_message_dict.py @@ -0,0 +1,18 @@ +from typing import List, TypedDict +from typing_extensions import NotRequired + +from sinch.domains.conversation.models.v1.messages.types.choice_message_properties_dict import ( + ChoiceMessagePropertiesDict, +) +from sinch.domains.conversation.models.v1.messages.types.choice_option_dict import ( + ChoiceOptionDict, +) +from sinch.domains.conversation.models.v1.messages.types.text_message_dict import ( + TextMessageDict, +) + + +class ChoiceMessageDict(TypedDict): + choices: List[ChoiceOptionDict] + text_message: NotRequired[TextMessageDict] + message_properties: NotRequired[ChoiceMessagePropertiesDict] diff --git a/sinch/domains/conversation/models/v1/messages/types/choice_message_properties_dict.py b/sinch/domains/conversation/models/v1/messages/types/choice_message_properties_dict.py new file mode 100644 index 00000000..db9d6ddd --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/choice_message_properties_dict.py @@ -0,0 +1,11 @@ +from typing import TypedDict +from typing_extensions import NotRequired + + +class ChoiceMessagePropertiesDict(TypedDict): + """ + Additional properties for ChoiceMessage (whatsapp_footer). + CardMessage uses MessagePropertiesDict with whatsapp_header. + """ + + whatsapp_footer: NotRequired[str] diff --git a/sinch/domains/conversation/models/v1/messages/types/choice_option_dict.py b/sinch/domains/conversation/models/v1/messages/types/choice_option_dict.py new file mode 100644 index 00000000..cd957119 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/choice_option_dict.py @@ -0,0 +1,34 @@ +from typing import Any, TypedDict +from typing_extensions import NotRequired + +from sinch.domains.conversation.models.v1.messages.types.calendar_message_dict import ( + CalendarMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.call_message_dict import ( + CallMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.location_message_dict import ( + LocationMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.share_location_message_dict import ( + ShareLocationMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.text_message_dict import ( + TextMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.url_message_dict import ( + UrlMessageDict, +) + + +class ChoiceOptionDict(TypedDict): + # Optional metadata returned back to you as postback + postback_data: NotRequired[Any] + + # Exactly one of the following keys is expected per choice: + call_message: NotRequired[CallMessageDict] + location_message: NotRequired[LocationMessageDict] + text_message: NotRequired[TextMessageDict] + url_message: NotRequired[UrlMessageDict] + calendar_message: NotRequired[CalendarMessageDict] + share_location_message: NotRequired[ShareLocationMessageDict] diff --git a/sinch/domains/conversation/models/v1/messages/types/contact_info_message_dict.py b/sinch/domains/conversation/models/v1/messages/types/contact_info_message_dict.py new file mode 100644 index 00000000..e98b0c96 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/contact_info_message_dict.py @@ -0,0 +1,52 @@ +from datetime import date +from typing import List, TypedDict +from typing_extensions import NotRequired + + +class NameInfoDict(TypedDict): + full_name: str + first_name: NotRequired[str] + last_name: NotRequired[str] + middle_name: NotRequired[str] + prefix: NotRequired[str] + suffix: NotRequired[str] + + +class PhoneNumberInfoDict(TypedDict): + phone_number: str + type: NotRequired[str] + + +class AddressInfoDict(TypedDict): + city: NotRequired[str] + country: NotRequired[str] + state: NotRequired[str] + zip: NotRequired[str] + type: NotRequired[str] + country_code: NotRequired[str] + + +class EmailInfoDict(TypedDict): + email_address: str + type: NotRequired[str] + + +class OrganizationInfoDict(TypedDict): + company: NotRequired[str] + department: NotRequired[str] + title: NotRequired[str] + + +class UrlInfoDict(TypedDict): + url: str + type: NotRequired[str] + + +class ContactInfoMessageDict(TypedDict): + name: NameInfoDict + phone_numbers: List[PhoneNumberInfoDict] + addresses: NotRequired[List[AddressInfoDict]] + email_addresses: NotRequired[List[EmailInfoDict]] + organization: NotRequired[OrganizationInfoDict] + urls: NotRequired[List[UrlInfoDict]] + birthday: NotRequired[date] diff --git a/sinch/domains/conversation/models/v1/messages/types/conversation_channel_type.py b/sinch/domains/conversation/models/v1/messages/types/conversation_channel_type.py new file mode 100644 index 00000000..27d46a48 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/conversation_channel_type.py @@ -0,0 +1,21 @@ +from typing import Literal, Union +from pydantic import StrictStr + +ConversationChannelType = Union[ + Literal[ + "WHATSAPP", + "RCS", + "SMS", + "MESSENGER", + "VIBERBM", + "MMS", + "INSTAGRAM", + "TELEGRAM", + "KAKAOTALK", + "KAKAOTALKCHAT", + "LINE", + "WECHAT", + "APPLEBC", + ], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/conversation_direction_type.py b/sinch/domains/conversation/models/v1/messages/types/conversation_direction_type.py new file mode 100644 index 00000000..d072fe29 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/conversation_direction_type.py @@ -0,0 +1,7 @@ +from typing import Literal, Union +from pydantic import StrictStr + +ConversationDirectionType = Union[ + Literal["TO_APP", "TO_CONTACT"], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/conversation_messages_view_type.py b/sinch/domains/conversation/models/v1/messages/types/conversation_messages_view_type.py new file mode 100644 index 00000000..643df25f --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/conversation_messages_view_type.py @@ -0,0 +1,8 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +ConversationMessagesViewType = Union[ + Literal["WITH_METADATA", "WITHOUT_METADATA"], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/coordinates_dict.py b/sinch/domains/conversation/models/v1/messages/types/coordinates_dict.py new file mode 100644 index 00000000..99eb060f --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/coordinates_dict.py @@ -0,0 +1,6 @@ +from typing import TypedDict, Union + + +class CoordinatesDict(TypedDict): + latitude: Union[int, float] + longitude: Union[int, float] diff --git a/sinch/domains/conversation/models/v1/messages/types/list_message_dict.py b/sinch/domains/conversation/models/v1/messages/types/list_message_dict.py new file mode 100644 index 00000000..0783d4bb --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/list_message_dict.py @@ -0,0 +1,51 @@ +from typing import List, TypedDict, Union +from typing_extensions import NotRequired + +from sinch.domains.conversation.models.v1.messages.types.media_properties_dict import ( + MediaPropertiesDict, +) + + +class ChoiceItemDict(TypedDict): + title: str + description: NotRequired[str] + media: NotRequired[MediaPropertiesDict] + postback_data: NotRequired[str] + + +class ProductItemDict(TypedDict): + id: str + marketplace: str + quantity: NotRequired[int] + item_price: NotRequired[Union[int, float]] + currency: NotRequired[str] + + +class ListItemChoiceDict(TypedDict): + choice: ChoiceItemDict + + +class ListItemProductDict(TypedDict): + product: ProductItemDict + + +ListItemDict = Union[ListItemChoiceDict, ListItemProductDict] + + +class ListSectionDict(TypedDict): + items: List[ListItemDict] + title: NotRequired[str] + + +class ListMessagePropertiesDict(TypedDict): + catalog_id: NotRequired[str] + menu: NotRequired[str] + whatsapp_header: NotRequired[str] + + +class ListMessageDict(TypedDict): + title: str + sections: List[ListSectionDict] + description: NotRequired[str] + media: NotRequired[MediaPropertiesDict] + message_properties: NotRequired[ListMessagePropertiesDict] diff --git a/sinch/domains/conversation/models/v1/messages/types/location_message_dict.py b/sinch/domains/conversation/models/v1/messages/types/location_message_dict.py new file mode 100644 index 00000000..38e945fb --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/location_message_dict.py @@ -0,0 +1,12 @@ +from typing import TypedDict +from typing_extensions import NotRequired + +from sinch.domains.conversation.models.v1.messages.types.coordinates_dict import ( + CoordinatesDict, +) + + +class LocationMessageDict(TypedDict): + coordinates: CoordinatesDict + title: str + label: NotRequired[str] diff --git a/sinch/domains/conversation/models/v1/messages/types/media_properties_dict.py b/sinch/domains/conversation/models/v1/messages/types/media_properties_dict.py new file mode 100644 index 00000000..b55181aa --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/media_properties_dict.py @@ -0,0 +1,8 @@ +from typing import TypedDict +from typing_extensions import NotRequired + + +class MediaPropertiesDict(TypedDict): + url: str + thumbnail_url: NotRequired[str] + filename_override: NotRequired[str] diff --git a/sinch/domains/conversation/models/v1/messages/types/message_content_type.py b/sinch/domains/conversation/models/v1/messages/types/message_content_type.py new file mode 100644 index 00000000..1b7058ea --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/message_content_type.py @@ -0,0 +1,7 @@ +from typing import Literal, Union +from pydantic import StrictStr + +MessageContentType = Union[ + Literal["CONTENT_UNKNOWN", "CONTENT_MARKETING", "CONTENT_NOTIFICATION"], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/message_properties_dict.py b/sinch/domains/conversation/models/v1/messages/types/message_properties_dict.py new file mode 100644 index 00000000..f7ab8036 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/message_properties_dict.py @@ -0,0 +1,6 @@ +from typing import TypedDict +from typing_extensions import NotRequired + + +class MessagePropertiesDict(TypedDict): + whatsapp_header: NotRequired[str] diff --git a/sinch/domains/conversation/models/v1/messages/types/message_queue_type.py b/sinch/domains/conversation/models/v1/messages/types/message_queue_type.py new file mode 100644 index 00000000..f7f4a28f --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/message_queue_type.py @@ -0,0 +1,7 @@ +from typing import Literal, Union +from pydantic import StrictStr + +MessageQueueType = Union[ + Literal["NORMAL_PRIORITY", "HIGH_PRIORITY"], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/messages_source_type.py b/sinch/domains/conversation/models/v1/messages/types/messages_source_type.py new file mode 100644 index 00000000..5acd391c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/messages_source_type.py @@ -0,0 +1,7 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +MessageSourceType = Union[ + Literal["CONVERSATION_SOURCE", "DISPATCH_SOURCE"], StrictStr +] diff --git a/sinch/domains/conversation/models/v1/messages/types/metadata_update_strategy_type.py b/sinch/domains/conversation/models/v1/messages/types/metadata_update_strategy_type.py new file mode 100644 index 00000000..94fb09b1 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/metadata_update_strategy_type.py @@ -0,0 +1,7 @@ +from typing import Literal, Union +from pydantic import StrictStr + +MetadataUpdateStrategyType = Union[ + Literal["REPLACE", "MERGE_PATCH"], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/payment_order_goods_type.py b/sinch/domains/conversation/models/v1/messages/types/payment_order_goods_type.py new file mode 100644 index 00000000..e6d83ef0 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/payment_order_goods_type.py @@ -0,0 +1,7 @@ +from typing import Literal, Union +from pydantic import StrictStr + +PaymentOrderGoodsType = Union[ + Literal["digital-goods", "physical-goods"], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/payment_order_status_type.py b/sinch/domains/conversation/models/v1/messages/types/payment_order_status_type.py new file mode 100644 index 00000000..cc66258e --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/payment_order_status_type.py @@ -0,0 +1,15 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +PaymentOrderStatusType = Union[ + Literal[ + "pending", + "processing", + "partially-shipped", + "shipped", + "completed", + "canceled", + ], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/payment_order_type.py b/sinch/domains/conversation/models/v1/messages/types/payment_order_type.py new file mode 100644 index 00000000..43454f75 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/payment_order_type.py @@ -0,0 +1,7 @@ +from typing import Literal, Union +from pydantic import StrictStr + +PaymentOrderType = Union[ + Literal["br", "sg"], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/pix_key_type.py b/sinch/domains/conversation/models/v1/messages/types/pix_key_type.py new file mode 100644 index 00000000..14aff004 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/pix_key_type.py @@ -0,0 +1,7 @@ +from typing import Literal, Union +from pydantic import StrictStr + +PixKeyType = Union[ + Literal["CPF", "CNPJ", "EMAIL", "PHONE", "EVP"], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/processing_mode_type.py b/sinch/domains/conversation/models/v1/messages/types/processing_mode_type.py new file mode 100644 index 00000000..4dd66473 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/processing_mode_type.py @@ -0,0 +1,7 @@ +from typing import Literal, Union +from pydantic import StrictStr + +ProcessingModeType = Union[ + Literal["CONVERSATION", "DISPATCH"], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/processing_strategy_type.py b/sinch/domains/conversation/models/v1/messages/types/processing_strategy_type.py new file mode 100644 index 00000000..8bb2311e --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/processing_strategy_type.py @@ -0,0 +1,7 @@ +from typing import Literal, Union +from pydantic import StrictStr + +ProcessingStrategyType = Union[ + Literal["DEFAULT", "DISPATCH_ONLY"], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/reason_code_type.py b/sinch/domains/conversation/models/v1/messages/types/reason_code_type.py new file mode 100644 index 00000000..80cd430c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/reason_code_type.py @@ -0,0 +1,36 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +ReasonCodeType = Union[ + Literal[ + "UNKNOWN", + "INTERNAL_ERROR", + "RATE_LIMITED", + "RECIPIENT_INVALID_CHANNEL_IDENTITY", + "RECIPIENT_NOT_REACHABLE", + "RECIPIENT_NOT_OPTED_IN", + "OUTSIDE_ALLOWED_SENDING_WINDOW", + "CHANNEL_FAILURE", + "CHANNEL_BAD_CONFIGURATION", + "CHANNEL_CONFIGURATION_MISSING", + "MEDIA_TYPE_UNSUPPORTED", + "MEDIA_TOO_LARGE", + "MEDIA_NOT_REACHABLE", + "NO_CHANNELS_LEFT", + "TEMPLATE_NOT_FOUND", + "TEMPLATE_INSUFFICIENT_PARAMETERS", + "TEMPLATE_NON_EXISTING_LANGUAGE_OR_VERSION", + "DELIVERY_TIMED_OUT", + "DELIVERY_REJECTED_DUE_TO_POLICY", + "CONTACT_NOT_FOUND", + "BAD_REQUEST", + "UNKNOWN_APP", + "NO_CHANNEL_IDENTITY_FOR_CONTACT", + "CHANNEL_REJECT", + "NO_PERMISSION", + "NO_PROFILE_AVAILABLE", + "UNSUPPORTED_OPERATION", + ], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/reason_sub_code_type.py b/sinch/domains/conversation/models/v1/messages/types/reason_sub_code_type.py new file mode 100644 index 00000000..011402d3 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/reason_sub_code_type.py @@ -0,0 +1,13 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +ReasonSubCodeType = Union[ + Literal[ + "UNSPECIFIED_SUB_CODE", + "ATTACHMENT_REJECTED", + "MEDIA_TYPE_UNDETERMINED", + "INACTIVE_SENDER", + ], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/recipient_dict.py b/sinch/domains/conversation/models/v1/messages/types/recipient_dict.py new file mode 100644 index 00000000..936c2187 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/recipient_dict.py @@ -0,0 +1,20 @@ +from typing import List, TypedDict, Union +from sinch.domains.conversation.models.v1.messages.types.conversation_channel_type import ( + ConversationChannelType, +) + + +class ChannelRecipientIdentityDict(TypedDict): + channel: ConversationChannelType + identity: str + + +class RecipientIdentifiedByDict(TypedDict): + channel_identities: List[ChannelRecipientIdentityDict] + + +class RecipientContactIdDict(TypedDict): + contact_id: str + + +RecipientDict = Union[RecipientIdentifiedByDict, RecipientContactIdDict] diff --git a/sinch/domains/conversation/models/v1/messages/types/send_message_request_body_dict.py b/sinch/domains/conversation/models/v1/messages/types/send_message_request_body_dict.py new file mode 100644 index 00000000..1f0de272 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/send_message_request_body_dict.py @@ -0,0 +1,46 @@ +from typing import TypedDict +from typing_extensions import NotRequired +from sinch.domains.conversation.models.v1.messages.types.card_message_dict import ( + CardMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.carousel_message_dict import ( + CarouselMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.choice_message_dict import ( + ChoiceMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.contact_info_message_dict import ( + ContactInfoMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.list_message_dict import ( + ListMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.location_message_dict import ( + LocationMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.media_properties_dict import ( + MediaPropertiesDict, +) +from sinch.domains.conversation.models.v1.messages.types.template_message_dict import ( + TemplateMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.text_message_dict import ( + TextMessageDict, +) + + +class SendMessageRequestBodyDict(TypedDict, total=False): + """ + TypedDict for the message body in send message requests. + At least one message type must be provided. + """ + + text_message: NotRequired[TextMessageDict] + card_message: NotRequired[CardMessageDict] + carousel_message: NotRequired[CarouselMessageDict] + choice_message: NotRequired[ChoiceMessageDict] + contact_info_message: NotRequired[ContactInfoMessageDict] + list_message: NotRequired[ListMessageDict] + location_message: NotRequired[LocationMessageDict] + media_message: NotRequired[MediaPropertiesDict] + template_message: NotRequired[TemplateMessageDict] diff --git a/sinch/domains/conversation/models/v1/messages/types/share_location_message_dict.py b/sinch/domains/conversation/models/v1/messages/types/share_location_message_dict.py new file mode 100644 index 00000000..5c4b975c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/share_location_message_dict.py @@ -0,0 +1,6 @@ +from typing import TypedDict + + +class ShareLocationMessageDict(TypedDict): + title: str + fallback_url: str diff --git a/sinch/domains/conversation/models/v1/messages/types/template_message_dict.py b/sinch/domains/conversation/models/v1/messages/types/template_message_dict.py new file mode 100644 index 00000000..2782a6ea --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/template_message_dict.py @@ -0,0 +1,20 @@ +from typing import Dict, TypedDict +from typing_extensions import NotRequired + + +class TemplateReferenceChannelSpecificDict(TypedDict): + template_id: str + version: NotRequired[str] + language_code: NotRequired[str] + parameters: NotRequired[Dict[str, str]] + + +class TemplateReferenceOmniChannelDict(TemplateReferenceChannelSpecificDict): + version: str + + +class TemplateMessageDict(TypedDict): + channel_template: NotRequired[ + Dict[str, TemplateReferenceChannelSpecificDict] + ] + omni_template: NotRequired[TemplateReferenceOmniChannelDict] diff --git a/sinch/domains/conversation/models/v1/messages/types/text_message_dict.py b/sinch/domains/conversation/models/v1/messages/types/text_message_dict.py new file mode 100644 index 00000000..f3c3330a --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/text_message_dict.py @@ -0,0 +1,5 @@ +from typing import TypedDict + + +class TextMessageDict(TypedDict): + text: str diff --git a/sinch/domains/conversation/models/v1/messages/types/url_message_dict.py b/sinch/domains/conversation/models/v1/messages/types/url_message_dict.py new file mode 100644 index 00000000..cb289c25 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/url_message_dict.py @@ -0,0 +1,6 @@ +from typing import TypedDict + + +class UrlMessageDict(TypedDict): + title: str + url: str diff --git a/sinch/domains/conversation/models/v1/messages/types/whatsapp_interactive_nfm_reply_name_type.py b/sinch/domains/conversation/models/v1/messages/types/whatsapp_interactive_nfm_reply_name_type.py new file mode 100644 index 00000000..08ed9f48 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/whatsapp_interactive_nfm_reply_name_type.py @@ -0,0 +1,7 @@ +from typing import Literal, Union +from pydantic import StrictStr + +WhatsAppInteractiveNfmReplyNameType = Union[ + Literal["flow", "address_message"], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/sinch_events/__init__.py b/sinch/domains/conversation/models/v1/sinch_events/__init__.py new file mode 100644 index 00000000..3e83f5b9 --- /dev/null +++ b/sinch/domains/conversation/models/v1/sinch_events/__init__.py @@ -0,0 +1,39 @@ +from sinch.domains.conversation.models.v1.sinch_events.events.conversation_sinch_event_payload import ( + ConversationSinchEventPayload, +) +from sinch.domains.conversation.models.v1.sinch_events.events.conversation_sinch_event_base import ( + ConversationSinchEventBase, +) +from sinch.domains.conversation.models.v1.sinch_events.events.delivery_status_type import ( + DeliveryStatusType, +) +from sinch.domains.conversation.models.v1.sinch_events.events.inbound_message import ( + InboundMessage, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_delivery_receipt_event import ( + MessageDeliveryReceiptEvent, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_delivery_report import ( + MessageDeliveryReport, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_inbound_event import ( + MessageInboundEvent, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_submit_event import ( + MessageSubmitEvent, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_submit_notification import ( + MessageSubmitNotification, +) + +__all__ = [ + "ConversationSinchEventPayload", + "ConversationSinchEventBase", + "InboundMessage", + "MessageDeliveryReceiptEvent", + "MessageDeliveryReport", + "DeliveryStatusType", + "MessageInboundEvent", + "MessageSubmitEvent", + "MessageSubmitNotification", +] diff --git a/sinch/domains/conversation/models/v1/sinch_events/events/conversation_sinch_event_base.py b/sinch/domains/conversation/models/v1/sinch_events/events/conversation_sinch_event_base.py new file mode 100644 index 00000000..114da8b5 --- /dev/null +++ b/sinch/domains/conversation/models/v1/sinch_events/events/conversation_sinch_event_base.py @@ -0,0 +1,35 @@ +from datetime import datetime +from typing import Optional + +from pydantic import Field, StrictStr + +from sinch.domains.conversation.sinch_events.v1.internal import SinchEvent + + +class ConversationSinchEventBase(SinchEvent): + """Base fields present on every Conversation API Sinch Event 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/sinch_events/events/conversation_sinch_event_payload.py b/sinch/domains/conversation/models/v1/sinch_events/events/conversation_sinch_event_payload.py new file mode 100644 index 00000000..71e9bce0 --- /dev/null +++ b/sinch/domains/conversation/models/v1/sinch_events/events/conversation_sinch_event_payload.py @@ -0,0 +1,22 @@ +from typing import Union + +from sinch.domains.conversation.models.v1.sinch_events.events.conversation_sinch_event_base import ( + ConversationSinchEventBase, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_delivery_receipt_event import ( + MessageDeliveryReceiptEvent, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_inbound_event import ( + MessageInboundEvent, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_submit_event import ( + MessageSubmitEvent, +) + + +ConversationSinchEventPayload = Union[ + MessageDeliveryReceiptEvent, + MessageInboundEvent, + MessageSubmitEvent, + ConversationSinchEventBase, +] diff --git a/sinch/domains/conversation/models/v1/sinch_events/events/delivery_status_type.py b/sinch/domains/conversation/models/v1/sinch_events/events/delivery_status_type.py new file mode 100644 index 00000000..a220270b --- /dev/null +++ b/sinch/domains/conversation/models/v1/sinch_events/events/delivery_status_type.py @@ -0,0 +1,14 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +DeliveryStatusType = Union[ + Literal[ + "QUEUED_ON_CHANNEL", + "DELIVERED", + "READ", + "FAILED", + "SWITCHING_CHANNEL", + ], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/sinch_events/events/inbound_message.py b/sinch/domains/conversation/models/v1/sinch_events/events/inbound_message.py new file mode 100644 index 00000000..422e2cc9 --- /dev/null +++ b/sinch/domains/conversation/models/v1/sinch_events/events/inbound_message.py @@ -0,0 +1,20 @@ +from typing import Optional + +from pydantic import Field + +from sinch.domains.conversation.sinch_events.v1.internal import SinchEvent +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, SinchEvent): + """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/sinch_events/events/message_delivery_receipt_event.py b/sinch/domains/conversation/models/v1/sinch_events/events/message_delivery_receipt_event.py new file mode 100644 index 00000000..6c344458 --- /dev/null +++ b/sinch/domains/conversation/models/v1/sinch_events/events/message_delivery_receipt_event.py @@ -0,0 +1,17 @@ +from pydantic import Field + +from sinch.domains.conversation.models.v1.sinch_events.events.conversation_sinch_event_base import ( + ConversationSinchEventBase, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_delivery_report import ( + MessageDeliveryReport, +) + + +class MessageDeliveryReceiptEvent(ConversationSinchEventBase): + """Sinch 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/sinch_events/events/message_delivery_report.py b/sinch/domains/conversation/models/v1/sinch_events/events/message_delivery_report.py new file mode 100644 index 00000000..43f1ae2c --- /dev/null +++ b/sinch/domains/conversation/models/v1/sinch_events/events/message_delivery_report.py @@ -0,0 +1,53 @@ +from typing import Optional + +from pydantic import Field, StrictStr + +from sinch.domains.conversation.sinch_events.v1.internal import SinchEvent +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.sinch_events.events.delivery_status_type import ( + DeliveryStatusType, +) + + +class MessageDeliveryReport(SinchEvent): + """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[DeliveryStatusType] = 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/sinch_events/events/message_inbound_event.py b/sinch/domains/conversation/models/v1/sinch_events/events/message_inbound_event.py new file mode 100644 index 00000000..540ddd49 --- /dev/null +++ b/sinch/domains/conversation/models/v1/sinch_events/events/message_inbound_event.py @@ -0,0 +1,16 @@ +from pydantic import Field + +from sinch.domains.conversation.models.v1.sinch_events.events.conversation_sinch_event_base import ( + ConversationSinchEventBase, +) +from sinch.domains.conversation.models.v1.sinch_events.events.inbound_message import ( + InboundMessage, +) + + +class MessageInboundEvent(ConversationSinchEventBase): + """Sinch Event for MESSAGE_INBOUND (inbound message from user).""" + + message: InboundMessage = Field( + description="The inbound message payload.", + ) diff --git a/sinch/domains/conversation/models/v1/sinch_events/events/message_submit_event.py b/sinch/domains/conversation/models/v1/sinch_events/events/message_submit_event.py new file mode 100644 index 00000000..fe7026c9 --- /dev/null +++ b/sinch/domains/conversation/models/v1/sinch_events/events/message_submit_event.py @@ -0,0 +1,16 @@ +from pydantic import Field + +from sinch.domains.conversation.models.v1.sinch_events.events.conversation_sinch_event_base import ( + ConversationSinchEventBase, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_submit_notification import ( + MessageSubmitNotification, +) + + +class MessageSubmitEvent(ConversationSinchEventBase): + """Sinch 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/sinch_events/events/message_submit_notification.py b/sinch/domains/conversation/models/v1/sinch_events/events/message_submit_notification.py new file mode 100644 index 00000000..ba09c56b --- /dev/null +++ b/sinch/domains/conversation/models/v1/sinch_events/events/message_submit_notification.py @@ -0,0 +1,47 @@ +from typing import Optional + +from pydantic import Field, StrictStr + +from sinch.domains.conversation.sinch_events.v1.internal import SinchEvent +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(SinchEvent): + """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/models/webhook/__init__.py b/sinch/domains/conversation/models/webhook/__init__.py deleted file mode 100644 index d9c33544..00000000 --- a/sinch/domains/conversation/models/webhook/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -from dataclasses import dataclass - -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class ConversationWebhook(SinchBaseModel): - id: str - app_id: str - target: str - target_type: str - secret: str - triggers: list - client_credentials: dict diff --git a/sinch/domains/conversation/models/webhook/requests.py b/sinch/domains/conversation/models/webhook/requests.py deleted file mode 100644 index 3195648b..00000000 --- a/sinch/domains/conversation/models/webhook/requests.py +++ /dev/null @@ -1,39 +0,0 @@ -from dataclasses import dataclass - -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class WebhookRequest(SinchRequestBaseModel): - app_id: str - target: str - triggers: list - client_credentials: dict - secret: str - target_type: str - - -@dataclass -class CreateConversationWebhookRequest(WebhookRequest): - pass - - -@dataclass -class ListConversationWebhookRequest(SinchRequestBaseModel): - app_id: str - - -@dataclass -class GetConversationWebhookRequest(SinchRequestBaseModel): - webhook_id: str - - -@dataclass -class DeleteConversationWebhookRequest(SinchRequestBaseModel): - webhook_id: str - - -@dataclass -class UpdateConversationWebhookRequest(WebhookRequest): - update_mask: str - webhook_id: str diff --git a/sinch/domains/conversation/models/webhook/responses.py b/sinch/domains/conversation/models/webhook/responses.py deleted file mode 100644 index 9255bebf..00000000 --- a/sinch/domains/conversation/models/webhook/responses.py +++ /dev/null @@ -1,30 +0,0 @@ -from dataclasses import dataclass -from typing import List - -from sinch.domains.conversation.models.webhook import ConversationWebhook -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class CreateWebhookResponse(ConversationWebhook): - pass - - -@dataclass -class UpdateWebhookResponse(ConversationWebhook): - pass - - -@dataclass -class SinchListWebhooksResponse(SinchBaseModel): - webhooks: List[ConversationWebhook] - - -@dataclass -class GetWebhookResponse(ConversationWebhook): - pass - - -@dataclass -class SinchDeleteWebhookResponse(SinchBaseModel): - pass diff --git a/sinch/domains/conversation/sinch_events/v1/__init__.py b/sinch/domains/conversation/sinch_events/v1/__init__.py new file mode 100644 index 00000000..93471398 --- /dev/null +++ b/sinch/domains/conversation/sinch_events/v1/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.conversation.sinch_events.v1.conversation_sinch_event import ( + ConversationSinchEvent, +) + +__all__ = ["ConversationSinchEvent"] diff --git a/sinch/domains/conversation/sinch_events/v1/conversation_sinch_event.py b/sinch/domains/conversation/sinch_events/v1/conversation_sinch_event.py new file mode 100644 index 00000000..45acda9b --- /dev/null +++ b/sinch/domains/conversation/sinch_events/v1/conversation_sinch_event.py @@ -0,0 +1,117 @@ +import logging +from typing import Any, Dict, Union, Optional +from sinch.domains.authentication.sinch_events.v1.authentication_validation import ( + validate_sinch_event_signature_with_nonce, +) +from sinch.domains.authentication.sinch_events.v1.sinch_event_utils import ( + decode_payload, + parse_json, + normalize_iso_timestamp, +) +from sinch.domains.conversation.models.v1.sinch_events import ( + ConversationSinchEventBase, + ConversationSinchEventPayload, + MessageDeliveryReceiptEvent, + MessageInboundEvent, + MessageSubmitEvent, +) + + +logger = logging.getLogger(__name__) + + +class ConversationSinchEvent: + """ + Handler for Conversation API Sinch Events: validate signature and parse events. + """ + + def __init__(self, callback_secret: Optional[str] = None): + """ + :param callback_secret: Secret configured for the event destination (used for HMAC validation). + """ + self.callback_secret = callback_secret + + def _validate_signature( + self, + payload: Union[str, bytes], + headers: Dict[str, str], + callback_secret: Optional[str] = None, + ) -> bool: + """ + Validate the Sinch Event 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 callback_secret: Secret for this request; defaults to the secret passed to __init__. + :returns: True if the signature is valid, False otherwise. + """ + secret = ( + callback_secret + if callback_secret is not None + else self.callback_secret + ) + if not secret: + return False + payload_str = decode_payload(payload, headers) + return validate_sinch_event_signature_with_nonce( + secret, headers, payload_str + ) + + def validate_authentication_header( + self, + headers: Dict[str, str], + json_payload: Union[str, bytes], + ) -> bool: + """ + Validate the Sinch Event signature (convenience wrapper around internal validation). + + :param headers: Incoming request's headers. + :param json_payload: Incoming request's raw body (str or bytes). + :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, bytes, Dict[str, Any]], + headers: Optional[Dict[str, str]] = None, + ) -> ConversationSinchEventPayload: + """ + Parse the Sinch Event 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, raw bytes, or dict of the event body. + :param headers: Request headers (used to decode charset when event_body is bytes). + :returns: Parsed event model. + :raises ValueError: If JSON parsing fails or the payload is invalid. + """ + if isinstance(event_body, bytes): + event_body = parse_json(decode_payload(event_body, headers)) + elif isinstance(event_body, str): + event_body = parse_json(event_body) + + # Normalize timestamp fields + for key in ("accepted_time", "event_time"): + if key in event_body and isinstance(event_body[key], str): + event_body[key] = normalize_iso_timestamp(event_body[key]) + + # Type is determined by which key is present (message_delivery_report, message, + # message_submit_notification). + if "message_delivery_report" in event_body: + return MessageDeliveryReceiptEvent(**event_body) + if "message" in event_body: + return MessageInboundEvent(**event_body) + if "message_submit_notification" in event_body: + return MessageSubmitEvent(**event_body) + + logger.warning( + "Conversation Sinch Event: unknown event type; returning base event." + ) + return ConversationSinchEventBase(**event_body) diff --git a/sinch/domains/conversation/sinch_events/v1/internal/__init__.py b/sinch/domains/conversation/sinch_events/v1/internal/__init__.py new file mode 100644 index 00000000..cd400379 --- /dev/null +++ b/sinch/domains/conversation/sinch_events/v1/internal/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.conversation.sinch_events.v1.internal.sinch_event import ( + SinchEvent, +) + +__all__ = ["SinchEvent"] diff --git a/sinch/domains/conversation/sinch_events/v1/internal/sinch_event.py b/sinch/domains/conversation/sinch_events/v1/internal/sinch_event.py new file mode 100644 index 00000000..22eefc3d --- /dev/null +++ b/sinch/domains/conversation/sinch_events/v1/internal/sinch_event.py @@ -0,0 +1,9 @@ +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class SinchEvent(BaseModelConfiguration): + """Base model for Conversation API Sinch Event payloads.""" + + pass diff --git a/sinch/domains/number_lookup/__init__.py b/sinch/domains/number_lookup/__init__.py new file mode 100644 index 00000000..315d37f1 --- /dev/null +++ b/sinch/domains/number_lookup/__init__.py @@ -0,0 +1,3 @@ +from sinch.domains.number_lookup.api.v1.number_lookup_apis import NumberLookup + +__all__ = ["NumberLookup"] diff --git a/sinch/domains/number_lookup/api/v1/__init__.py b/sinch/domains/number_lookup/api/v1/__init__.py new file mode 100644 index 00000000..83c137df --- /dev/null +++ b/sinch/domains/number_lookup/api/v1/__init__.py @@ -0,0 +1,3 @@ +from sinch.domains.number_lookup.api.v1.internal import LookupNumberEndpoint + +__all__ = ["LookupNumberEndpoint"] diff --git a/sinch/domains/number_lookup/api/v1/base/__init__.py b/sinch/domains/number_lookup/api/v1/base/__init__.py new file mode 100644 index 00000000..4ea698a7 --- /dev/null +++ b/sinch/domains/number_lookup/api/v1/base/__init__.py @@ -0,0 +1,3 @@ +from sinch.domains.number_lookup.api.v1.base.base_lookup import BaseLookup + +__all__ = ["BaseLookup"] diff --git a/sinch/domains/number_lookup/api/v1/base/base_lookup.py b/sinch/domains/number_lookup/api/v1/base/base_lookup.py new file mode 100644 index 00000000..bdb97fdf --- /dev/null +++ b/sinch/domains/number_lookup/api/v1/base/base_lookup.py @@ -0,0 +1,26 @@ +class BaseLookup: + """Base class for handling Sinch Lookup operations.""" + + def __init__(self, sinch): + self._sinch = sinch + + def _request(self, endpoint_class, request_data): + """ + A helper method to make requests to endpoints. + + Args: + endpoint_class: The endpoint class to call. + request_data: The request data to pass to the endpoint. + + Returns: + The response from the Sinch transport request. + """ + if not self._sinch.configuration.project_id: + raise ValueError("project_id is required for Lookup API") + + return self._sinch.configuration.transport.request( + endpoint_class( + project_id=self._sinch.configuration.project_id, + request_data=request_data, + ) + ) diff --git a/sinch/domains/number_lookup/api/v1/internal/__init__.py b/sinch/domains/number_lookup/api/v1/internal/__init__.py new file mode 100644 index 00000000..151449ef --- /dev/null +++ b/sinch/domains/number_lookup/api/v1/internal/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.number_lookup.api.v1.internal.number_lookup_endpoints import ( + LookupNumberEndpoint, +) + +__all__ = [ + "LookupNumberEndpoint", +] diff --git a/sinch/domains/number_lookup/api/v1/internal/base/__init__.py b/sinch/domains/number_lookup/api/v1/internal/base/__init__.py new file mode 100644 index 00000000..4f1459d9 --- /dev/null +++ b/sinch/domains/number_lookup/api/v1/internal/base/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.number_lookup.api.v1.internal.base.lookup_endpoint import ( + LookupEndpoint, +) + +__all__ = [ + "LookupEndpoint", +] diff --git a/sinch/domains/number_lookup/api/v1/internal/base/lookup_endpoint.py b/sinch/domains/number_lookup/api/v1/internal/base/lookup_endpoint.py new file mode 100644 index 00000000..c3923f90 --- /dev/null +++ b/sinch/domains/number_lookup/api/v1/internal/base/lookup_endpoint.py @@ -0,0 +1,19 @@ +from abc import ABC +from sinch.core.models.http_response import HTTPResponse +from sinch.core.endpoint import HTTPEndpoint +from sinch.domains.number_lookup.exceptions import NumberLookupException + + +class LookupEndpoint(HTTPEndpoint, ABC): + def __init__(self, project_id: str, request_data): + super().__init__(project_id, request_data) + + def handle_response(self, response: HTTPResponse): + if response.status_code >= 400: + error_message = f"Error {response.status_code}" + + raise NumberLookupException( + message=error_message, + response=response, + is_from_server=True, + ) diff --git a/sinch/domains/number_lookup/api/v1/internal/number_lookup_endpoints.py b/sinch/domains/number_lookup/api/v1/internal/number_lookup_endpoints.py new file mode 100644 index 00000000..03dac66c --- /dev/null +++ b/sinch/domains/number_lookup/api/v1/internal/number_lookup_endpoints.py @@ -0,0 +1,51 @@ +import json +from typing import Type +from sinch.core.enums import HTTPAuthentication, HTTPMethods +from sinch.core.models.http_response import HTTPResponse +from sinch.core.types import BM +from sinch.domains.number_lookup.api.v1.internal.base import LookupEndpoint +from sinch.domains.number_lookup.exceptions import NumberLookupException +from sinch.domains.number_lookup.models.v1.internal import LookupNumberRequest +from sinch.domains.number_lookup.models.v1.response import LookupNumberResponse + + +class LookupNumberEndpoint(LookupEndpoint): + ENDPOINT_URL = "{origin}/v2/projects/{project_id}/lookups" + HTTP_METHOD = HTTPMethods.POST.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: LookupNumberRequest): + super(LookupNumberEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def build_url(self, sinch) -> str: + return self.ENDPOINT_URL.format( + origin=sinch.configuration.number_lookup_origin, + project_id=self.project_id, + ) + + def request_body(self) -> str: + request_data = self.request_data.model_dump( + by_alias=True, exclude_none=True + ) + return json.dumps(request_data) + + def process_response_model( + self, response_body: dict, response_model: Type[BM] + ) -> BM: + try: + return response_model.model_validate(response_body) + except Exception as e: + raise ValueError(f"Invalid response structure: {e}") from e + + def handle_response(self, response: HTTPResponse) -> LookupNumberResponse: + try: + super(LookupNumberEndpoint, self).handle_response(response) + except NumberLookupException as e: + raise NumberLookupException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, LookupNumberResponse) diff --git a/sinch/domains/number_lookup/api/v1/number_lookup_apis.py b/sinch/domains/number_lookup/api/v1/number_lookup_apis.py new file mode 100644 index 00000000..afb2f949 --- /dev/null +++ b/sinch/domains/number_lookup/api/v1/number_lookup_apis.py @@ -0,0 +1,42 @@ +from typing import Optional, List +from sinch.domains.number_lookup.api.v1.base import BaseLookup +from sinch.domains.number_lookup.api.v1.internal import LookupNumberEndpoint +from sinch.domains.number_lookup.models.v1.internal import LookupNumberRequest +from sinch.domains.number_lookup.models.v1.response import LookupNumberResponse +from sinch.domains.number_lookup.models.v1.types import ( + RndFeatureOptionsDict, + LookupFeaturesType, +) + + +class NumberLookup(BaseLookup): + def lookup( + self, + number: str, + features: Optional[List[LookupFeaturesType]] = None, + rnd_feature_options: Optional[RndFeatureOptionsDict] = None, + **kwargs, + ) -> LookupNumberResponse: + """ + Performs a number lookup. + You can make a minimal request or add additional options to the features array. + + :param number: MSISDN in E.164 format to query (e.g., "+12312312312") + :type number: str + :param features: List of requested features. Options: "LineType", "SimSwap", "VoIPDetection", "RND" + :type features: Optional[List[str]] + :param rnd_feature_options: Optional dictionary with RND feature options + :type rnd_feature_options: Optional[RndFeatureOptionsDict] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: LookupNumberResponse + :rtype: LookupNumberResponse + """ + request_data = LookupNumberRequest( + number=number, + features=features, + rnd_feature_options=rnd_feature_options, + **kwargs, + ) + return self._request(LookupNumberEndpoint, request_data) diff --git a/sinch/domains/number_lookup/exceptions.py b/sinch/domains/number_lookup/exceptions.py new file mode 100644 index 00000000..8b77f573 --- /dev/null +++ b/sinch/domains/number_lookup/exceptions.py @@ -0,0 +1,5 @@ +from sinch.core.exceptions import SinchException + + +class NumberLookupException(SinchException): + pass diff --git a/sinch/domains/numbers/endpoints/available/__init__.py b/sinch/domains/number_lookup/models/__init__.py similarity index 100% rename from sinch/domains/numbers/endpoints/available/__init__.py rename to sinch/domains/number_lookup/models/__init__.py diff --git a/sinch/domains/number_lookup/models/v1/internal/__init__.py b/sinch/domains/number_lookup/models/v1/internal/__init__.py new file mode 100644 index 00000000..6d3e609a --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/internal/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.number_lookup.models.v1.internal.lookup_number_request import ( + LookupNumberRequest, +) + +__all__ = [ + "LookupNumberRequest", +] diff --git a/sinch/domains/number_lookup/models/v1/internal/base/__init__.py b/sinch/domains/number_lookup/models/v1/internal/base/__init__.py new file mode 100644 index 00000000..96762760 --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/internal/base/__init__.py @@ -0,0 +1,9 @@ +from sinch.domains.number_lookup.models.v1.internal.base.base_model_configuration import ( + BaseModelConfigurationRequest, + BaseModelConfigurationResponse, +) + +__all__ = [ + "BaseModelConfigurationRequest", + "BaseModelConfigurationResponse", +] diff --git a/sinch/domains/number_lookup/models/v1/internal/base/base_model_configuration.py b/sinch/domains/number_lookup/models/v1/internal/base/base_model_configuration.py new file mode 100644 index 00000000..204ea49d --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/internal/base/base_model_configuration.py @@ -0,0 +1,44 @@ +import re +from typing import Any +from pydantic import BaseModel, ConfigDict + + +class BaseModelConfigurationRequest(BaseModel): + """ + A base model that allows extra fields and converts snake_case to camelCase. + """ + + model_config = ConfigDict( + # Allows using both alias (camelCase) and field name (snake_case) + populate_by_name=True, + # Allows extra values in input + extra="allow", + ) + + +class BaseModelConfigurationResponse(BaseModel): + """ + A base model that allows extra fields and converts camelCase to snake_case + """ + + @staticmethod + def _to_snake_case(camel_str: str) -> str: + """Helper to convert camelCase string to snake_case.""" + return re.sub(r"(? None: + """Converts unknown fields from camelCase to snake_case.""" + if self.__pydantic_extra__: + converted_extra = { + self._to_snake_case(key): value + for key, value in self.__pydantic_extra__.items() + } + self.__pydantic_extra__.clear() + self.__pydantic_extra__.update(converted_extra) diff --git a/sinch/domains/number_lookup/models/v1/internal/lookup_number_request.py b/sinch/domains/number_lookup/models/v1/internal/lookup_number_request.py new file mode 100644 index 00000000..c3ddc3bc --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/internal/lookup_number_request.py @@ -0,0 +1,26 @@ +from typing import Optional, Dict, Any +from pydantic import Field, StrictStr, conlist, field_serializer +from sinch.core.models.utils import serialize_datetime_in_dict +from sinch.domains.number_lookup.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) +from sinch.domains.number_lookup.models.v1.types import LookupFeaturesType + + +class LookupNumberRequest(BaseModelConfigurationRequest): + number: StrictStr = Field( + ..., description="MSISDN in E.164 format to query" + ) + features: Optional[conlist(LookupFeaturesType)] = Field( + default=None, + description="Contains requested features. Fallback to LineType if not provided.", + ) + rnd_feature_options: Optional[Dict[str, Any]] = Field( + default=None, alias="rndFeatureOptions" + ) + + @field_serializer("rnd_feature_options") + def serialize_rnd_feature_options( + self, value: Optional[Dict[str, Any]] + ) -> Optional[Dict[str, Any]]: + return serialize_datetime_in_dict(value) diff --git a/sinch/domains/number_lookup/models/v1/response/__init__.py b/sinch/domains/number_lookup/models/v1/response/__init__.py new file mode 100644 index 00000000..827a74dc --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/response/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.number_lookup.models.v1.response.lookup_number_response import ( + LookupNumberResponse, +) + +__all__ = [ + "LookupNumberResponse", +] diff --git a/sinch/domains/number_lookup/models/v1/response/lookup_number_response.py b/sinch/domains/number_lookup/models/v1/response/lookup_number_response.py new file mode 100644 index 00000000..e01e4175 --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/response/lookup_number_response.py @@ -0,0 +1,25 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.number_lookup.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.number_lookup.models.v1.shared import ( + Line, + SimSwap, + VoIPDetection, + Rnd, +) + + +class LookupNumberResponse(BaseModelConfigurationResponse): + line: Optional[Line] = None + sim_swap: Optional[SimSwap] = Field(default=None, alias="simSwap") + voip_detection: Optional[VoIPDetection] = Field( + default=None, alias="voIPDetection" + ) + rnd: Optional[Rnd] = None + country_code: Optional[StrictStr] = Field( + default=None, alias="countryCode" + ) + trace_id: Optional[StrictStr] = Field(default=None, alias="traceId") + number: Optional[StrictStr] = None diff --git a/sinch/domains/number_lookup/models/v1/shared/__init__.py b/sinch/domains/number_lookup/models/v1/shared/__init__.py new file mode 100644 index 00000000..f6faeaa1 --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/shared/__init__.py @@ -0,0 +1,17 @@ +from sinch.domains.number_lookup.models.v1.shared.line import Line +from sinch.domains.number_lookup.models.v1.shared.sim_swap import SimSwap +from sinch.domains.number_lookup.models.v1.shared.voip_detection import ( + VoIPDetection, +) +from sinch.domains.number_lookup.models.v1.shared.rnd import Rnd +from sinch.domains.number_lookup.models.v1.shared.lookup_error import ( + LookupError, +) + +__all__ = [ + "Line", + "SimSwap", + "VoIPDetection", + "Rnd", + "LookupError", +] diff --git a/sinch/domains/number_lookup/models/v1/shared/line.py b/sinch/domains/number_lookup/models/v1/shared/line.py new file mode 100644 index 00000000..d822f24b --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/shared/line.py @@ -0,0 +1,23 @@ +from datetime import datetime +from typing import Optional +from pydantic import Field, StrictBool, StrictStr +from sinch.domains.number_lookup.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.number_lookup.models.v1.shared.lookup_error import ( + LookupError, +) + + +class Line(BaseModelConfigurationResponse): + carrier: Optional[StrictStr] = None + type: Optional[StrictStr] = None + mobile_country_code: Optional[StrictStr] = Field( + default=None, alias="mobileCountryCode" + ) + mobile_network_code: Optional[StrictStr] = Field( + default=None, alias="mobileNetworkCode" + ) + ported: Optional[StrictBool] = None + porting_date: Optional[datetime] = Field(default=None, alias="portingDate") + error: Optional[LookupError] = None diff --git a/sinch/domains/number_lookup/models/v1/shared/lookup_error.py b/sinch/domains/number_lookup/models/v1/shared/lookup_error.py new file mode 100644 index 00000000..514ce3e3 --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/shared/lookup_error.py @@ -0,0 +1,12 @@ +from typing import Optional +from pydantic import StrictStr, StrictInt +from sinch.domains.number_lookup.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class LookupError(BaseModelConfigurationResponse): + status: Optional[StrictInt] = None + title: Optional[StrictStr] = None + detail: Optional[StrictStr] = None + type: Optional[StrictStr] = None diff --git a/sinch/domains/number_lookup/models/v1/shared/rnd.py b/sinch/domains/number_lookup/models/v1/shared/rnd.py new file mode 100644 index 00000000..750a83b7 --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/shared/rnd.py @@ -0,0 +1,13 @@ +from typing import Optional +from pydantic import StrictBool +from sinch.domains.number_lookup.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.number_lookup.models.v1.shared.lookup_error import ( + LookupError, +) + + +class Rnd(BaseModelConfigurationResponse): + disconnected: Optional[StrictBool] = None + error: Optional[LookupError] = None diff --git a/sinch/domains/number_lookup/models/v1/shared/sim_swap.py b/sinch/domains/number_lookup/models/v1/shared/sim_swap.py new file mode 100644 index 00000000..0b65b507 --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/shared/sim_swap.py @@ -0,0 +1,17 @@ +from typing import Optional +from pydantic import Field, StrictBool +from sinch.domains.number_lookup.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.number_lookup.models.v1.shared.lookup_error import ( + LookupError, +) +from sinch.domains.number_lookup.models.v1.types import SwapPeriodType + + +class SimSwap(BaseModelConfigurationResponse): + swapped: Optional[StrictBool] = None + swap_period: Optional[SwapPeriodType] = Field( + default=None, alias="swapPeriod" + ) + error: Optional[LookupError] = None diff --git a/sinch/domains/number_lookup/models/v1/shared/voip_detection.py b/sinch/domains/number_lookup/models/v1/shared/voip_detection.py new file mode 100644 index 00000000..6809130c --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/shared/voip_detection.py @@ -0,0 +1,13 @@ +from typing import Optional +from sinch.domains.number_lookup.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.number_lookup.models.v1.shared.lookup_error import ( + LookupError, +) +from sinch.domains.number_lookup.models.v1.types import VoIPProbabilityType + + +class VoIPDetection(BaseModelConfigurationResponse): + probability: Optional[VoIPProbabilityType] = None + error: Optional[LookupError] = None diff --git a/sinch/domains/number_lookup/models/v1/types/__init__.py b/sinch/domains/number_lookup/models/v1/types/__init__.py new file mode 100644 index 00000000..aebdf4e1 --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/types/__init__.py @@ -0,0 +1,19 @@ +from sinch.domains.number_lookup.models.v1.types.lookup_features import ( + LookupFeaturesType, +) +from sinch.domains.number_lookup.models.v1.types.rnd_feature_options import ( + RndFeatureOptionsDict, +) +from sinch.domains.number_lookup.models.v1.types.voip_probability import ( + VoIPProbabilityType, +) +from sinch.domains.number_lookup.models.v1.types.swap_period import ( + SwapPeriodType, +) + +__all__ = [ + "LookupFeaturesType", + "RndFeatureOptionsDict", + "VoIPProbabilityType", + "SwapPeriodType", +] diff --git a/sinch/domains/number_lookup/models/v1/types/lookup_features.py b/sinch/domains/number_lookup/models/v1/types/lookup_features.py new file mode 100644 index 00000000..7dd6f70c --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/types/lookup_features.py @@ -0,0 +1,7 @@ +from typing import Union, Literal +from pydantic import StrictStr + + +LookupFeaturesType = Union[ + Literal["LineType", "SimSwap", "VoIPDetection", "RND"], StrictStr +] diff --git a/sinch/domains/number_lookup/models/v1/types/rnd_feature_options.py b/sinch/domains/number_lookup/models/v1/types/rnd_feature_options.py new file mode 100644 index 00000000..5ad2cced --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/types/rnd_feature_options.py @@ -0,0 +1,7 @@ +from datetime import datetime +from typing import TypedDict, Union +from typing_extensions import NotRequired + + +class RndFeatureOptionsDict(TypedDict): + contact_date: NotRequired[Union[str, datetime]] diff --git a/sinch/domains/number_lookup/models/v1/types/swap_period.py b/sinch/domains/number_lookup/models/v1/types/swap_period.py new file mode 100644 index 00000000..6c6d7f0d --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/types/swap_period.py @@ -0,0 +1,19 @@ +from typing import Union, Literal +from pydantic import StrictStr + + +SwapPeriodType = Union[ + Literal[ + "Undefined", + "SP4H", + "SP12H", + "SP24H", + "SP48H", + "SP5D", + "SP7D", + "SP14D", + "SP30D", + "SPMAX", + ], + StrictStr, +] diff --git a/sinch/domains/number_lookup/models/v1/types/voip_probability.py b/sinch/domains/number_lookup/models/v1/types/voip_probability.py new file mode 100644 index 00000000..16c7f4bc --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/types/voip_probability.py @@ -0,0 +1,7 @@ +from typing import Union, Literal +from pydantic import StrictStr + + +VoIPProbabilityType = Union[ + Literal["Unknown", "High", "Likely", "Low"], StrictStr +] diff --git a/sinch/domains/numbers/__init__.py b/sinch/domains/numbers/__init__.py index 9dc430d5..0411ea02 100644 --- a/sinch/domains/numbers/__init__.py +++ b/sinch/domains/numbers/__init__.py @@ -1,332 +1,3 @@ -from sinch.core.pagination import TokenBasedPaginator, AsyncTokenBasedPaginator -from sinch.domains.numbers.endpoints.available.search_for_number import SearchForNumberEndpoint -from sinch.domains.numbers.endpoints.available.list_available_numbers import AvailableNumbersEndpoint -from sinch.domains.numbers.endpoints.available.activate_number import ActivateNumberEndpoint -from sinch.domains.numbers.endpoints.available.rent_any_number import RentAnyNumberEndpoint -from sinch.domains.numbers.endpoints.callbacks.get_configuration import GetNumbersCallbackConfigurationEndpoint -from sinch.domains.numbers.endpoints.callbacks.update_configuration import UpdateNumbersCallbackConfigurationEndpoint +from sinch.domains.numbers.virtual_numbers import VirtualNumbers -from sinch.domains.numbers.endpoints.active.list_active_numbers_for_project import ListActiveNumbersEndpoint -from sinch.domains.numbers.endpoints.active.update_number_configuration import UpdateNumberConfigurationEndpoint -from sinch.domains.numbers.endpoints.active.get_number_configuration import GetNumberConfigurationEndpoint -from sinch.domains.numbers.endpoints.active.release_number_from_project import ReleaseNumberFromProjectEndpoint -from sinch.domains.numbers.endpoints.regions.list_available_regions import ListAvailableRegionsEndpoint - -from sinch.domains.numbers.models.regions.requests import ListAvailableRegionsForProjectRequest -from sinch.domains.numbers.models.active.requests import ( - ListActiveNumbersRequest, GetNumberConfigurationRequest, - UpdateNumberConfigurationRequest, ReleaseNumberFromProjectRequest -) -from sinch.domains.numbers.models.available.requests import ( - ListAvailableNumbersRequest, ActivateNumberRequest, - CheckNumberAvailabilityRequest, RentAnyNumberRequest -) -from sinch.domains.numbers.models.regions.responses import ListAvailableRegionsResponse -from sinch.domains.numbers.models.available.responses import ( - ListAvailableNumbersResponse, ActivateNumberResponse, - CheckNumberAvailabilityResponse -) -from sinch.domains.numbers.models.active.responses import ( - ListActiveNumbersResponse, UpdateNumberConfigurationResponse, - GetNumberConfigurationResponse, ReleaseNumberFromProjectResponse -) -from sinch.domains.numbers.models.callbacks.responses import ( - GetNumbersCallbackConfigurationResponse, - UpdateNumbersCallbackConfigurationResponse -) -from sinch.domains.numbers.models.callbacks.requests import ( - UpdateNumbersCallbackConfigurationRequest -) - - -class AvailableNumbers: - def __init__(self, sinch): - self._sinch = sinch - - def list( - self, - region_code: str, - number_type: str, - number_pattern: str = None, - number_search_pattern: str = None, - capabilities: list = None, - page_size: int = None - ) -> ListAvailableNumbersResponse: - """ - Search for available virtual numbers using a variety of parameters to filter results. - For additional documentation, see https://www.sinch.com and visit our developer portal. - """ - return self._sinch.configuration.transport.request( - AvailableNumbersEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ListAvailableNumbersRequest( - region_code=region_code, - number_type=number_type, - page_size=page_size, - capabilities=capabilities, - number_search_pattern=number_search_pattern, - number_pattern=number_pattern - ) - ) - ) - - def activate( - self, - phone_number: str, - sms_configuration: dict = None, - voice_configuration: dict = None - ) -> ActivateNumberResponse: - """ - Activate a virtual number to use with SMS products, Voice products, or both. - For additional documentation, see https://www.sinch.com and visit our developer portal. - """ - return self._sinch.configuration.transport.request( - ActivateNumberEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ActivateNumberRequest( - phone_number=phone_number, - sms_configuration=sms_configuration, - voice_configuration=voice_configuration - ) - ) - ) - - def rent_any( - self, - region_code: str, - type_: str, - number_pattern: str = None, - capabilities: list = None, - sms_configuration: dict = None, - voice_configuration: dict = None, - callback_url: str = None - ) -> RentAnyNumberRequest: - return self._sinch.configuration.transport.request( - RentAnyNumberEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=RentAnyNumberRequest( - region_code=region_code, - type_=type_, - number_pattern=number_pattern, - capabilities=capabilities, - sms_configuration=sms_configuration, - voice_configuration=voice_configuration, - callback_url=callback_url - ) - ) - ) - - def check_availability(self, phone_number: str) -> CheckNumberAvailabilityResponse: - """ - Enter a specific phone number to check availability. - For additional documentation, see https://www.sinch.com and visit our developer portal. - """ - return self._sinch.configuration.transport.request( - SearchForNumberEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=CheckNumberAvailabilityRequest( - phone_number=phone_number - ) - ) - ) - - -class ActiveNumbers: - def __init__(self, sinch): - self._sinch = sinch - - def list( - self, - region_code: str, - number_type: str, - number_pattern: str = None, - number_search_pattern: str = None, - capabilities: list = None, - page_size: int = None, - page_token: str = None - ) -> ListActiveNumbersResponse: - """ - Search for all active virtual numbers associated with a certain project. - For additional documentation, see https://www.sinch.com and visit our developer portal. - """ - return TokenBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListActiveNumbersEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ListActiveNumbersRequest( - region_code=region_code, - number_type=number_type, - page_size=page_size, - capabilities=capabilities, - number_pattern=number_pattern, - number_search_pattern=number_search_pattern, - page_token=page_token - ) - ) - ) - - def update( - self, - phone_number: str = None, - display_name: str = None, - sms_configuration: dict = None, - voice_configuration: dict = None, - app_id: str = None - ) -> UpdateNumberConfigurationResponse: - """ - Make updates to the configuration of your virtual number. - Update the display name, change the currency type, or reconfigure for either SMS and/or Voice. - For additional documentation, see https://www.sinch.com and visit our developer portal. - """ - return self._sinch.configuration.transport.request( - UpdateNumberConfigurationEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=UpdateNumberConfigurationRequest( - phone_number=phone_number, - display_name=display_name, - sms_configuration=sms_configuration, - voice_configuration=voice_configuration, - app_id=app_id - ) - ) - ) - - def get(self, phone_number: str) -> GetNumberConfigurationResponse: - """ - List of configuration settings for your virtual number. - For additional documentation, see https://www.sinch.com and visit our developer portal. - """ - return self._sinch.configuration.transport.request( - GetNumberConfigurationEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=GetNumberConfigurationRequest( - phone_number=phone_number - ) - ) - ) - - def release(self, phone_number: str) -> ReleaseNumberFromProjectResponse: - """ - Release numbers you no longer need from your project. - For additional documentation, see https://www.sinch.com and visit our developer portal. - """ - return self._sinch.configuration.transport.request( - ReleaseNumberFromProjectEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ReleaseNumberFromProjectRequest( - phone_number=phone_number - ) - ) - ) - - -class ActiveNumbersWithAsyncPagination(ActiveNumbers): - async def list( - self, - region_code: str, - number_type: str, - number_pattern: str = None, - number_search_pattern: str = None, - capabilities: list = None, - page_size: int = None, - page_token: str = None - ) -> ListActiveNumbersResponse: - return await AsyncTokenBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListActiveNumbersEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ListActiveNumbersRequest( - region_code=region_code, - number_type=number_type, - page_size=page_size, - capabilities=capabilities, - number_pattern=number_pattern, - number_search_pattern=number_search_pattern, - page_token=page_token - ) - ) - ) - - -class AvailableRegions: - def __init__(self, sinch): - self._sinch = sinch - - def list( - self, - number_type: str = None, - number_types: list = None - ) -> ListAvailableRegionsResponse: - """ - Lists all regions for numbers provided using the project ID. - Some numbers can be configured for multiple regions. - See which regions apply to your virtual number. - For additional documentation, see https://www.sinch.com and visit our developer portal. - """ - return self._sinch.configuration.transport.request( - ListAvailableRegionsEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ListAvailableRegionsForProjectRequest( - number_type=number_type, - number_types=number_types - ) - ) - ) - - -class Callbacks: - def __init__(self, sinch): - self._sinch = sinch - - def get_configuration(self) -> GetNumbersCallbackConfigurationResponse: - return self._sinch.configuration.transport.request( - GetNumbersCallbackConfigurationEndpoint( - project_id=self._sinch.configuration.project_id - ) - ) - - def update_configuration(self, hmac_secret) -> UpdateNumbersCallbackConfigurationResponse: - return self._sinch.configuration.transport.request( - UpdateNumbersCallbackConfigurationEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=UpdateNumbersCallbackConfigurationRequest( - hmac_secret=hmac_secret - ) - ) - ) - - -class NumbersBase: - """ - Documentation for Sinch virtual Numbers is found at https://developers.sinch.com/docs/numbers/. - """ - def __init__(self, sinch): - self._sinch = sinch - - -class Numbers(NumbersBase): - """ - Synchronous version of the Numbers Domain - """ - __doc__ += NumbersBase.__doc__ - - def __init__(self, sinch): - super(Numbers, self).__init__(sinch) - self.available = AvailableNumbers(self._sinch) - self.regions = AvailableRegions(self._sinch) - self.active = ActiveNumbers(self._sinch) - self.callbacks = Callbacks(self._sinch) - - -class NumbersAsync(NumbersBase): - """ - Asynchronous version of the Numbers Domain - """ - __doc__ += NumbersBase.__doc__ - - def __init__(self, sinch): - super(NumbersAsync, self).__init__(sinch) - self.available = AvailableNumbers(self._sinch) - self.regions = AvailableRegions(self._sinch) - self.active = ActiveNumbersWithAsyncPagination(self._sinch) - self.callbacks = Callbacks(self._sinch) +__all__ = ["VirtualNumbers"] diff --git a/sinch/domains/numbers/endpoints/callbacks/__init__.py b/sinch/domains/numbers/api/__init__.py similarity index 100% rename from sinch/domains/numbers/endpoints/callbacks/__init__.py rename to sinch/domains/numbers/api/__init__.py diff --git a/sinch/domains/numbers/api/v1/__init__.py b/sinch/domains/numbers/api/v1/__init__.py new file mode 100644 index 00000000..79f11071 --- /dev/null +++ b/sinch/domains/numbers/api/v1/__init__.py @@ -0,0 +1,18 @@ +from sinch.domains.numbers.api.v1.active_numbers_apis import ActiveNumbers +from sinch.domains.numbers.api.v1.available_numbers_apis import ( + AvailableNumbers, +) +from sinch.domains.numbers.api.v1.available_regions_apis import ( + AvailableRegions, +) +from sinch.domains.numbers.api.v1.event_destinations_apis import ( + EventDestinations, +) + + +__all__ = [ + "ActiveNumbers", + "AvailableNumbers", + "AvailableRegions", + "EventDestinations", +] diff --git a/sinch/domains/numbers/api/v1/active_numbers_apis.py b/sinch/domains/numbers/api/v1/active_numbers_apis.py new file mode 100644 index 00000000..ef10ed1c --- /dev/null +++ b/sinch/domains/numbers/api/v1/active_numbers_apis.py @@ -0,0 +1,83 @@ +from typing import Optional, List +from sinch.core.pagination import TokenBasedPaginator, Paginator +from sinch.domains.numbers.api.v1.base import BaseNumbers +from sinch.domains.numbers.api.v1.internal import ( + GetNumberConfigurationEndpoint, + ListActiveNumbersEndpoint, + ReleaseNumberFromProjectEndpoint, + UpdateNumberConfigurationEndpoint, +) +from sinch.domains.numbers.models.v1.response import ActiveNumber + +from sinch.domains.numbers.models.v1.internal import ( + ListActiveNumbersRequest, + NumberRequest, + UpdateNumberConfigurationRequest, +) +from sinch.domains.numbers.models.v1.types import ( + CapabilityType, + NumberSearchPatternType, + NumberType, + OrderByType, + SmsConfigurationDict, + VoiceConfigurationDict, +) + + +class ActiveNumbers(BaseNumbers): + def list( + self, + region_code: Optional[str] = None, + number_type: Optional[NumberType] = None, + number_pattern: Optional[str] = None, + number_search_pattern: Optional[NumberSearchPatternType] = None, + capabilities: Optional[List[CapabilityType]] = None, + page_size: Optional[int] = None, + page_token: Optional[str] = None, + order_by: Optional[OrderByType] = None, + **kwargs, + ) -> Paginator[ActiveNumber]: + return TokenBasedPaginator( + sinch=self._sinch, + endpoint=ListActiveNumbersEndpoint( + project_id=self._sinch.configuration.project_id, + request_data=ListActiveNumbersRequest( + region_code=region_code, + number_type=number_type, + page_size=page_size, + capabilities=capabilities, + number_pattern=number_pattern, + number_search_pattern=number_search_pattern, + page_token=page_token, + order_by=order_by, + **kwargs, + ), + ), + ) + + def update( + self, + phone_number: str, + display_name: Optional[str] = None, + sms_configuration: Optional[SmsConfigurationDict] = None, + voice_configuration: Optional[VoiceConfigurationDict] = None, + event_destination_target: Optional[str] = None, + **kwargs, + ) -> ActiveNumber: + request_data = UpdateNumberConfigurationRequest( + phone_number=phone_number, + display_name=display_name, + sms_configuration=sms_configuration, + voice_configuration=voice_configuration, + event_destination_target=event_destination_target, + **kwargs, + ) + return self._request(UpdateNumberConfigurationEndpoint, request_data) + + def get(self, phone_number: str, **kwargs) -> ActiveNumber: + request_data = NumberRequest(phone_number=phone_number, **kwargs) + return self._request(GetNumberConfigurationEndpoint, request_data) + + def release(self, phone_number: str, **kwargs) -> ActiveNumber: + request_data = NumberRequest(phone_number=phone_number, **kwargs) + return self._request(ReleaseNumberFromProjectEndpoint, request_data) diff --git a/sinch/domains/numbers/api/v1/available_numbers_apis.py b/sinch/domains/numbers/api/v1/available_numbers_apis.py new file mode 100644 index 00000000..a5538a3f --- /dev/null +++ b/sinch/domains/numbers/api/v1/available_numbers_apis.py @@ -0,0 +1,102 @@ +from typing import Optional, List + +from sinch.core.pagination import Paginator, TokenBasedPaginator +from sinch.domains.numbers.models.v1.response import ( + ActiveNumber, + AvailableNumber, +) +from sinch.domains.numbers.api.v1.base import BaseNumbers +from sinch.domains.numbers.api.v1.internal import ( + AvailableNumbersEndpoint, + RentAnyNumberEndpoint, + RentNumberEndpoint, + SearchForNumberEndpoint, +) +from sinch.domains.numbers.models.v1.internal import ( + ListAvailableNumbersRequest, + NumberRequest, + RentAnyNumberRequest, + RentNumberRequest, +) +from sinch.domains.numbers.models.v1.types import ( + CapabilityType, + NumberPatternDict, + NumberSearchPatternType, + NumberType, + SmsConfigurationDict, + VoiceConfigurationDict, +) + + +class AvailableNumbers(BaseNumbers): + def check_availability( + self, phone_number: str, **kwargs + ) -> AvailableNumber: + request_data = NumberRequest(phone_number=phone_number, **kwargs) + return self._request(SearchForNumberEndpoint, request_data) + + def search_for_available_numbers( + self, + region_code: str, + number_type: NumberType, + number_pattern: Optional[str] = None, + number_search_pattern: Optional[NumberSearchPatternType] = None, + capabilities: Optional[List[CapabilityType]] = None, + page_size: Optional[int] = None, + **kwargs, + ) -> Paginator[AvailableNumber]: + return TokenBasedPaginator( + sinch=self._sinch, + endpoint=AvailableNumbersEndpoint( + project_id=self._sinch.configuration.project_id, + request_data=ListAvailableNumbersRequest( + region_code=region_code, + number_type=number_type, + page_size=page_size, + capabilities=capabilities, + number_pattern=number_pattern, + number_search_pattern=number_search_pattern, + **kwargs, + ), + ), + ) + + def rent( + self, + phone_number: str, + sms_configuration: Optional[SmsConfigurationDict] = None, + voice_configuration: Optional[VoiceConfigurationDict] = None, + event_destination_target: Optional[str] = None, + **kwargs, + ) -> ActiveNumber: + request_data = RentNumberRequest( + phone_number=phone_number, + sms_configuration=sms_configuration, + voice_configuration=voice_configuration, + event_destination_target=event_destination_target, + **kwargs, + ) + return self._request(RentNumberEndpoint, request_data) + + def rent_any( + self, + region_code: str, + number_type: NumberType, + number_pattern: Optional[NumberPatternDict] = None, + capabilities: Optional[List[CapabilityType]] = None, + sms_configuration: Optional[SmsConfigurationDict] = None, + voice_configuration: Optional[VoiceConfigurationDict] = None, + event_destination_target: Optional[str] = None, + **kwargs, + ) -> ActiveNumber: + request_data = RentAnyNumberRequest( + region_code=region_code, + number_type=number_type, + number_pattern=number_pattern, + capabilities=capabilities, + sms_configuration=sms_configuration, + voice_configuration=voice_configuration, + event_destination_target=event_destination_target, + **kwargs, + ) + return self._request(RentAnyNumberEndpoint, request_data) diff --git a/sinch/domains/numbers/api/v1/available_regions_apis.py b/sinch/domains/numbers/api/v1/available_regions_apis.py new file mode 100644 index 00000000..5ba441e7 --- /dev/null +++ b/sinch/domains/numbers/api/v1/available_regions_apis.py @@ -0,0 +1,42 @@ +from typing import Optional, List +from sinch.core.pagination import TokenBasedPaginator, Paginator +from sinch.domains.numbers.api.v1.internal import ListAvailableRegionsEndpoint +from sinch.domains.numbers.models.v1.internal import ( + ListAvailableRegionsRequest, +) +from sinch.domains.numbers.models.v1.response import AvailableRegion +from sinch.domains.numbers.models.v1.types import NumberType + + +class AvailableRegions: + def __init__(self, sinch): + self._sinch = sinch + + def list( + self, types: Optional[List[NumberType]] = None, **kwargs + ) -> Paginator[AvailableRegion]: + """ + Lists all regions for numbers provided using the project ID. + Some numbers can be configured for multiple regions. + See which regions apply to your virtual number. + + :param types: List of number types to filter the regions. + :type types: Optional[List[NumberType]] + + :param kwargs: Additional parameters for the request. + :type kwargs: Optional[dict] + + :return: A paginator object containing the list of available regions. + :rtype: Paginator[Region] + + For additional documentation, see https://www.sinch.com and visit our developer portal. + """ + return TokenBasedPaginator( + sinch=self._sinch, + endpoint=ListAvailableRegionsEndpoint( + project_id=self._sinch.configuration.project_id, + request_data=ListAvailableRegionsRequest( + types=types, **kwargs + ), + ), + ) diff --git a/sinch/domains/numbers/api/v1/base/__init__.py b/sinch/domains/numbers/api/v1/base/__init__.py new file mode 100644 index 00000000..94295842 --- /dev/null +++ b/sinch/domains/numbers/api/v1/base/__init__.py @@ -0,0 +1,3 @@ +from sinch.domains.numbers.api.v1.base.base_numbers import BaseNumbers + +__all__ = ["BaseNumbers"] diff --git a/sinch/domains/numbers/api/v1/base/base_numbers.py b/sinch/domains/numbers/api/v1/base/base_numbers.py new file mode 100644 index 00000000..af60fd32 --- /dev/null +++ b/sinch/domains/numbers/api/v1/base/base_numbers.py @@ -0,0 +1,23 @@ +class BaseNumbers: + """Base class for handling Sinch Number operations.""" + + def __init__(self, sinch): + self._sinch = sinch + + def _request(self, endpoint_class, request_data): + """ + A helper method to make requests to endpoints. + + Args: + endpoint_class: The endpoint class to call. + request_data: The request data to pass to the endpoint. + + Returns: + The response from the Sinch transport request. + """ + return self._sinch.configuration.transport.request( + endpoint_class( + project_id=self._sinch.configuration.project_id, + request_data=request_data, + ) + ) diff --git a/sinch/domains/numbers/api/v1/event_destinations_apis.py b/sinch/domains/numbers/api/v1/event_destinations_apis.py new file mode 100644 index 00000000..e84a8056 --- /dev/null +++ b/sinch/domains/numbers/api/v1/event_destinations_apis.py @@ -0,0 +1,53 @@ +from sinch.domains.numbers.api.v1.base import BaseNumbers +from sinch.domains.numbers.api.v1.internal import ( + GetEventDestinationEndpoint, + UpdateEventDestinationEndpoint, +) +from sinch.domains.numbers.models.v1.internal import ( + UpdateEventDestinationRequest, +) +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) +from sinch.domains.numbers.models.v1.response import ( + EventDestinationResponse, +) + + +class EventDestinations(BaseNumbers): + def get(self, **kwargs) -> EventDestinationResponse: + """ + Returns the event destination configuration for the specified project + + :param kwargs: Additional parameters for the request. + :type kwargs: dict + + :returns: The event destination configuration for the project. + :rtype: EventDestinationResponse + + For detailed documentation, visit: https://developers.sinch.com + """ + request_data = None + if kwargs: + request_data = BaseModelConfigurationRequest(**kwargs) + return self._request(GetEventDestinationEndpoint, request_data) + + def update(self, hmac_secret: str, **kwargs) -> EventDestinationResponse: + """ + Updates the event destination configuration for the specified project + + :param hmac_secret: The HMAC secret used to sign the event destination requests. + :type hmac_secret: str + + :param kwargs: Additional parameters for the request. + :type kwargs: dict + + :returns: The updated event destination configuration for the project. + :rtype: EventDestinationResponse + + For detailed documentation, visit https://developers.sinch.com + """ + request_data = UpdateEventDestinationRequest( + hmac_secret=hmac_secret, **kwargs + ) + return self._request(UpdateEventDestinationEndpoint, request_data) diff --git a/sinch/domains/numbers/exceptions.py b/sinch/domains/numbers/api/v1/exceptions.py similarity index 62% rename from sinch/domains/numbers/exceptions.py rename to sinch/domains/numbers/api/v1/exceptions.py index bb94f383..7a208139 100644 --- a/sinch/domains/numbers/exceptions.py +++ b/sinch/domains/numbers/api/v1/exceptions.py @@ -3,3 +3,7 @@ class NumbersException(SinchException): pass + + +class NumberNotFoundException(NumbersException): + pass diff --git a/sinch/domains/numbers/api/v1/internal/__init__.py b/sinch/domains/numbers/api/v1/internal/__init__.py new file mode 100644 index 00000000..3e42b294 --- /dev/null +++ b/sinch/domains/numbers/api/v1/internal/__init__.py @@ -0,0 +1,33 @@ +from sinch.domains.numbers.api.v1.internal.active_numbers_endpoints import ( + GetNumberConfigurationEndpoint, + ListActiveNumbersEndpoint, + ReleaseNumberFromProjectEndpoint, + UpdateNumberConfigurationEndpoint, +) +from sinch.domains.numbers.api.v1.internal.available_numbers_endpoints import ( + AvailableNumbersEndpoint, + RentAnyNumberEndpoint, + RentNumberEndpoint, + SearchForNumberEndpoint, +) +from sinch.domains.numbers.api.v1.internal.available_regions_endpoints import ( + ListAvailableRegionsEndpoint, +) +from sinch.domains.numbers.api.v1.internal.event_destinations_endpoints import ( + GetEventDestinationEndpoint, + UpdateEventDestinationEndpoint, +) + +__all__ = [ + "AvailableNumbersEndpoint", + "GetEventDestinationEndpoint", + "GetNumberConfigurationEndpoint", + "ListActiveNumbersEndpoint", + "ListAvailableRegionsEndpoint", + "ReleaseNumberFromProjectEndpoint", + "RentNumberEndpoint", + "RentAnyNumberEndpoint", + "SearchForNumberEndpoint", + "UpdateEventDestinationEndpoint", + "UpdateNumberConfigurationEndpoint", +] diff --git a/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py b/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py new file mode 100644 index 00000000..9b31a217 --- /dev/null +++ b/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py @@ -0,0 +1,143 @@ +import json +from sinch.core.enums import HTTPAuthentication, HTTPMethods +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.api.v1.exceptions import ( + NumbersException, + NumberNotFoundException, +) +from sinch.domains.numbers.api.v1.internal.base import NumbersEndpoint +from sinch.domains.numbers.models.v1.internal import ( + ListActiveNumbersRequest, + ListActiveNumbersResponse, + NumberRequest, + UpdateNumberConfigurationRequest, +) +from sinch.domains.numbers.models.v1.response import ActiveNumber + + +class GetNumberConfigurationEndpoint(NumbersEndpoint): + """ + Endpoint to get the configuration of a specific number + """ + + ENDPOINT_URL = ( + "{origin}/v1/projects/{project_id}/activeNumbers/{phone_number}" + ) + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: NumberRequest): + super(GetNumberConfigurationEndpoint, self).__init__( + project_id, request_data + ) + self.project_id = project_id + self.request_data = request_data + + def handle_response(self, response: HTTPResponse) -> ActiveNumber: + try: + super(GetNumberConfigurationEndpoint, self).handle_response( + response + ) + except NumbersException as e: + raise NumberNotFoundException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, ActiveNumber) + + +class ListActiveNumbersEndpoint(NumbersEndpoint): + """ + Endpoint to list all active numbers for a project. + """ + + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/activeNumbers" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__( + self, project_id: str, request_data: ListActiveNumbersRequest + ): + super(ListActiveNumbersEndpoint, self).__init__( + project_id, request_data + ) + self.project_id = project_id + self.request_data = request_data + + def build_query_params(self) -> dict: + return self.request_data.model_dump(exclude_none=True, by_alias=True) + + def handle_response( + self, response: HTTPResponse + ) -> ListActiveNumbersResponse: + super(ListActiveNumbersEndpoint, self).handle_response(response) + return self.process_response_model( + response.body, ListActiveNumbersResponse + ) + + +class ReleaseNumberFromProjectEndpoint(NumbersEndpoint): + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/activeNumbers/{phone_number}:release" + HTTP_METHOD = HTTPMethods.POST.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id, request_data: NumberRequest): + super(ReleaseNumberFromProjectEndpoint, self).__init__( + project_id, request_data + ) + self.project_id = project_id + self.request_data = request_data + + def handle_response(self, response: HTTPResponse) -> ActiveNumber: + try: + super(ReleaseNumberFromProjectEndpoint, self).handle_response( + response + ) + except NumbersException as e: + raise NumberNotFoundException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, ActiveNumber) + + +class UpdateNumberConfigurationEndpoint(NumbersEndpoint): + """ + Endpoint to update the configuration of a specific number + """ + + ENDPOINT_URL = ( + "{origin}/v1/projects/{project_id}/activeNumbers/{phone_number}" + ) + HTTP_METHOD = HTTPMethods.PATCH.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__( + self, project_id: str, request_data: UpdateNumberConfigurationRequest + ): + super(UpdateNumberConfigurationEndpoint, self).__init__( + project_id, request_data + ) + self.project_id = project_id + self.request_data = request_data + + def request_body(self): + request_data = self.request_data.model_dump( + by_alias=True, exclude_none=True + ) + return json.dumps(request_data) + + def handle_response(self, response: HTTPResponse) -> ActiveNumber: + try: + super(UpdateNumberConfigurationEndpoint, self).handle_response( + response + ) + except NumbersException as e: + raise NumberNotFoundException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, ActiveNumber) diff --git a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py new file mode 100644 index 00000000..b9321256 --- /dev/null +++ b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py @@ -0,0 +1,131 @@ +import json +from sinch.core.enums import HTTPAuthentication, HTTPMethods +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.api.v1.exceptions import ( + NumberNotFoundException, + NumbersException, +) +from sinch.domains.numbers.models.v1.internal import ( + ListAvailableNumbersRequest, + ListAvailableNumbersResponse, + NumberRequest, + RentAnyNumberRequest, + RentNumberRequest, +) +from sinch.domains.numbers.models.v1.response import ( + ActiveNumber, + AvailableNumber, +) +from sinch.domains.numbers.api.v1.internal.base import NumbersEndpoint + + +class RentNumberEndpoint(NumbersEndpoint): + """ + Endpoint to rent a virtual number for a project. + """ + + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers/{phone_number}:rent" + HTTP_METHOD = HTTPMethods.POST.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: RentNumberRequest): + super(RentNumberEndpoint, self).__init__(project_id, request_data) + + def request_body(self) -> str: + # Convert the request data to a dictionary and remove None values + request_data = self.request_data.model_dump( + by_alias=True, exclude_none=True + ) + return json.dumps(request_data) + + def handle_response(self, response: HTTPResponse) -> ActiveNumber: + try: + super(RentNumberEndpoint, self).handle_response(response) + except NumbersException as ex: + raise NumberNotFoundException( + message=ex.args[0], + response=ex.http_response, + is_from_server=ex.is_from_server, + ) + return self.process_response_model(response.body, ActiveNumber) + + +class AvailableNumbersEndpoint(NumbersEndpoint): + """ + Endpoint to list available virtual numbers for a project. + """ + + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__( + self, project_id: str, request_data: ListAvailableNumbersRequest + ): + super(AvailableNumbersEndpoint, self).__init__( + project_id, request_data + ) + self.request_data = request_data + + def build_query_params(self) -> dict: + return self.request_data.model_dump(exclude_none=True, by_alias=True) + + def handle_response( + self, response: HTTPResponse + ) -> ListAvailableNumbersResponse: + super(AvailableNumbersEndpoint, self).handle_response(response) + return self.process_response_model( + response.body, ListAvailableNumbersResponse + ) + + +class RentAnyNumberEndpoint(NumbersEndpoint): + """ + Endpoint to rent an available virtual number for a project. + """ + + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers:rentAny" + HTTP_METHOD = HTTPMethods.POST.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: RentAnyNumberRequest): + super(RentAnyNumberEndpoint, self).__init__(project_id, request_data) + self.request_data = request_data + + def request_body(self) -> str: + request_data = self.request_data.model_dump( + by_alias=True, exclude_none=True + ) + return json.dumps(request_data) + + def handle_response(self, response: HTTPResponse) -> ActiveNumber: + error = super(RentAnyNumberEndpoint, self).handle_response(response) + if error: + return error + return self.process_response_model(response.body, ActiveNumber) + + +class SearchForNumberEndpoint(NumbersEndpoint): + """ + Endpoint to check the availability of a virtual number for a project. + """ + + ENDPOINT_URL = ( + "{origin}/v1/projects/{project_id}/availableNumbers/{phone_number}" + ) + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: NumberRequest): + super(SearchForNumberEndpoint, self).__init__(project_id, request_data) + + def handle_response(self, response: HTTPResponse) -> AvailableNumber: + try: + super(SearchForNumberEndpoint, self).handle_response(response) + except NumbersException as e: + raise NumberNotFoundException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, AvailableNumber) diff --git a/sinch/domains/numbers/api/v1/internal/available_regions_endpoints.py b/sinch/domains/numbers/api/v1/internal/available_regions_endpoints.py new file mode 100644 index 00000000..4f957921 --- /dev/null +++ b/sinch/domains/numbers/api/v1/internal/available_regions_endpoints.py @@ -0,0 +1,50 @@ +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.api.v1.exceptions import ( + NumbersException, + NumberNotFoundException, +) +from sinch.domains.numbers.api.v1.internal.base.numbers_endpoint import ( + NumbersEndpoint, +) +from sinch.core.enums import HTTPAuthentication, HTTPMethods +from sinch.domains.numbers.models.v1.internal import ( + ListAvailableRegionsRequest, + ListAvailableRegionsResponse, +) + + +class ListAvailableRegionsEndpoint(NumbersEndpoint): + """ + Endpoint to list all the regions that have numbers assigned to a project + """ + + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableRegions" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__( + self, project_id: str, request_data: ListAvailableRegionsRequest + ): + super(ListAvailableRegionsEndpoint, self).__init__( + project_id, request_data + ) + self.project_id = project_id + self.request_data = request_data + + def build_query_params(self) -> dict: + return self.request_data.model_dump(exclude_none=True, by_alias=True) + + def handle_response( + self, response: HTTPResponse + ) -> ListAvailableRegionsResponse: + try: + super(ListAvailableRegionsEndpoint, self).handle_response(response) + except NumbersException as ex: + raise NumberNotFoundException( + message=ex.args[0], + response=ex.http_response, + is_from_server=ex.is_from_server, + ) + return self.process_response_model( + response.body, ListAvailableRegionsResponse + ) diff --git a/sinch/domains/numbers/api/v1/internal/base/__init__.py b/sinch/domains/numbers/api/v1/internal/base/__init__.py new file mode 100644 index 00000000..8c71684d --- /dev/null +++ b/sinch/domains/numbers/api/v1/internal/base/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.numbers.api.v1.internal.base.numbers_endpoint import ( + NumbersEndpoint, +) + +__all__ = ["NumbersEndpoint"] diff --git a/sinch/domains/numbers/api/v1/internal/base/numbers_endpoint.py b/sinch/domains/numbers/api/v1/internal/base/numbers_endpoint.py new file mode 100644 index 00000000..43b24862 --- /dev/null +++ b/sinch/domains/numbers/api/v1/internal/base/numbers_endpoint.py @@ -0,0 +1,74 @@ +from abc import ABC +from typing import Type +from sinch.core.models.http_response import HTTPResponse +from sinch.core.endpoint import HTTPEndpoint +from sinch.core.types import BM +from sinch.domains.numbers.api.v1.exceptions import NumbersException +from sinch.domains.numbers.models.v1.errors import NotFoundError + + +class NumbersEndpoint(HTTPEndpoint, ABC): + def __init__(self, project_id: str, request_data: BM): + super().__init__(project_id, request_data) + + def build_url(self, sinch) -> str: + if not self.ENDPOINT_URL: + raise NotImplementedError( + "ENDPOINT_URL must be defined in the Numbers endpoint subclass " + ) + + return self.ENDPOINT_URL.format( + origin=sinch.configuration.numbers_origin, + project_id=self.project_id, + **vars(self.request_data), + ) + + def build_query_params(self) -> dict: + """ + Constructs the query parameters for the endpoint. + + Returns: + dict: The query parameters to be sent with the API request. + """ + return {} + + def request_body(self) -> str: + """ + Returns the request body as a JSON string. + + Returns: + str: The request body as a JSON string. + """ + return "" + + def process_response_model( + self, response_body: dict, response_model: Type[BM] + ) -> BM: + """ + Processes the response body and maps it to a response model. + + Args: + response_body (dict): The raw response body. + response_model (type): The Pydantic model class to map the response. + + Returns: + Parsed response object. + """ + try: + return response_model.model_validate(response_body) + except Exception as e: + raise ValueError(f"Invalid response structure: {e}") from e + + def handle_response(self, response: HTTPResponse): + if response.status_code == 404: + error = NotFoundError(**response.body["error"]) + raise NumbersException( + message=error, response=response, is_from_server=True + ) + + if response.status_code >= 400: + raise NumbersException( + message=f"{response.body['error'].get('message')} {response.body['error'].get('status')}", + response=response, + is_from_server=True, + ) diff --git a/sinch/domains/numbers/api/v1/internal/event_destinations_endpoints.py b/sinch/domains/numbers/api/v1/internal/event_destinations_endpoints.py new file mode 100644 index 00000000..f2fe06bd --- /dev/null +++ b/sinch/domains/numbers/api/v1/internal/event_destinations_endpoints.py @@ -0,0 +1,103 @@ +import json +from sinch.core.enums import HTTPAuthentication, HTTPMethods +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.api.v1.exceptions import ( + NumbersException, + NumberNotFoundException, +) +from sinch.domains.numbers.api.v1.internal.base import NumbersEndpoint +from sinch.domains.numbers.models.v1.internal import ( + UpdateEventDestinationRequest, +) +from sinch.domains.numbers.models.v1.response import ( + EventDestinationResponse, +) + + +class GetEventDestinationEndpoint(NumbersEndpoint): + """ + Endpoint to get the event destination configuration for a project. + """ + + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/callbackConfiguration" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data=None): + super(GetEventDestinationEndpoint, self).__init__( + project_id, request_data + ) + self.project_id = project_id + self.request_data = request_data + + def build_url(self, sinch) -> str: + if self.request_data: + super(GetEventDestinationEndpoint, self).build_url(sinch) + return self.ENDPOINT_URL.format( + origin=sinch.configuration.numbers_origin, + project_id=self.project_id, + ) + + def build_query_params(self) -> dict: + if self.request_data: + return self.request_data.model_dump( + exclude_none=True, by_alias=True + ) + return {} + + def handle_response( + self, response: HTTPResponse + ) -> EventDestinationResponse: + try: + super(GetEventDestinationEndpoint, self).handle_response(response) + except NumbersException as e: + raise NumberNotFoundException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model( + response.body, EventDestinationResponse + ) + + +class UpdateEventDestinationEndpoint(NumbersEndpoint): + """ + Endpoint to update the event destination configuration for a project. + """ + + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/callbackConfiguration" + HTTP_METHOD = HTTPMethods.PATCH.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__( + self, project_id: str, request_data: UpdateEventDestinationRequest + ): + super(UpdateEventDestinationEndpoint, self).__init__( + project_id, request_data + ) + self.project_id = project_id + self.request_data = request_data + + def request_body(self): + request_data = self.request_data.model_dump( + by_alias=True, exclude_none=True + ) + return json.dumps(request_data) + + def handle_response( + self, response: HTTPResponse + ) -> EventDestinationResponse: + try: + super(UpdateEventDestinationEndpoint, self).handle_response( + response + ) + except NumbersException as e: + raise NumberNotFoundException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model( + response.body, EventDestinationResponse + ) diff --git a/sinch/domains/numbers/endpoints/active/get_number_configuration.py b/sinch/domains/numbers/endpoints/active/get_number_configuration.py deleted file mode 100644 index 12e90032..00000000 --- a/sinch/domains/numbers/endpoints/active/get_number_configuration.py +++ /dev/null @@ -1,41 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods - -from sinch.domains.numbers.models.active.requests import GetNumberConfigurationRequest -from sinch.domains.numbers.models.active.responses import GetNumberConfigurationResponse - - -class GetNumberConfigurationEndpoint(NumbersEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/activeNumbers/{phone_number}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: GetNumberConfigurationRequest): - super(GetNumberConfigurationEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.numbers_origin, - project_id=self.project_id, - phone_number=self.request_data.phone_number - ) - - def handle_response(self, response: HTTPResponse) -> GetNumberConfigurationResponse: - super(GetNumberConfigurationEndpoint, self).handle_response(response) - return GetNumberConfigurationResponse( - phone_number=response.body["phoneNumber"], - project_id=response.body["projectId"], - display_name=response.body["displayName"], - region_code=response.body["regionCode"], - type=response.body["type"], - capability=response.body["capability"], - money=response.body["money"], - payment_interval_months=response.body["paymentIntervalMonths"], - next_charge_date=response.body["nextChargeDate"], - expire_at=response.body["expireAt"], - sms_configuration=response.body["smsConfiguration"], - voice_configuration=response.body["voiceConfiguration"] - ) diff --git a/sinch/domains/numbers/endpoints/active/list_active_numbers_for_project.py b/sinch/domains/numbers/endpoints/active/list_active_numbers_for_project.py deleted file mode 100644 index 8357ac4a..00000000 --- a/sinch/domains/numbers/endpoints/active/list_active_numbers_for_project.py +++ /dev/null @@ -1,68 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models.active import ActiveNumber -from sinch.domains.numbers.models.active.requests import ListActiveNumbersRequest -from sinch.domains.numbers.models.active.responses import ListActiveNumbersResponse - - -class ListActiveNumbersEndpoint(NumbersEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/activeNumbers" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: ListActiveNumbersRequest): - super(ListActiveNumbersEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.numbers_origin, - project_id=self.project_id - ) - - def build_query_params(self) -> dict: - params = { - "regionCode": self.request_data.region_code, - "type": self.request_data.number_type, - } - - if self.request_data.capabilities: - params["capabilities"] = self.request_data.capabilities - - if self.request_data.number_pattern: - params["numberPattern.pattern"] = self.request_data.number_pattern - - if self.request_data.number_search_pattern: - params["numberPattern.searchPattern"] = self.request_data.capabilities - - if self.request_data.page_size: - params["pageSize"] = self.request_data.page_size - - if self.request_data.page_token: - params["pageToken"] = self.request_data.page_token - - return params - - def handle_response(self, response: HTTPResponse) -> ListActiveNumbersResponse: - super(ListActiveNumbersEndpoint, self).handle_response(response) - return ListActiveNumbersResponse( - [ - ActiveNumber( - phone_number=number["phoneNumber"], - project_id=number["projectId"], - display_name=number["displayName"], - region_code=number["regionCode"], - type=number["type"], - capability=number["capability"], - money=number["money"], - payment_interval_months=number["paymentIntervalMonths"], - next_charge_date=number["nextChargeDate"], - expire_at=number["expireAt"], - sms_configuration=number["smsConfiguration"], - voice_configuration=number["voiceConfiguration"] - ) for number in response.body["activeNumbers"] - ], - next_page_token=response.body["nextPageToken"] - ) diff --git a/sinch/domains/numbers/endpoints/active/release_number_from_project.py b/sinch/domains/numbers/endpoints/active/release_number_from_project.py deleted file mode 100644 index d49721bd..00000000 --- a/sinch/domains/numbers/endpoints/active/release_number_from_project.py +++ /dev/null @@ -1,40 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models.active.requests import ReleaseNumberFromProjectRequest -from sinch.domains.numbers.models.active.responses import ReleaseNumberFromProjectResponse - - -class ReleaseNumberFromProjectEndpoint(NumbersEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/activeNumbers/{phone_number}:release" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id, request_data: ReleaseNumberFromProjectRequest): - super(ReleaseNumberFromProjectEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.numbers_origin, - project_id=self.project_id, - phone_number=self.request_data.phone_number - ) - - def handle_response(self, response: HTTPResponse) -> ReleaseNumberFromProjectResponse: - super(ReleaseNumberFromProjectEndpoint, self).handle_response(response) - return ReleaseNumberFromProjectResponse( - phone_number=response.body["phoneNumber"], - project_id=response.body["projectId"], - display_name=response.body["displayName"], - region_code=response.body["regionCode"], - type=response.body["type"], - capability=response.body["capability"], - money=response.body["money"], - payment_interval_months=response.body["paymentIntervalMonths"], - next_charge_date=response.body["nextChargeDate"], - expire_at=response.body["expireAt"], - sms_configuration=response.body["smsConfiguration"], - voice_configuration=response.body["voiceConfiguration"] - ) diff --git a/sinch/domains/numbers/endpoints/active/update_number_configuration.py b/sinch/domains/numbers/endpoints/active/update_number_configuration.py deleted file mode 100644 index 866bef73..00000000 --- a/sinch/domains/numbers/endpoints/active/update_number_configuration.py +++ /dev/null @@ -1,44 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models.active.requests import UpdateNumberConfigurationRequest -from sinch.domains.numbers.models.active.responses import UpdateNumberConfigurationResponse - - -class UpdateNumberConfigurationEndpoint(NumbersEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/activeNumbers/{phone_number}" - HTTP_METHOD = HTTPMethods.PATCH.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: UpdateNumberConfigurationRequest): - super(UpdateNumberConfigurationEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.numbers_origin, - project_id=self.project_id, - phone_number=self.request_data.phone_number - ) - - def request_body(self): - self.request_data.phone_number = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> UpdateNumberConfigurationResponse: - super(UpdateNumberConfigurationEndpoint, self).handle_response(response) - return UpdateNumberConfigurationResponse( - phone_number=response.body["phoneNumber"], - project_id=response.body["projectId"], - display_name=response.body["displayName"], - region_code=response.body["regionCode"], - type=response.body["type"], - capability=response.body["capability"], - money=response.body["money"], - payment_interval_months=response.body["paymentIntervalMonths"], - next_charge_date=response.body["nextChargeDate"], - expire_at=response.body["expireAt"], - sms_configuration=response.body["smsConfiguration"], - voice_configuration=response.body["voiceConfiguration"] - ) diff --git a/sinch/domains/numbers/endpoints/available/activate_number.py b/sinch/domains/numbers/endpoints/available/activate_number.py deleted file mode 100644 index 1155e89e..00000000 --- a/sinch/domains/numbers/endpoints/available/activate_number.py +++ /dev/null @@ -1,32 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models.available.requests import ActivateNumberRequest -from sinch.domains.numbers.models.available.responses import ActivateNumberResponse - - -class ActivateNumberEndpoint(NumbersEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers/{phone_number}:rent" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: ActivateNumberRequest): - super(ActivateNumberEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.numbers_origin, - project_id=self.project_id, - phone_number=self.request_data.phone_number - ) - - def handle_response(self, response: HTTPResponse) -> ActivateNumberResponse: - super(ActivateNumberEndpoint, self).handle_response(response) - return ActivateNumberResponse( - phone_number=response.body["phoneNumber"], - region_code=response.body["regionCode"], - type=response.body["type"], - capability=response.body["capability"] - ) diff --git a/sinch/domains/numbers/endpoints/available/list_available_numbers.py b/sinch/domains/numbers/endpoints/available/list_available_numbers.py deleted file mode 100644 index 10036e71..00000000 --- a/sinch/domains/numbers/endpoints/available/list_available_numbers.py +++ /dev/null @@ -1,61 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models import Number - -from sinch.domains.numbers.models.available.requests import ListAvailableNumbersRequest -from sinch.domains.numbers.models.available.responses import ListAvailableNumbersResponse - - -class AvailableNumbersEndpoint(NumbersEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: ListAvailableNumbersRequest): - super(AvailableNumbersEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.numbers_origin, - project_id=self.project_id - ) - - def build_query_params(self) -> dict: - query_params = { - "regionCode": self.request_data.region_code, - "type": self.request_data.number_type - } - - if self.request_data.page_size: - query_params["size"] = self.request_data.page_size - - if self.request_data.capabilities: - query_params["capabilities"] = self.request_data.capabilities - - if self.request_data.number_pattern: - query_params["numberPattern.pattern"] = self.request_data.number_pattern - - if self.request_data.number_search_pattern: - query_params["numberPattern.searchPattern"] = self.request_data.number_search_pattern - - return query_params - - def handle_response(self, response: HTTPResponse) -> ListAvailableNumbersResponse: - super(AvailableNumbersEndpoint, self).handle_response(response) - return ListAvailableNumbersResponse( - [ - Number( - phone_number=number["phoneNumber"], - region_code=number["regionCode"], - type=number["type"], - capability=number["capability"], - setup_price=number["setupPrice"], - monthly_price=number["monthlyPrice"], - payment_interval_months=number["paymentIntervalMonths"], - supporting_documentation_required=number["supportingDocumentationRequired"] - ) for number in response.body["availableNumbers"] - ] - ) diff --git a/sinch/domains/numbers/endpoints/available/rent_any_number.py b/sinch/domains/numbers/endpoints/available/rent_any_number.py deleted file mode 100644 index 692a17d0..00000000 --- a/sinch/domains/numbers/endpoints/available/rent_any_number.py +++ /dev/null @@ -1,67 +0,0 @@ -import json -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models.available.requests import RentAnyNumberRequest -from sinch.domains.numbers.models.available.responses import RentAnyNumberResponse - - -class RentAnyNumberEndpoint(NumbersEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers:rentAny" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: RentAnyNumberRequest): - super(RentAnyNumberEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.numbers_origin, - project_id=self.project_id - ) - - def request_body(self): - request_data = self.request_data.as_dict() - request_body = {} - - if request_data.get("region_code"): - request_body["regionCode"] = request_data["region_code"] - - if request_data.get("type_"): - request_body["type"] = request_data["type_"] - - if request_data.get("number_pattern"): - request_body["numberPattern"] = request_data["number_pattern"] - - if request_data.get("capabilities"): - request_body["capabilities"] = request_data["capabilities"] - - if request_data.get("sms_configuration"): - request_body["smsConfiguration"] = request_data["sms_configuration"] - - if request_data.get("voice_configuration"): - request_body["voiceConfiguration"] = request_data["voice_configuration"] - - if request_data.get("callback_url"): - request_body["callbackUrl"] = request_data["callback_url"] - - return json.dumps(request_body) - - def handle_response(self, response: HTTPResponse) -> RentAnyNumberResponse: - super(RentAnyNumberEndpoint, self).handle_response(response) - return RentAnyNumberResponse( - phone_number=response.body["phoneNumber"], - region_code=response.body["regionCode"], - type=response.body["type"], - capability=response.body["capability"], - project_id=response.body["projectId"], - callback_url=response.body["callbackUrl"], - expire_at=response.body["expireAt"], - money=response.body["money"], - next_charge_date=response.body["nextChargeDate"], - sms_configuration=response.body["smsConfiguration"], - voice_configuration=response.body["voiceConfiguration"], - payment_interval_months=response.body["paymentIntervalMonths"] - ) diff --git a/sinch/domains/numbers/endpoints/available/search_for_number.py b/sinch/domains/numbers/endpoints/available/search_for_number.py deleted file mode 100644 index 1d896247..00000000 --- a/sinch/domains/numbers/endpoints/available/search_for_number.py +++ /dev/null @@ -1,36 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models.available.responses import CheckNumberAvailabilityResponse -from sinch.domains.numbers.models.available.requests import CheckNumberAvailabilityRequest - - -class SearchForNumberEndpoint(NumbersEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers/{phone_number}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: CheckNumberAvailabilityRequest): - super(SearchForNumberEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.numbers_origin, - project_id=self.project_id, - phone_number=self.request_data.phone_number - ) - - def handle_response(self, response: HTTPResponse) -> CheckNumberAvailabilityResponse: - super(SearchForNumberEndpoint, self).handle_response(response) - return CheckNumberAvailabilityResponse( - phone_number=response.body["phoneNumber"], - region_code=response.body["regionCode"], - type=response.body["type"], - capability=response.body["capability"], - setup_price=response.body["setupPrice"], - monthly_price=response.body["monthlyPrice"], - payment_interval_months=response.body["paymentIntervalMonths"], - supporting_documentation_required=response.body["supportingDocumentationRequired"] - ) diff --git a/sinch/domains/numbers/endpoints/callbacks/get_configuration.py b/sinch/domains/numbers/endpoints/callbacks/get_configuration.py deleted file mode 100644 index 5e05c8bc..00000000 --- a/sinch/domains/numbers/endpoints/callbacks/get_configuration.py +++ /dev/null @@ -1,27 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models.callbacks.responses import GetNumbersCallbackConfigurationResponse - - -class GetNumbersCallbackConfigurationEndpoint(NumbersEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/callbackConfiguration" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str): - super().__init__(project_id, None) - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.numbers_origin, - project_id=self.project_id - ) - - def handle_response(self, response: HTTPResponse) -> GetNumbersCallbackConfigurationResponse: - super().handle_response(response) - return GetNumbersCallbackConfigurationResponse( - project_id=response.body['projectId'], - hmac_secret=response.body['hmacSecret'] - ) diff --git a/sinch/domains/numbers/endpoints/callbacks/update_configuration.py b/sinch/domains/numbers/endpoints/callbacks/update_configuration.py deleted file mode 100644 index 1d7c2a56..00000000 --- a/sinch/domains/numbers/endpoints/callbacks/update_configuration.py +++ /dev/null @@ -1,32 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models.callbacks.responses import UpdateNumbersCallbackConfigurationResponse -from sinch.domains.numbers.models.callbacks.requests import UpdateNumbersCallbackConfigurationRequest - - -class UpdateNumbersCallbackConfigurationEndpoint(NumbersEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/callbackConfiguration" - HTTP_METHOD = HTTPMethods.PATCH.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: UpdateNumbersCallbackConfigurationRequest): - super().__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.numbers_origin, - project_id=self.project_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> UpdateNumbersCallbackConfigurationResponse: - super().handle_response(response) - return UpdateNumbersCallbackConfigurationResponse( - project_id=response.body['projectId'], - hmac_secret=response.body['hmacSecret'] - ) diff --git a/sinch/domains/numbers/endpoints/numbers_endpoint.py b/sinch/domains/numbers/endpoints/numbers_endpoint.py deleted file mode 100644 index 1d8a9346..00000000 --- a/sinch/domains/numbers/endpoints/numbers_endpoint.py +++ /dev/null @@ -1,13 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.core.endpoint import HTTPEndpoint -from sinch.domains.numbers.exceptions import NumbersException - - -class NumbersEndpoint(HTTPEndpoint): - def handle_response(self, response: HTTPResponse): - if response.status_code >= 400: - raise NumbersException( - message=response.body["error"].get("message"), - response=response, - is_from_server=True - ) diff --git a/sinch/domains/numbers/endpoints/regions/list_available_regions.py b/sinch/domains/numbers/endpoints/regions/list_available_regions.py deleted file mode 100644 index b5897879..00000000 --- a/sinch/domains/numbers/endpoints/regions/list_available_regions.py +++ /dev/null @@ -1,46 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models.regions import Region - -from sinch.domains.numbers.models.regions.responses import ListAvailableRegionsResponse -from sinch.domains.numbers.models.regions.requests import ListAvailableRegionsForProjectRequest - - -class ListAvailableRegionsEndpoint(NumbersEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableRegions" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: ListAvailableRegionsForProjectRequest): - super(ListAvailableRegionsEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.numbers_origin, - project_id=self.project_id - ) - - def build_query_params(self) -> dict: - query_params = {} - if self.request_data.number_type: - query_params["type"] = self.request_data.number_type - - if self.request_data.number_types: - query_params["types"] = self.request_data.number_types - - return query_params - - def handle_response(self, response: HTTPResponse) -> ListAvailableRegionsResponse: - super(ListAvailableRegionsEndpoint, self).handle_response(response) - return ListAvailableRegionsResponse( - [ - Region( - region_code=region["regionCode"], - region_name=region["regionName"], - types=region["types"] - ) for region in response.body["availableRegions"] - ] - ) diff --git a/sinch/domains/numbers/enums.py b/sinch/domains/numbers/enums.py deleted file mode 100644 index 558a8e80..00000000 --- a/sinch/domains/numbers/enums.py +++ /dev/null @@ -1,12 +0,0 @@ -from enum import Enum - - -class NumberCapability(Enum): - SMS = "SMS" - VOICE = "VOICE" - - -class NumberType(Enum): - MOBILE = "MOBILE" - LOCAL = "LOCAL" - TOLL_FREE = "TOLL_FREE" diff --git a/sinch/domains/numbers/models/__init__.py b/sinch/domains/numbers/models/__init__.py index 986eb93e..e69de29b 100644 --- a/sinch/domains/numbers/models/__init__.py +++ b/sinch/domains/numbers/models/__init__.py @@ -1,41 +0,0 @@ -from dataclasses import dataclass -from decimal import Decimal -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.numbers.enums import NumberType, NumberCapability - - -@dataclass -class Number(SinchBaseModel): - phone_number: str - region_code: str - type: NumberType - capability: NumberCapability - setup_price: dict - monthly_price: dict - payment_interval_months: int - supporting_documentation_required: bool - - -@dataclass -class ScheduledVoiceProvisioning(SinchBaseModel): - app_id: str - status: str - last_updated_time: str - - -@dataclass -class VoiceConfiguration(SinchBaseModel): - app_id: str - scheduled_provisioning: ScheduledVoiceProvisioning - - -@dataclass -class SmsConfiguration(SinchBaseModel): - service_plan_id: str - scheduled_provisioning: ScheduledVoiceProvisioning - - -@dataclass -class Money(SinchBaseModel): - currency_code: str - amount: Decimal diff --git a/sinch/domains/numbers/models/active/__init__.py b/sinch/domains/numbers/models/active/__init__.py deleted file mode 100644 index e2d393d9..00000000 --- a/sinch/domains/numbers/models/active/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -from dataclasses import dataclass - -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.numbers.enums import NumberType, NumberCapability - - -@dataclass -class ActiveNumber(SinchBaseModel): - phone_number: str - project_id: str - display_name: str - region_code: str - type: NumberType - capability: NumberCapability - money: dict - payment_interval_months: int - next_charge_date: str - expire_at: str - sms_configuration: dict - voice_configuration: dict diff --git a/sinch/domains/numbers/models/active/requests.py b/sinch/domains/numbers/models/active/requests.py deleted file mode 100644 index 6af5b046..00000000 --- a/sinch/domains/numbers/models/active/requests.py +++ /dev/null @@ -1,33 +0,0 @@ -from dataclasses import dataclass - -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class ListActiveNumbersRequest(SinchRequestBaseModel): - region_code: str - number_type: str - page_size: int - capabilities: list - number_search_pattern: str - number_pattern: str - page_token: str - - -@dataclass -class GetNumberConfigurationRequest(SinchRequestBaseModel): - phone_number: str - - -@dataclass -class UpdateNumberConfigurationRequest(SinchRequestBaseModel): - phone_number: str - display_name: str - sms_configuration: dict - voice_configuration: dict - app_id: str - - -@dataclass -class ReleaseNumberFromProjectRequest(SinchRequestBaseModel): - phone_number: str diff --git a/sinch/domains/numbers/models/active/responses.py b/sinch/domains/numbers/models/active/responses.py deleted file mode 100644 index e47e6e17..00000000 --- a/sinch/domains/numbers/models/active/responses.py +++ /dev/null @@ -1,26 +0,0 @@ -from dataclasses import dataclass -from typing import List, Optional - -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.numbers.models.active import ActiveNumber - - -@dataclass -class ListActiveNumbersResponse(SinchBaseModel): - active_numbers: List[ActiveNumber] - next_page_token: Optional[str] = None - - -@dataclass -class UpdateNumberConfigurationResponse(ActiveNumber): - pass - - -@dataclass -class GetNumberConfigurationResponse(ActiveNumber): - pass - - -@dataclass -class ReleaseNumberFromProjectResponse(ActiveNumber): - pass diff --git a/sinch/domains/numbers/models/available/requests.py b/sinch/domains/numbers/models/available/requests.py deleted file mode 100644 index 063cedfc..00000000 --- a/sinch/domains/numbers/models/available/requests.py +++ /dev/null @@ -1,36 +0,0 @@ -from dataclasses import dataclass - -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class ListAvailableNumbersRequest(SinchRequestBaseModel): - region_code: str - number_type: str - page_size: int - capabilities: list - number_search_pattern: str - number_pattern: str - - -@dataclass -class ActivateNumberRequest(SinchRequestBaseModel): - phone_number: str - sms_configuration: dict - voice_configuration: dict - - -@dataclass -class RentAnyNumberRequest(SinchRequestBaseModel): - region_code: str - type_: str - number_pattern: str - capabilities: list - sms_configuration: dict - voice_configuration: dict - callback_url: str - - -@dataclass -class CheckNumberAvailabilityRequest(SinchRequestBaseModel): - phone_number: str diff --git a/sinch/domains/numbers/models/available/responses.py b/sinch/domains/numbers/models/available/responses.py deleted file mode 100644 index 2e1d1501..00000000 --- a/sinch/domains/numbers/models/available/responses.py +++ /dev/null @@ -1,40 +0,0 @@ -from dataclasses import dataclass -from typing import List - -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.numbers.models import Number - - -@dataclass -class ListAvailableNumbersResponse(SinchBaseModel): - available_numbers: List[Number] - - -@dataclass -class ActivateNumberResponse(SinchBaseModel): - phone_number: str - region_code: str - type: str - capability: list - - -@dataclass -class RentAnyNumberResponse(SinchBaseModel): - phone_number: str - project_id: str - region_code: str - type: str - capability: list - money: dict - payment_interval_months: int - next_charge_date: str - expire_at: str - sms_configuration: object - voice_configuration: object - callback_url: str - capability: tuple - - -@dataclass -class CheckNumberAvailabilityResponse(Number): - pass diff --git a/sinch/domains/numbers/models/callbacks/requests.py b/sinch/domains/numbers/models/callbacks/requests.py deleted file mode 100644 index 621fbad7..00000000 --- a/sinch/domains/numbers/models/callbacks/requests.py +++ /dev/null @@ -1,8 +0,0 @@ -from dataclasses import dataclass - -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class UpdateNumbersCallbackConfigurationRequest(SinchRequestBaseModel): - hmac_secret: str diff --git a/sinch/domains/numbers/models/callbacks/responses.py b/sinch/domains/numbers/models/callbacks/responses.py deleted file mode 100644 index 73fe758b..00000000 --- a/sinch/domains/numbers/models/callbacks/responses.py +++ /dev/null @@ -1,19 +0,0 @@ -from dataclasses import dataclass - -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class NumbersCallbackConfigurationResponse(SinchBaseModel): - project_id: str - hmac_secret: str - - -@dataclass -class GetNumbersCallbackConfigurationResponse(NumbersCallbackConfigurationResponse): - pass - - -@dataclass -class UpdateNumbersCallbackConfigurationResponse(NumbersCallbackConfigurationResponse): - pass diff --git a/sinch/domains/numbers/models/regions/__init__.py b/sinch/domains/numbers/models/regions/__init__.py deleted file mode 100644 index 0db21448..00000000 --- a/sinch/domains/numbers/models/regions/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from dataclasses import dataclass - -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class Region(SinchBaseModel): - region_code: str - region_name: str - types: list diff --git a/sinch/domains/numbers/models/regions/requests.py b/sinch/domains/numbers/models/regions/requests.py deleted file mode 100644 index 6d44c2be..00000000 --- a/sinch/domains/numbers/models/regions/requests.py +++ /dev/null @@ -1,9 +0,0 @@ -from dataclasses import dataclass - -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class ListAvailableRegionsForProjectRequest(SinchRequestBaseModel): - number_type: str - number_types: list diff --git a/sinch/domains/numbers/models/regions/responses.py b/sinch/domains/numbers/models/regions/responses.py deleted file mode 100644 index 9c80879f..00000000 --- a/sinch/domains/numbers/models/regions/responses.py +++ /dev/null @@ -1,10 +0,0 @@ -from dataclasses import dataclass -from typing import List - -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.numbers.models.regions import Region - - -@dataclass -class ListAvailableRegionsResponse(SinchBaseModel): - available_regions: List[Region] diff --git a/sinch/domains/numbers/endpoints/regions/__init__.py b/sinch/domains/numbers/models/v1/__init__.py similarity index 100% rename from sinch/domains/numbers/endpoints/regions/__init__.py rename to sinch/domains/numbers/models/v1/__init__.py diff --git a/sinch/domains/numbers/models/v1/errors/__init__.py b/sinch/domains/numbers/models/v1/errors/__init__.py new file mode 100644 index 00000000..885a36a3 --- /dev/null +++ b/sinch/domains/numbers/models/v1/errors/__init__.py @@ -0,0 +1,11 @@ +from sinch.domains.numbers.models.v1.errors.not_found_error import ( + NotFoundError, +) +from sinch.domains.numbers.models.v1.errors.not_found_error_details import ( + NotFoundErrorDetails, +) + +__all__ = [ + "NotFoundError", + "NotFoundErrorDetails", +] diff --git a/sinch/domains/numbers/models/v1/errors/not_found_error.py b/sinch/domains/numbers/models/v1/errors/not_found_error.py new file mode 100644 index 00000000..da52f032 --- /dev/null +++ b/sinch/domains/numbers/models/v1/errors/not_found_error.py @@ -0,0 +1,20 @@ +from typing import Optional +from pydantic import ConfigDict, conlist, Field, StrictInt, StrictStr +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.numbers.models.v1.errors.not_found_error_details import ( + NotFoundErrorDetails, +) + + +class NotFoundError(BaseModelConfigurationResponse): + code: Optional[StrictInt] = Field(default=None) + message: Optional[StrictStr] = Field(default=None) + status: Optional[StrictStr] = Field(default=None) + details: Optional[conlist(NotFoundErrorDetails)] = Field(default=None) + + model_config = ConfigDict( + populate_by_name=True, + alias_generator=BaseModelConfigurationResponse._to_snake_case, + ) diff --git a/sinch/domains/numbers/models/v1/errors/not_found_error_details.py b/sinch/domains/numbers/models/v1/errors/not_found_error_details.py new file mode 100644 index 00000000..3c511cbf --- /dev/null +++ b/sinch/domains/numbers/models/v1/errors/not_found_error_details.py @@ -0,0 +1,17 @@ +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from typing import Optional +from pydantic import Field, StrictStr + + +class NotFoundErrorDetails(BaseModelConfigurationResponse): + type: Optional[StrictStr] = Field(default=None, alias="type") + resource_type: Optional[StrictStr] = Field( + default=None, alias="resourceType" + ) + resource_name: Optional[StrictStr] = Field( + default=None, alias="resourceName" + ) + owner: Optional[StrictStr] = Field(default=None, alias="owner") + description: Optional[StrictStr] = Field(default=None, alias="description") diff --git a/sinch/domains/numbers/models/v1/internal/__init__.py b/sinch/domains/numbers/models/v1/internal/__init__.py new file mode 100644 index 00000000..c69e43e6 --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/__init__.py @@ -0,0 +1,61 @@ +from sinch.domains.numbers.models.v1.internal.rent_number_request import ( + RentNumberRequest, +) +from sinch.domains.numbers.models.v1.internal.list_active_numbers_request import ( + ListActiveNumbersRequest, +) +from sinch.domains.numbers.models.v1.internal.list_active_numbers_response import ( + ListActiveNumbersResponse, +) +from sinch.domains.numbers.models.v1.internal.list_available_numbers_request import ( + ListAvailableNumbersRequest, +) +from sinch.domains.numbers.models.v1.internal.list_available_numbers_response import ( + ListAvailableNumbersResponse, +) +from sinch.domains.numbers.models.v1.internal.list_available_regions_request import ( + ListAvailableRegionsRequest, +) +from sinch.domains.numbers.models.v1.internal.list_available_regions_response import ( + ListAvailableRegionsResponse, +) +from sinch.domains.numbers.models.v1.internal.number_request import ( + NumberRequest, +) +from sinch.domains.numbers.models.v1.internal.rent_any_number_request import ( + RentAnyNumberRequest, +) +from sinch.domains.numbers.models.v1.internal.sms_configuration_request import ( + SmsConfigurationRequest, +) +from sinch.domains.numbers.models.v1.internal.update_event_destination_request import ( + UpdateEventDestinationRequest, +) +from sinch.domains.numbers.models.v1.internal.update_number_configuration_request import ( + UpdateNumberConfigurationRequest, +) +from sinch.domains.numbers.models.v1.internal.voice_configuration_request import ( + VoiceConfigurationCustom, + VoiceConfigurationEST, + VoiceConfigurationFAX, + VoiceConfigurationRTC, +) + +__all__ = [ + "ListActiveNumbersRequest", + "ListAvailableNumbersRequest", + "ListActiveNumbersResponse", + "ListAvailableNumbersResponse", + "ListAvailableRegionsRequest", + "ListAvailableRegionsResponse", + "NumberRequest", + "RentAnyNumberRequest", + "RentNumberRequest", + "SmsConfigurationRequest", + "UpdateEventDestinationRequest", + "UpdateNumberConfigurationRequest", + "VoiceConfigurationCustom", + "VoiceConfigurationEST", + "VoiceConfigurationFAX", + "VoiceConfigurationRTC", +] diff --git a/sinch/domains/numbers/models/v1/internal/base/__init__.py b/sinch/domains/numbers/models/v1/internal/base/__init__.py new file mode 100644 index 00000000..51cffe56 --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/base/__init__.py @@ -0,0 +1,9 @@ +from sinch.domains.numbers.models.v1.internal.base.base_model_configuration import ( + BaseModelConfigurationRequest, + BaseModelConfigurationResponse, +) + +__all__ = [ + "BaseModelConfigurationRequest", + "BaseModelConfigurationResponse", +] diff --git a/sinch/domains/numbers/models/v1/internal/base/base_model_configuration.py b/sinch/domains/numbers/models/v1/internal/base/base_model_configuration.py new file mode 100644 index 00000000..8e9d179b --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/base/base_model_configuration.py @@ -0,0 +1,112 @@ +import re +from typing import Any +from pydantic import BaseModel, ConfigDict + + +class BaseModelConfigurationRequest(BaseModel): + """ + A base model that allows extra fields and converts snake_case to camelCase. + """ + + @staticmethod + def _to_camel_case(snake_str: str) -> str: + """Converts snake_case to camelCase while preserving multiple underscores.""" + if not snake_str or "_" not in snake_str: + return snake_str + components = snake_str.split("_") + return components[0].lower() + "".join( + (x.capitalize() if x else "_") for x in components[1:] + ) + + @classmethod + def _convert_dict_keys(cls, obj): + """Recursively convert dictionary keys to camelCase.""" + if isinstance(obj, dict): + new_dict = {} + for key, value in obj.items(): + # Convert dict key to camelCase + camel_key = cls._to_camel_case(key) + # Recurse on the value + new_dict[camel_key] = cls._convert_dict_keys(value) + return new_dict + elif isinstance(obj, list): + # Recurse through any list elements (they might be dicts too) + return [cls._convert_dict_keys(item) for item in obj] + else: + return obj + + model_config = ConfigDict( + # Allows using both alias (camelCase) and field name (snake_case) + populate_by_name=True, + # Allows extra values in input + extra="allow", + ) + + def _convert_dict_to_camel_case(self, data): + if isinstance(data, dict): + return { + self._to_camel_case(k): self._convert_dict_to_camel_case(v) + for k, v in data.items() + } + elif isinstance(data, list): + return [self._convert_dict_to_camel_case(i) for i in data] + return data + + def model_dump(self, **kwargs) -> dict: + """Converts extra fields from snake_case to camelCase when dumping the model in endpoint.""" + # Get the standard model dump. + data = super().model_dump(**kwargs) + if not kwargs or kwargs["by_alias"]: + data = self._convert_dict_to_camel_case(data) + + # Get extra fields + extra_data = self.__pydantic_extra__ or {} + + # Merge known + unknown into one dictionary first + combined = {**data, **extra_data} + + final_dict = {} + + for key, value in combined.items(): + if key in extra_data: + # This is an unknown field to be converted + new_key = self._to_camel_case(key) + else: + # Known field - keep the top-level key as given + new_key = key + + # Recursively convert any nested dict keys + converted_value = self._convert_dict_keys(value) + + # Add to final dictionary + final_dict[new_key] = converted_value + + return final_dict + + +class BaseModelConfigurationResponse(BaseModel): + """ + A base model that allows extra fields and converts camelCase to snake_case + """ + + @staticmethod + def _to_snake_case(camel_str: str) -> str: + """Helper to convert camelCase string to snake_case.""" + return re.sub(r"(? None: + """Converts unknown fields from camelCase to snake_case.""" + if self.__pydantic_extra__: + converted_extra = { + self._to_snake_case(key): value + for key, value in self.__pydantic_extra__.items() + } + self.__pydantic_extra__.clear() + self.__pydantic_extra__.update(converted_extra) diff --git a/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py new file mode 100644 index 00000000..f832beb5 --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py @@ -0,0 +1,37 @@ +from typing import Optional +from pydantic import Field, StrictInt, StrictStr, field_validator, conlist +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) +from sinch.domains.numbers.models.v1.types import ( + CapabilityType, + OrderByType, + NumberSearchPatternType, + NumberType, +) + + +class ListActiveNumbersRequest(BaseModelConfigurationRequest): + region_code: Optional[StrictStr] = Field( + default=None, + alias="regionCode", + description="ISO 3166-1 alpha-2 country code. Example: US, GB or SE.", + ) + number_type: Optional[NumberType] = Field(default=None, alias="type") + page_size: Optional[StrictInt] = Field(default=None, alias="pageSize") + capabilities: Optional[conlist(CapabilityType)] = Field(default=None) + number_search_pattern: Optional[NumberSearchPatternType] = Field( + default=None, alias="numberPattern.searchPattern" + ) + number_pattern: Optional[StrictStr] = Field( + default=None, alias="numberPattern.pattern" + ) + page_token: Optional[StrictStr] = Field(default=None, alias="pageToken") + order_by: Optional[OrderByType] = Field(default=None, alias="orderBy") + + @field_validator("order_by", mode="before") + @classmethod + def convert_order_by(cls, value): + if isinstance(value, str): + return cls._to_camel_case(value) + return value diff --git a/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py b/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py new file mode 100644 index 00000000..e93f6aff --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py @@ -0,0 +1,30 @@ +from typing import Optional +from pydantic import ( + BaseModel, + ConfigDict, + Field, + StrictStr, + StrictInt, + conlist, +) +from sinch.domains.numbers.models.v1.response import ActiveNumber + + +class ListActiveNumbersResponse(BaseModel): + active_numbers: Optional[conlist(ActiveNumber)] = Field( + default=None, alias="activeNumbers" + ) + next_page_token: Optional[StrictStr] = Field( + default=None, alias="nextPageToken" + ) + total_size: Optional[StrictInt] = Field(default=None, alias="totalSize") + + model_config = ConfigDict( + populate_by_name=True, + extra="allow", + ) + + @property + def content(self): + """Returns the active numbers as part of the response object to be used in the pagination.""" + return self.active_numbers or [] diff --git a/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py b/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py new file mode 100644 index 00000000..6a3c8082 --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py @@ -0,0 +1,26 @@ +from typing import Optional +from pydantic import Field, StrictInt, StrictStr, conlist +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) +from sinch.domains.numbers.models.v1.types import ( + CapabilityType, + NumberSearchPatternType, + NumberType, +) + + +class ListAvailableNumbersRequest(BaseModelConfigurationRequest): + region_code: StrictStr = Field( + alias="regionCode", + description="ISO 3166-1 alpha-2 country code. Example: US, GB or SE.", + ) + number_type: NumberType = Field(alias="type") + page_size: Optional[StrictInt] = Field(default=None, alias="size") + capabilities: Optional[conlist(CapabilityType)] = Field(default=None) + number_search_pattern: Optional[NumberSearchPatternType] = Field( + default=None, alias="numberPattern.searchPattern" + ) + number_pattern: Optional[StrictStr] = Field( + default=None, alias="numberPattern.pattern" + ) diff --git a/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py b/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py new file mode 100644 index 00000000..d4aa6e51 --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py @@ -0,0 +1,16 @@ +from typing import Optional +from pydantic import BaseModel, ConfigDict, Field, conlist +from sinch.domains.numbers.models.v1.response import AvailableNumber + + +class ListAvailableNumbersResponse(BaseModel): + available_numbers: Optional[conlist(AvailableNumber)] = Field( + default=None, alias="availableNumbers" + ) + + model_config = ConfigDict(populate_by_name=True) + + @property + def content(self): + """Returns the available numbers as part of the response object to be used in the pagination.""" + return self.available_numbers or [] diff --git a/sinch/domains/numbers/models/v1/internal/list_available_regions_request.py b/sinch/domains/numbers/models/v1/internal/list_available_regions_request.py new file mode 100644 index 00000000..5e99dc57 --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/list_available_regions_request.py @@ -0,0 +1,10 @@ +from typing import Optional +from pydantic import Field, conlist +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) +from sinch.domains.numbers.models.v1.types import NumberType + + +class ListAvailableRegionsRequest(BaseModelConfigurationRequest): + types: Optional[conlist(NumberType)] = Field(default=None) diff --git a/sinch/domains/numbers/models/v1/internal/list_available_regions_response.py b/sinch/domains/numbers/models/v1/internal/list_available_regions_response.py new file mode 100644 index 00000000..430c0a4b --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/list_available_regions_response.py @@ -0,0 +1,16 @@ +from typing import Optional +from pydantic import BaseModel, ConfigDict, Field, conlist +from sinch.domains.numbers.models.v1.response import AvailableRegion + + +class ListAvailableRegionsResponse(BaseModel): + available_regions: Optional[conlist(AvailableRegion)] = Field( + default=None, alias="availableRegions" + ) + + model_config = ConfigDict(populate_by_name=True) + + @property + def content(self): + """Returns the available regions as part of the response object to be used in the pagination.""" + return self.available_regions or [] diff --git a/sinch/domains/numbers/models/v1/internal/number_request.py b/sinch/domains/numbers/models/v1/internal/number_request.py new file mode 100644 index 00000000..658147e8 --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/number_request.py @@ -0,0 +1,11 @@ +from pydantic import Field, StrictStr +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class NumberRequest(BaseModelConfigurationRequest): + phone_number: StrictStr = Field( + alias="phoneNumber", + description="Phone number in E.164 format with leading '+'. Example: '+12025550134'.", + ) diff --git a/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py b/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py new file mode 100644 index 00000000..85b88f73 --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py @@ -0,0 +1,39 @@ +from typing import Optional, Dict, Any +from pydantic import Field, StrictStr, conlist +from sinch.domains.numbers.models.v1.types import CapabilityType, NumberType +from sinch.domains.numbers.models.v1.utils.validators import ( + validate_sms_voice_configuration, + validate_number_pattern, +) +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class RentAnyNumberRequest(BaseModelConfigurationRequest): + region_code: StrictStr = Field( + alias="regionCode", + description="ISO 3166-1 alpha-2 country code. Example: US, GB or SE.", + ) + number_type: NumberType = Field(alias="type") + number_pattern: Optional[Dict[str, Any]] = Field( + default=None, alias="numberPattern" + ) + capabilities: Optional[conlist(CapabilityType)] = Field(default=None) + sms_configuration: Optional[Dict[str, Any]] = Field( + default=None, alias="smsConfiguration" + ) + voice_configuration: Optional[Dict[str, Any]] = Field( + default=None, alias="voiceConfiguration" + ) + event_destination_target: Optional[StrictStr] = Field( + default=None, alias="callbackUrl" + ) + + def __init__(self, **data): + """ + Custom initializer to validate nested dictionaries. + """ + validate_sms_voice_configuration(data) + validate_number_pattern(data) + super().__init__(**data) diff --git a/sinch/domains/numbers/models/v1/internal/rent_number_request.py b/sinch/domains/numbers/models/v1/internal/rent_number_request.py new file mode 100644 index 00000000..9c2980b6 --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/rent_number_request.py @@ -0,0 +1,32 @@ +from typing import Optional, Dict +from pydantic import Field, StrictStr +from sinch.domains.numbers.models.v1.utils.validators import ( + validate_sms_voice_configuration, +) +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class RentNumberRequest(BaseModelConfigurationRequest): + phone_number: StrictStr = Field( + alias="phoneNumber", + description="Phone number in E.164 format with leading '+'. Example: '+12025550134'.", + ) + # Accepts only dictionary input, not Pydantic models + sms_configuration: Optional[Dict] = Field( + default=None, alias="smsConfiguration" + ) + voice_configuration: Optional[Dict] = Field( + default=None, alias="voiceConfiguration" + ) + event_destination_target: Optional[StrictStr] = Field( + default=None, alias="callbackUrl" + ) + + def __init__(self, **data): + """ + Custom initializer to validate nested dictionaries. + """ + validate_sms_voice_configuration(data) + super().__init__(**data) diff --git a/sinch/domains/numbers/models/v1/internal/sms_configuration_request.py b/sinch/domains/numbers/models/v1/internal/sms_configuration_request.py new file mode 100644 index 00000000..5e1f82fa --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/sms_configuration_request.py @@ -0,0 +1,10 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class SmsConfigurationRequest(BaseModelConfigurationRequest): + service_plan_id: StrictStr = Field(alias="servicePlanId") + campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") diff --git a/sinch/domains/numbers/models/v1/internal/update_event_destination_request.py b/sinch/domains/numbers/models/v1/internal/update_event_destination_request.py new file mode 100644 index 00000000..6f2e5abc --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/update_event_destination_request.py @@ -0,0 +1,9 @@ +from typing import Optional +from pydantic import StrictStr, Field +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class UpdateEventDestinationRequest(BaseModelConfigurationRequest): + hmac_secret: Optional[StrictStr] = Field(default=None, alias="hmacSecret") diff --git a/sinch/domains/numbers/models/v1/internal/update_number_configuration_request.py b/sinch/domains/numbers/models/v1/internal/update_number_configuration_request.py new file mode 100644 index 00000000..5586ecdf --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/update_number_configuration_request.py @@ -0,0 +1,35 @@ +from typing import Optional, Dict +from pydantic import Field, StrictStr +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) +from sinch.domains.numbers.models.v1.utils.validators import ( + validate_sms_voice_configuration, +) + + +class UpdateNumberConfigurationRequest(BaseModelConfigurationRequest): + phone_number: StrictStr = Field( + alias="phoneNumber", + description="Phone number in E.164 format with leading '+'. Example: '+12025550134'.", + ) + display_name: Optional[StrictStr] = Field( + default=None, alias="displayName" + ) + sms_configuration: Optional[Dict] = Field( + default=None, alias="smsConfiguration" + ) + voice_configuration: Optional[Dict] = Field( + default=None, alias="voiceConfiguration" + ) + event_destination_target: Optional[StrictStr] = Field( + default=None, alias="callbackUrl" + ) + + def __init__(self, **data): + """ + Custom initializer to validate nested dictionaries. + """ + if data.get("sms_configuration") or data.get("voice_configuration"): + validate_sms_voice_configuration(data) + super().__init__(**data) diff --git a/sinch/domains/numbers/models/v1/internal/voice_configuration_custom_request.py b/sinch/domains/numbers/models/v1/internal/voice_configuration_custom_request.py new file mode 100644 index 00000000..785515c7 --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/voice_configuration_custom_request.py @@ -0,0 +1,8 @@ +from pydantic import StrictStr +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class VoiceConfigurationCustom(BaseModelConfigurationRequest): + type: StrictStr diff --git a/sinch/domains/numbers/models/v1/internal/voice_configuration_est_request.py b/sinch/domains/numbers/models/v1/internal/voice_configuration_est_request.py new file mode 100644 index 00000000..10d85df0 --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/voice_configuration_est_request.py @@ -0,0 +1,10 @@ +from typing import Optional, Literal +from pydantic import Field, StrictStr +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class VoiceConfigurationEST(BaseModelConfigurationRequest): + type: Literal["EST"] = "EST" + trunk_id: Optional[StrictStr] = Field(default=None, alias="trunkId") diff --git a/sinch/domains/numbers/models/v1/internal/voice_configuration_fax_request.py b/sinch/domains/numbers/models/v1/internal/voice_configuration_fax_request.py new file mode 100644 index 00000000..798615f8 --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/voice_configuration_fax_request.py @@ -0,0 +1,10 @@ +from typing import Optional, Literal +from pydantic import Field, StrictStr +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class VoiceConfigurationFAX(BaseModelConfigurationRequest): + type: Literal["FAX"] = "FAX" + service_id: Optional[StrictStr] = Field(default=None, alias="serviceId") diff --git a/sinch/domains/numbers/models/v1/internal/voice_configuration_request.py b/sinch/domains/numbers/models/v1/internal/voice_configuration_request.py new file mode 100644 index 00000000..39f0398d --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/voice_configuration_request.py @@ -0,0 +1,24 @@ +from typing import Optional, Literal +from pydantic import Field, StrictStr +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class VoiceConfigurationFAX(BaseModelConfigurationRequest): + type: Literal["FAX"] = "FAX" + service_id: Optional[StrictStr] = Field(default=None, alias="serviceId") + + +class VoiceConfigurationEST(BaseModelConfigurationRequest): + type: Literal["EST"] = "EST" + trunk_id: Optional[StrictStr] = Field(default=None, alias="trunkId") + + +class VoiceConfigurationRTC(BaseModelConfigurationRequest): + type: Literal["RTC"] = "RTC" + app_id: Optional[StrictStr] = Field(default=None, alias="appId") + + +class VoiceConfigurationCustom(BaseModelConfigurationRequest): + type: StrictStr diff --git a/sinch/domains/numbers/models/v1/internal/voice_configuration_rtc_request.py b/sinch/domains/numbers/models/v1/internal/voice_configuration_rtc_request.py new file mode 100644 index 00000000..ada4c448 --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/voice_configuration_rtc_request.py @@ -0,0 +1,10 @@ +from typing import Optional, Literal +from pydantic import Field, StrictStr +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class VoiceConfigurationRTC(BaseModelConfigurationRequest): + type: Literal["RTC"] = "RTC" + app_id: Optional[StrictStr] = Field(default=None, alias="appId") diff --git a/sinch/domains/numbers/models/v1/response/__init__.py b/sinch/domains/numbers/models/v1/response/__init__.py new file mode 100644 index 00000000..d96ad096 --- /dev/null +++ b/sinch/domains/numbers/models/v1/response/__init__.py @@ -0,0 +1,17 @@ +from sinch.domains.numbers.models.v1.response.active_number import ActiveNumber +from sinch.domains.numbers.models.v1.response.available_number import ( + AvailableNumber, +) +from sinch.domains.numbers.models.v1.response.available_region import ( + AvailableRegion, +) +from sinch.domains.numbers.models.v1.response.event_destination_response import ( + EventDestinationResponse, +) + +__all__ = [ + "ActiveNumber", + "AvailableNumber", + "AvailableRegion", + "EventDestinationResponse", +] diff --git a/sinch/domains/numbers/models/v1/response/active_number.py b/sinch/domains/numbers/models/v1/response/active_number.py new file mode 100644 index 00000000..0306abab --- /dev/null +++ b/sinch/domains/numbers/models/v1/response/active_number.py @@ -0,0 +1,48 @@ +from datetime import datetime +from typing import Optional +from pydantic import StrictStr, Field, StrictInt, conlist +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.numbers.models.v1.shared import Money, SmsConfiguration +from sinch.domains.numbers.models.v1.types import ( + CapabilityType, + NumberType, + VoiceConfiguration, +) + + +class ActiveNumber(BaseModelConfigurationResponse): + phone_number: Optional[StrictStr] = Field( + default=None, + alias="phoneNumber", + description="Phone number in E.164 format with leading '+'. Example: '+12025550134'.", + ) + project_id: Optional[StrictStr] = Field(default=None, alias="projectId") + display_name: Optional[StrictStr] = Field( + default=None, alias="displayName" + ) + region_code: Optional[StrictStr] = Field( + default=None, + alias="regionCode", + description="ISO 3166-1 alpha-2 country code. Example: US, GB or SE.", + ) + type: Optional[NumberType] = Field(default=None) + capabilities: Optional[conlist(CapabilityType)] = Field(default=None) + money: Optional[Money] = Field(default=None) + payment_interval_months: Optional[StrictInt] = Field( + default=None, alias="paymentIntervalMonths" + ) + next_charge_date: Optional[datetime] = Field( + default=None, alias="nextChargeDate" + ) + expire_at: Optional[datetime] = Field(default=None, alias="expireAt") + sms_configuration: Optional[SmsConfiguration] = Field( + default=None, alias="smsConfiguration" + ) + voice_configuration: Optional[VoiceConfiguration] = Field( + default=None, alias="voiceConfiguration" + ) + event_destination_target: Optional[StrictStr] = Field( + default=None, alias="callbackUrl" + ) diff --git a/sinch/domains/numbers/models/v1/response/available_number.py b/sinch/domains/numbers/models/v1/response/available_number.py new file mode 100644 index 00000000..e5383c21 --- /dev/null +++ b/sinch/domains/numbers/models/v1/response/available_number.py @@ -0,0 +1,30 @@ +from typing import Optional +from pydantic import Field, StrictBool, StrictInt, StrictStr, conlist +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.numbers.models.v1.shared import Money +from sinch.domains.numbers.models.v1.types import CapabilityType, NumberType + + +class AvailableNumber(BaseModelConfigurationResponse): + phone_number: Optional[StrictStr] = Field( + default=None, + alias="phoneNumber", + description="Phone number in E.164 format with leading '+'. Example: '+12025550134'.", + ) + region_code: Optional[StrictStr] = Field( + default=None, + alias="regionCode", + description="ISO 3166-1 alpha-2 country code. Example: US, GB or SE.", + ) + type: Optional[NumberType] = Field(default=None) + capability: Optional[conlist(CapabilityType)] = Field(default=None) + setup_price: Optional[Money] = Field(default=None, alias="setupPrice") + monthly_price: Optional[Money] = Field(default=None, alias="monthlyPrice") + payment_interval_months: Optional[StrictInt] = Field( + default=None, alias="paymentIntervalMonths" + ) + supporting_documentation_required: Optional[StrictBool] = Field( + default=None, alias="supportingDocumentationRequired" + ) diff --git a/sinch/domains/numbers/models/v1/response/available_region.py b/sinch/domains/numbers/models/v1/response/available_region.py new file mode 100644 index 00000000..58aa42b1 --- /dev/null +++ b/sinch/domains/numbers/models/v1/response/available_region.py @@ -0,0 +1,16 @@ +from typing import Optional +from pydantic import StrictStr, Field, conlist +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.numbers.models.v1.types import NumberType + + +class AvailableRegion(BaseModelConfigurationResponse): + region_code: Optional[StrictStr] = Field( + default=None, + alias="regionCode", + description="ISO 3166-1 alpha-2 country code. Example: US, GB or SE.", + ) + region_name: Optional[StrictStr] = Field(default=None, alias="regionName") + types: Optional[conlist(NumberType)] = Field(default=None) diff --git a/sinch/domains/numbers/models/v1/response/event_destination_response.py b/sinch/domains/numbers/models/v1/response/event_destination_response.py new file mode 100644 index 00000000..b94d915a --- /dev/null +++ b/sinch/domains/numbers/models/v1/response/event_destination_response.py @@ -0,0 +1,10 @@ +from typing import Optional +from pydantic import StrictStr, Field +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class EventDestinationResponse(BaseModelConfigurationResponse): + project_id: Optional[StrictStr] = Field(default=None, alias="projectId") + hmac_secret: Optional[StrictStr] = Field(default=None, alias="hmacSecret") diff --git a/sinch/domains/numbers/models/v1/shared/__init__.py b/sinch/domains/numbers/models/v1/shared/__init__.py new file mode 100644 index 00000000..63332c08 --- /dev/null +++ b/sinch/domains/numbers/models/v1/shared/__init__.py @@ -0,0 +1,51 @@ +from sinch.domains.numbers.models.v1.shared.money import Money +from sinch.domains.numbers.models.v1.shared.number_pattern import NumberPattern +from sinch.domains.numbers.models.v1.shared.scheduled_sms_provisioning import ( + ScheduledSmsProvisioning, +) +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_common import ( + ScheduledVoiceProvisioningCommon, +) +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_custom import ( + ScheduledVoiceProvisioningCustom, +) +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_est import ( + ScheduledVoiceProvisioningEST, +) +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_fax import ( + ScheduledVoiceProvisioningFAX, +) +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_rtc import ( + ScheduledVoiceProvisioningRTC, +) +from sinch.domains.numbers.models.v1.shared.sms_configuration import ( + SmsConfiguration, +) +from sinch.domains.numbers.models.v1.shared.sms_configuration_base import ( + SmsConfigurationBase, +) +from sinch.domains.numbers.models.v1.shared.voice_configuration_est import ( + VoiceConfigurationEST, +) +from sinch.domains.numbers.models.v1.shared.voice_configuration_rtc import ( + VoiceConfigurationRTC, +) +from sinch.domains.numbers.models.v1.shared.voice_configuration_fax import ( + VoiceConfigurationFAX, +) + +__all__ = [ + "Money", + "NumberPattern", + "ScheduledSmsProvisioning", + "ScheduledVoiceProvisioningCommon", + "ScheduledVoiceProvisioningCustom", + "ScheduledVoiceProvisioningEST", + "ScheduledVoiceProvisioningFAX", + "ScheduledVoiceProvisioningRTC", + "SmsConfiguration", + "SmsConfigurationBase", + "VoiceConfigurationEST", + "VoiceConfigurationRTC", + "VoiceConfigurationFAX", +] diff --git a/sinch/domains/numbers/models/v1/shared/money.py b/sinch/domains/numbers/models/v1/shared/money.py new file mode 100644 index 00000000..d55d9264 --- /dev/null +++ b/sinch/domains/numbers/models/v1/shared/money.py @@ -0,0 +1,10 @@ +from typing import Optional +from pydantic import StrictStr, Field, condecimal +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class Money(BaseModelConfigurationResponse): + currency_code: Optional[StrictStr] = Field(alias="currencyCode") + amount: Optional[condecimal()] = None diff --git a/sinch/domains/numbers/models/v1/shared/number_pattern.py b/sinch/domains/numbers/models/v1/shared/number_pattern.py new file mode 100644 index 00000000..b2ee848f --- /dev/null +++ b/sinch/domains/numbers/models/v1/shared/number_pattern.py @@ -0,0 +1,13 @@ +from typing import Optional +from pydantic import StrictStr, Field +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) +from sinch.domains.numbers.models.v1.types import NumberSearchPatternType + + +class NumberPattern(BaseModelConfigurationRequest): + pattern: Optional[StrictStr] + search_pattern: Optional[NumberSearchPatternType] = Field( + alias="searchPattern" + ) diff --git a/sinch/domains/numbers/models/v1/shared/scheduled_sms_provisioning.py b/sinch/domains/numbers/models/v1/shared/scheduled_sms_provisioning.py new file mode 100644 index 00000000..172740ea --- /dev/null +++ b/sinch/domains/numbers/models/v1/shared/scheduled_sms_provisioning.py @@ -0,0 +1,24 @@ +from datetime import datetime +from typing import Optional +from pydantic import StrictStr, Field, conlist +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.numbers.models.v1.types.status_scheduled_provisioning import ( + StatusScheduledProvisioning, +) +from sinch.domains.numbers.models.v1.types.sms_error_code import SmsErrorCode + + +class ScheduledSmsProvisioning(BaseModelConfigurationResponse): + service_plan_id: Optional[StrictStr] = Field( + default=None, alias="servicePlanId" + ) + campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") + status: Optional[StatusScheduledProvisioning] = None + last_updated_time: Optional[datetime] = Field( + default=None, alias="lastUpdatedTime" + ) + error_codes: Optional[conlist(SmsErrorCode)] = Field( + default=None, alias="errorCodes" + ) diff --git a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_common.py b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_common.py new file mode 100644 index 00000000..549bd015 --- /dev/null +++ b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_common.py @@ -0,0 +1,20 @@ +from datetime import datetime +from typing import Optional +from pydantic import Field +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.numbers.models.v1.types.status_scheduled_provisioning import ( + StatusScheduledProvisioning, +) +from sinch.domains.numbers.models.v1.types.voice_application_type import ( + VoiceApplicationType, +) + + +class ScheduledVoiceProvisioningCommon(BaseModelConfigurationResponse): + type: VoiceApplicationType + last_updated_time: Optional[datetime] = Field( + default=None, alias="lastUpdatedTime" + ) + status: Optional[StatusScheduledProvisioning] = None diff --git a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_custom.py b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_custom.py new file mode 100644 index 00000000..508f560a --- /dev/null +++ b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_custom.py @@ -0,0 +1,8 @@ +from pydantic import StrictStr +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class ScheduledVoiceProvisioningCustom(BaseModelConfigurationResponse): + type: StrictStr diff --git a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_est.py b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_est.py new file mode 100644 index 00000000..0a3018f3 --- /dev/null +++ b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_est.py @@ -0,0 +1,9 @@ +from typing import Optional +from pydantic import StrictStr, Field +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_common import ( + ScheduledVoiceProvisioningCommon, +) + + +class ScheduledVoiceProvisioningEST(ScheduledVoiceProvisioningCommon): + trunk_id: Optional[StrictStr] = Field(default=None, alias="trunkId") diff --git a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_fax.py b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_fax.py new file mode 100644 index 00000000..d5ab4f44 --- /dev/null +++ b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_fax.py @@ -0,0 +1,9 @@ +from typing import Optional +from pydantic import StrictStr, Field +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_common import ( + ScheduledVoiceProvisioningCommon, +) + + +class ScheduledVoiceProvisioningFAX(ScheduledVoiceProvisioningCommon): + service_id: Optional[StrictStr] = Field(default=None, alias="serviceId") diff --git a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_rtc.py b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_rtc.py new file mode 100644 index 00000000..65127b74 --- /dev/null +++ b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_rtc.py @@ -0,0 +1,9 @@ +from typing import Optional +from pydantic import StrictStr, Field +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_common import ( + ScheduledVoiceProvisioningCommon, +) + + +class ScheduledVoiceProvisioningRTC(ScheduledVoiceProvisioningCommon): + app_id: Optional[StrictStr] = Field(default=None, alias="appId") diff --git a/sinch/domains/numbers/models/v1/shared/sms_configuration.py b/sinch/domains/numbers/models/v1/shared/sms_configuration.py new file mode 100644 index 00000000..d91d01c1 --- /dev/null +++ b/sinch/domains/numbers/models/v1/shared/sms_configuration.py @@ -0,0 +1,14 @@ +from typing import Optional +from pydantic import Field +from sinch.domains.numbers.models.v1.shared.scheduled_sms_provisioning import ( + ScheduledSmsProvisioning, +) +from sinch.domains.numbers.models.v1.shared.sms_configuration_base import ( + SmsConfigurationBase, +) + + +class SmsConfiguration(SmsConfigurationBase): + scheduled_provisioning: Optional[ScheduledSmsProvisioning] = Field( + default=None, alias="scheduledProvisioning" + ) diff --git a/sinch/domains/numbers/models/v1/shared/sms_configuration_base.py b/sinch/domains/numbers/models/v1/shared/sms_configuration_base.py new file mode 100644 index 00000000..33ec021c --- /dev/null +++ b/sinch/domains/numbers/models/v1/shared/sms_configuration_base.py @@ -0,0 +1,10 @@ +from typing import Optional +from pydantic import StrictStr, Field +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class SmsConfigurationBase(BaseModelConfigurationResponse): + service_plan_id: StrictStr = Field(alias="servicePlanId") + campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") diff --git a/sinch/domains/numbers/models/v1/shared/voice_configuration_common.py b/sinch/domains/numbers/models/v1/shared/voice_configuration_common.py new file mode 100644 index 00000000..153e6065 --- /dev/null +++ b/sinch/domains/numbers/models/v1/shared/voice_configuration_common.py @@ -0,0 +1,17 @@ +from datetime import datetime +from typing import Literal, Optional, Union +from pydantic import Field, StrictStr +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.numbers.models.v1.types import ScheduledVoiceProvisioning + + +class VoiceConfigurationCommon(BaseModelConfigurationResponse): + type: Optional[Union[Literal["RTC", "EST", "FAX"], StrictStr]] + last_updated_time: Optional[datetime] = Field( + default=None, alias="lastUpdatedTime" + ) + scheduled_voice_provisioning: Optional[ScheduledVoiceProvisioning] = Field( + default=None, alias="scheduledVoiceProvisioning" + ) diff --git a/sinch/domains/numbers/models/v1/shared/voice_configuration_est.py b/sinch/domains/numbers/models/v1/shared/voice_configuration_est.py new file mode 100644 index 00000000..62cb1cd5 --- /dev/null +++ b/sinch/domains/numbers/models/v1/shared/voice_configuration_est.py @@ -0,0 +1,8 @@ +from pydantic import Field, StrictStr +from sinch.domains.numbers.models.v1.shared.voice_configuration_common import ( + VoiceConfigurationCommon, +) + + +class VoiceConfigurationEST(VoiceConfigurationCommon): + trunk_id: StrictStr = Field(default=None, alias="trunkId") diff --git a/sinch/domains/numbers/models/v1/shared/voice_configuration_fax.py b/sinch/domains/numbers/models/v1/shared/voice_configuration_fax.py new file mode 100644 index 00000000..34f09374 --- /dev/null +++ b/sinch/domains/numbers/models/v1/shared/voice_configuration_fax.py @@ -0,0 +1,8 @@ +from pydantic import Field, StrictStr +from sinch.domains.numbers.models.v1.shared.voice_configuration_common import ( + VoiceConfigurationCommon, +) + + +class VoiceConfigurationFAX(VoiceConfigurationCommon): + service_id: StrictStr = Field(default=None, alias="serviceId") diff --git a/sinch/domains/numbers/models/v1/shared/voice_configuration_rtc.py b/sinch/domains/numbers/models/v1/shared/voice_configuration_rtc.py new file mode 100644 index 00000000..66d8dd9c --- /dev/null +++ b/sinch/domains/numbers/models/v1/shared/voice_configuration_rtc.py @@ -0,0 +1,8 @@ +from pydantic import Field, StrictStr +from sinch.domains.numbers.models.v1.shared.voice_configuration_common import ( + VoiceConfigurationCommon, +) + + +class VoiceConfigurationRTC(VoiceConfigurationCommon): + app_id: StrictStr = Field(default=None, alias="appId") diff --git a/sinch/domains/numbers/models/v1/types/__init__.py b/sinch/domains/numbers/models/v1/types/__init__.py new file mode 100644 index 00000000..0d4d9f6c --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/__init__.py @@ -0,0 +1,61 @@ +from sinch.domains.numbers.models.v1.types.capability_type import ( + CapabilityType, +) +from sinch.domains.numbers.models.v1.types.number_search_pattern_type import ( + NumberSearchPatternType, +) +from sinch.domains.numbers.models.v1.types.number_pattern_dict import ( + NumberPatternDict, +) +from sinch.domains.numbers.models.v1.types.number_type import NumberType +from sinch.domains.numbers.models.v1.types.order_by_type import OrderByType +from sinch.domains.numbers.models.v1.types.scheduled_voice_provisioning import ( + ScheduledVoiceProvisioning, +) +from sinch.domains.numbers.models.v1.types.sms_configuration_dict import ( + SmsConfigurationDict, +) +from sinch.domains.numbers.models.v1.types.sms_error_code import SmsErrorCode +from sinch.domains.numbers.models.v1.types.status_scheduled_provisioning import ( + StatusScheduledProvisioning, +) +from sinch.domains.numbers.models.v1.types.voice_application_type import ( + VoiceApplicationType, +) +from sinch.domains.numbers.models.v1.types.voice_configuration import ( + VoiceConfiguration, +) +from sinch.domains.numbers.models.v1.types.voice_configuration_dict import ( + VoiceConfigurationDict, +) +from sinch.domains.numbers.models.v1.types.voice_configuration_est_dict import ( + VoiceConfigurationESTDict, +) +from sinch.domains.numbers.models.v1.types.voice_configuration_fax_dict import ( + VoiceConfigurationFAXDict, +) +from sinch.domains.numbers.models.v1.types.voice_configuration_rtc_dict import ( + VoiceConfigurationRTCDict, +) +from sinch.domains.numbers.models.v1.types.voice_configuration_custom_dict import ( + VoiceConfigurationCustomDict, +) + +__all__ = [ + "CapabilityType", + "NumberPatternDict", + "NumberSearchPatternType", + "NumberType", + "OrderByType", + "ScheduledVoiceProvisioning", + "SmsConfigurationDict", + "SmsErrorCode", + "StatusScheduledProvisioning", + "VoiceApplicationType", + "VoiceConfiguration", + "VoiceConfigurationCustomDict", + "VoiceConfigurationESTDict", + "VoiceConfigurationFAXDict", + "VoiceConfigurationRTCDict", + "VoiceConfigurationDict", +] diff --git a/sinch/domains/numbers/models/v1/types/capability_type.py b/sinch/domains/numbers/models/v1/types/capability_type.py new file mode 100644 index 00000000..ab5dab80 --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/capability_type.py @@ -0,0 +1,5 @@ +from pydantic import StrictStr +from typing import Literal, Union + + +CapabilityType = Union[Literal["SMS", "VOICE"], StrictStr] diff --git a/sinch/domains/numbers/models/v1/types/number_pattern_dict.py b/sinch/domains/numbers/models/v1/types/number_pattern_dict.py new file mode 100644 index 00000000..2e674f7f --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/number_pattern_dict.py @@ -0,0 +1,8 @@ +from typing import TypedDict +from typing_extensions import NotRequired +from sinch.domains.numbers.models.v1.types import NumberSearchPatternType + + +class NumberPatternDict(TypedDict): + pattern: NotRequired[str] + search_pattern: NotRequired[NumberSearchPatternType] diff --git a/sinch/domains/numbers/models/v1/types/number_search_pattern_type.py b/sinch/domains/numbers/models/v1/types/number_search_pattern_type.py new file mode 100644 index 00000000..23208cbf --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/number_search_pattern_type.py @@ -0,0 +1,5 @@ +from typing import Union, Literal +from pydantic import StrictStr + + +NumberSearchPatternType = Union[Literal["START", "CONTAINS", "END"], StrictStr] diff --git a/sinch/domains/numbers/models/v1/types/number_type.py b/sinch/domains/numbers/models/v1/types/number_type.py new file mode 100644 index 00000000..fcefdf3d --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/number_type.py @@ -0,0 +1,5 @@ +from typing import Union, Literal +from pydantic import StrictStr + + +NumberType = Union[Literal["MOBILE", "LOCAL", "TOLL_FREE"], StrictStr] diff --git a/sinch/domains/numbers/models/v1/types/order_by_type.py b/sinch/domains/numbers/models/v1/types/order_by_type.py new file mode 100644 index 00000000..ed46cdc5 --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/order_by_type.py @@ -0,0 +1,5 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +OrderByType = Union[Literal["PHONE_NUMBER", "DISPLAY_NAME"], StrictStr] diff --git a/sinch/domains/numbers/models/v1/types/scheduled_voice_provisioning.py b/sinch/domains/numbers/models/v1/types/scheduled_voice_provisioning.py new file mode 100644 index 00000000..d002d638 --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/scheduled_voice_provisioning.py @@ -0,0 +1,21 @@ +from typing import Union +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_est import ( + ScheduledVoiceProvisioningEST, +) +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_fax import ( + ScheduledVoiceProvisioningFAX, +) +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_rtc import ( + ScheduledVoiceProvisioningRTC, +) +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_custom import ( + ScheduledVoiceProvisioningCustom, +) + + +ScheduledVoiceProvisioning = Union[ + ScheduledVoiceProvisioningEST, + ScheduledVoiceProvisioningFAX, + ScheduledVoiceProvisioningRTC, + ScheduledVoiceProvisioningCustom, +] diff --git a/sinch/domains/numbers/models/v1/types/sms_configuration_dict.py b/sinch/domains/numbers/models/v1/types/sms_configuration_dict.py new file mode 100644 index 00000000..65c78434 --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/sms_configuration_dict.py @@ -0,0 +1,7 @@ +from typing import TypedDict +from typing_extensions import NotRequired + + +class SmsConfigurationDict(TypedDict): + service_plan_id: str + campaign_id: NotRequired[str] diff --git a/sinch/domains/numbers/models/v1/types/sms_error_code.py b/sinch/domains/numbers/models/v1/types/sms_error_code.py new file mode 100644 index 00000000..3847e7ec --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/sms_error_code.py @@ -0,0 +1,27 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +SmsErrorCode = Union[ + Literal[ + "ERROR_CODE_UNSPECIFIED", + "INTERNAL_ERROR", + "SMS_PROVISIONING_FAILED", + "CAMPAIGN_PROVISIONING_FAILED", + "CAMPAIGN_NOT_AVAILABLE", + "EXCEEDED_10DLC_LIMIT", + "NUMBER_PROVISIONING_FAILED", + "PARTNER_SERVICE_UNAVAILABLE", + "CAMPAIGN_PENDING_ACCEPTANCE", + "MNO_SHARING_ERROR", + "CAMPAIGN_EXPIRED", + "CAMPAIGN_MNO_REJECTED", + "CAMPAIGN_MNO_SUSPENDED", + "CAMPAIGN_MNO_REVIEW", + "INSUFFICIENT_BALANCE", + "MOCK_CAMPAIGN_NOT_ALLOWED", + "TFN_NOT_ALLOWED", + "INVALID_NNID", + ], + StrictStr, +] diff --git a/sinch/domains/numbers/models/v1/types/status_scheduled_provisioning.py b/sinch/domains/numbers/models/v1/types/status_scheduled_provisioning.py new file mode 100644 index 00000000..3103f0d4 --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/status_scheduled_provisioning.py @@ -0,0 +1,10 @@ +from typing import Union, Literal +from pydantic import StrictStr + + +StatusScheduledProvisioning = Union[ + Literal[ + "WAITING", "IN_PROGRESS", "FAILED", "PROVISIONING_STATUS_UNSPECIFIED" + ], + StrictStr, +] diff --git a/sinch/domains/numbers/models/v1/types/voice_application_type.py b/sinch/domains/numbers/models/v1/types/voice_application_type.py new file mode 100644 index 00000000..6c9d4f83 --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/voice_application_type.py @@ -0,0 +1,5 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +VoiceApplicationType = Union[Literal["RTC", "EST", "FAX"], StrictStr] diff --git a/sinch/domains/numbers/models/v1/types/voice_configuration.py b/sinch/domains/numbers/models/v1/types/voice_configuration.py new file mode 100644 index 00000000..24aa27c4 --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/voice_configuration.py @@ -0,0 +1,14 @@ +from typing import Union +from sinch.domains.numbers.models.v1.shared.voice_configuration_est import ( + VoiceConfigurationEST, +) +from sinch.domains.numbers.models.v1.shared.voice_configuration_rtc import ( + VoiceConfigurationRTC, +) +from sinch.domains.numbers.models.v1.shared.voice_configuration_fax import ( + VoiceConfigurationFAX, +) + +VoiceConfiguration = Union[ + VoiceConfigurationEST, VoiceConfigurationRTC, VoiceConfigurationFAX +] diff --git a/sinch/domains/numbers/models/v1/types/voice_configuration_custom_dict.py b/sinch/domains/numbers/models/v1/types/voice_configuration_custom_dict.py new file mode 100644 index 00000000..acaa0bdd --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/voice_configuration_custom_dict.py @@ -0,0 +1,5 @@ +from typing_extensions import TypedDict + + +class VoiceConfigurationCustomDict(TypedDict): + type: str diff --git a/sinch/domains/numbers/models/v1/types/voice_configuration_dict.py b/sinch/domains/numbers/models/v1/types/voice_configuration_dict.py new file mode 100644 index 00000000..9655d736 --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/voice_configuration_dict.py @@ -0,0 +1,25 @@ +from typing import Union, Annotated +from pydantic import Field +from sinch.domains.numbers.models.v1.types.voice_configuration_est_dict import ( + VoiceConfigurationESTDict, +) +from sinch.domains.numbers.models.v1.types.voice_configuration_rtc_dict import ( + VoiceConfigurationRTCDict, +) +from sinch.domains.numbers.models.v1.types.voice_configuration_fax_dict import ( + VoiceConfigurationFAXDict, +) +from sinch.domains.numbers.models.v1.types.voice_configuration_custom_dict import ( + VoiceConfigurationCustomDict, +) + + +VoiceConfigurationDict = Annotated[ + Union[ + VoiceConfigurationFAXDict, + VoiceConfigurationRTCDict, + VoiceConfigurationESTDict, + VoiceConfigurationCustomDict, + ], + Field(discriminator="type"), +] diff --git a/sinch/domains/numbers/models/v1/types/voice_configuration_est_dict.py b/sinch/domains/numbers/models/v1/types/voice_configuration_est_dict.py new file mode 100644 index 00000000..a7c87495 --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/voice_configuration_est_dict.py @@ -0,0 +1,7 @@ +from typing import TypedDict, Literal +from typing_extensions import NotRequired + + +class VoiceConfigurationESTDict(TypedDict): + type: Literal["EST"] + trunk_id: NotRequired[str] diff --git a/sinch/domains/numbers/models/v1/types/voice_configuration_fax_dict.py b/sinch/domains/numbers/models/v1/types/voice_configuration_fax_dict.py new file mode 100644 index 00000000..5b00a9a9 --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/voice_configuration_fax_dict.py @@ -0,0 +1,7 @@ +from typing import TypedDict, Literal +from typing_extensions import NotRequired + + +class VoiceConfigurationFAXDict(TypedDict): + type: Literal["FAX"] + service_id: NotRequired[str] diff --git a/sinch/domains/numbers/models/v1/types/voice_configuration_rtc_dict.py b/sinch/domains/numbers/models/v1/types/voice_configuration_rtc_dict.py new file mode 100644 index 00000000..3e9e41ed --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/voice_configuration_rtc_dict.py @@ -0,0 +1,7 @@ +from typing import TypedDict, Literal +from typing_extensions import NotRequired + + +class VoiceConfigurationRTCDict(TypedDict): + type: Literal["RTC"] + app_id: NotRequired[str] diff --git a/sinch/domains/numbers/models/available/__init__.py b/sinch/domains/numbers/models/v1/utils/__init__.py similarity index 100% rename from sinch/domains/numbers/models/available/__init__.py rename to sinch/domains/numbers/models/v1/utils/__init__.py diff --git a/sinch/domains/numbers/models/v1/utils/validators.py b/sinch/domains/numbers/models/v1/utils/validators.py new file mode 100644 index 00000000..27729926 --- /dev/null +++ b/sinch/domains/numbers/models/v1/utils/validators.py @@ -0,0 +1,58 @@ +from typing import Dict, Any +from sinch.domains.numbers.models.v1.internal.sms_configuration_request import ( + SmsConfigurationRequest, +) +from sinch.domains.numbers.models.v1.internal.voice_configuration_request import ( + VoiceConfigurationRTC, + VoiceConfigurationEST, + VoiceConfigurationFAX, + VoiceConfigurationCustom, +) +from sinch.domains.numbers.models.v1.shared.number_pattern import NumberPattern + + +def validate_number_pattern(data: Dict[str, Any]) -> None: + """ + Validates `number_pattern` field in request data. + + Args: + data (dict): The request payload. + + Raises: + ValidationError: If validation fails for the number pattern. + """ + for key in ("numberPattern", "number_pattern"): + if key in data and data[key] is not None: + NumberPattern(**data[key]) + + +def validate_sms_voice_configuration(data: Dict[str, Any]) -> None: + """ + Validates `sms_configuration` and `voice_configuration` fields in request data. + + Args: + data (dict): The request payload. + + Raises: + ValidationError: If validation fails for the configurations. + """ + # Validate SMS Configuration + for key in ("smsConfiguration", "sms_configuration"): + if key in data and data[key] is not None: + SmsConfigurationRequest(**data[key]) + + # Validate Voice Configuration + voice_config_map = { + "RTC": VoiceConfigurationRTC, + "EST": VoiceConfigurationEST, + "FAX": VoiceConfigurationFAX, + } + + for key in ("voiceConfiguration", "voice_configuration"): + if key in data and data[key] is not None: + # Handle legacy requests + voice_type = data[key].get("type") or "RTC" + voice_config_class = voice_config_map.get( + voice_type, VoiceConfigurationCustom + ) + voice_config_class(**data[key]) diff --git a/sinch/domains/numbers/models/callbacks/__init__.py b/sinch/domains/numbers/sinch_events/__init__.py similarity index 100% rename from sinch/domains/numbers/models/callbacks/__init__.py rename to sinch/domains/numbers/sinch_events/__init__.py diff --git a/sinch/domains/numbers/sinch_events/v1/__init__.py b/sinch/domains/numbers/sinch_events/v1/__init__.py new file mode 100644 index 00000000..50027ca3 --- /dev/null +++ b/sinch/domains/numbers/sinch_events/v1/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.numbers.sinch_events.v1.sinch_events import ( + SinchEvents, +) + +__all__ = ["SinchEvents"] diff --git a/sinch/domains/numbers/sinch_events/v1/events/__init__.py b/sinch/domains/numbers/sinch_events/v1/events/__init__.py new file mode 100644 index 00000000..94728f9c --- /dev/null +++ b/sinch/domains/numbers/sinch_events/v1/events/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.numbers.sinch_events.v1.events.number_sinch_event import ( + NumberSinchEvent, +) + +__all__ = ["NumberSinchEvent"] diff --git a/sinch/domains/numbers/sinch_events/v1/events/number_sinch_event.py b/sinch/domains/numbers/sinch_events/v1/events/number_sinch_event.py new file mode 100644 index 00000000..43633e2c --- /dev/null +++ b/sinch/domains/numbers/sinch_events/v1/events/number_sinch_event.py @@ -0,0 +1,50 @@ +from datetime import datetime +from typing import Optional, Union, Literal +from pydantic import Field, StrictStr +from sinch.domains.numbers.sinch_events.v1.internal import SinchEvent + + +class NumberSinchEvent(SinchEvent): + event_id: Optional[StrictStr] = Field(default=None, alias="eventId") + timestamp: Optional[datetime] = Field(default=None) + project_id: Optional[StrictStr] = Field(default=None, alias="projectId") + resource_id: Optional[StrictStr] = Field(default=None, alias="resourceId") + resource_type: Optional[Union[Literal["ACTIVE_NUMBER"], StrictStr]] = ( + Field(default=None, alias="resourceType") + ) + event_type: Optional[ + Union[ + Literal[ + "PROVISIONING_TO_CAMPAIGN", + "DEPROVISIONING_FROM_CAMPAIGN", + "PROVISIONING_TO_SMS_PLATFORM", + "DEPROVISIONING_FROM_SMS_PLATFORM", + "PROVISIONING_TO_VOICE_PLATFORM", + "DEPROVISIONING_TO_VOICE_PLATFORM", + ], + StrictStr, + ] + ] = Field(default=None, alias="eventType") + status: Optional[Union[Literal["SUCCEEDED", "FAILED"], StrictStr]] = None + failure_code: Optional[ + Union[ + Literal[ + "CAMPAIGN_EXPIRED", + "CAMPAIGN_MNO_REJECTED", + "CAMPAIGN_MNO_REVIEW", + "CAMPAIGN_MNO_SUSPENDED", + "CAMPAIGN_NOT_AVAILABLE", + "CAMPAIGN_PENDING_ACCEPTANCE", + "CAMPAIGN_PROVISIONING_FAILED", + "EXCEEDED_10DLC_LIMIT", + "INSUFFICIENT_BALANCE", + "INVALID_NNID", + "MNO_SHARING_ERROR", + "MOCK_CAMPAIGN_NOT_ALLOWED", + "NUMBER_PROVISIONING_FAILED", + "PARTNER_SERVICE_UNAVAILABLE", + "TFN_NOT_ALLOWED", + ], + StrictStr, + ] + ] = Field(default=None, alias="failureCode") diff --git a/sinch/domains/numbers/sinch_events/v1/internal/__init__.py b/sinch/domains/numbers/sinch_events/v1/internal/__init__.py new file mode 100644 index 00000000..6af7aa73 --- /dev/null +++ b/sinch/domains/numbers/sinch_events/v1/internal/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.numbers.sinch_events.v1.internal.sinch_event import ( + SinchEvent, +) + +__all__ = ["SinchEvent"] diff --git a/sinch/domains/numbers/sinch_events/v1/internal/sinch_event.py b/sinch/domains/numbers/sinch_events/v1/internal/sinch_event.py new file mode 100644 index 00000000..5ec2f5ce --- /dev/null +++ b/sinch/domains/numbers/sinch_events/v1/internal/sinch_event.py @@ -0,0 +1,9 @@ +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +# Base for NumberSinchEvent used for request modeling. +# Not to be confused with a response as in BaseModelConfigurationResponse. +class SinchEvent(BaseModelConfigurationResponse): + pass diff --git a/sinch/domains/numbers/sinch_events/v1/sinch_events.py b/sinch/domains/numbers/sinch_events/v1/sinch_events.py new file mode 100644 index 00000000..a93708a5 --- /dev/null +++ b/sinch/domains/numbers/sinch_events/v1/sinch_events.py @@ -0,0 +1,70 @@ +from typing import Any, Dict, Optional, Union +from sinch.domains.authentication.sinch_events.v1.authentication_validation import ( + validate_signature_header, +) +from sinch.domains.authentication.sinch_events.v1.sinch_event_utils import ( + decode_payload, + parse_json, + normalize_iso_timestamp, +) +from sinch.domains.numbers.sinch_events.v1.events import NumberSinchEvent + + +class SinchEvents: + def __init__(self, callback_secret: str): + self.callback_secret = callback_secret + + def validate_authentication_header( + self, + headers: Dict[str, str], + json_payload: Union[str, bytes], + ) -> bool: + """ + Validate the authorization header for a callback request + + :param headers: Incoming request's headers + :type headers: Dict[str, str] + :param json_payload: Incoming request's raw body (str or bytes) + :type json_payload: Union[str, bytes] + :returns: True if the X-Sinch-Signature header is valid + :rtype: bool + """ + payload_str = ( + decode_payload(json_payload, headers) + if isinstance(json_payload, bytes) + else json_payload + ) + return validate_signature_header( + self.callback_secret, headers, payload_str + ) + + def parse_event( + self, + event_body: Union[str, bytes, Dict[str, Any]], + headers: Optional[Dict[str, str]] = None, + ) -> NumberSinchEvent: + """ + Parses the event payload into a NumberSinchEvent object. + + Handles a known issue where the server omits timezone information from + the ``timestamp`` field. If the timezone is missing, the method assumes + UTC and returns a timezone-aware ``datetime`` object. + + :param event_body: The event payload (JSON string, raw bytes, or dict). + :type event_body: Union[str, bytes, Dict[str, Any]] + :param headers: Request headers (used to decode charset when event_body is bytes). + :type headers: Optional[Dict[str, str]] + :returns: A parsed Pydantic object with a timezone-aware ``timestamp``. + :rtype: NumberSinchEvent + """ + if isinstance(event_body, bytes): + event_body = parse_json(decode_payload(event_body, headers)) + elif isinstance(event_body, str): + event_body = parse_json(event_body) + timestamp = event_body.get("timestamp") + if timestamp: + event_body["timestamp"] = normalize_iso_timestamp(timestamp) + try: + return NumberSinchEvent(**event_body) + except Exception as e: + raise ValueError(f"Failed to parse event body: {e}") diff --git a/sinch/domains/numbers/virtual_numbers.py b/sinch/domains/numbers/virtual_numbers.py new file mode 100644 index 00000000..a76e88ab --- /dev/null +++ b/sinch/domains/numbers/virtual_numbers.py @@ -0,0 +1,461 @@ +from typing import Optional, overload, List +from sinch.domains.numbers.api.v1 import ( + ActiveNumbers, + AvailableNumbers, + AvailableRegions, + EventDestinations, +) +from sinch.core.pagination import Paginator +from sinch.domains.numbers.models.v1.response import ( + ActiveNumber, + AvailableNumber, +) +from sinch.domains.numbers.models.v1.types import ( + CapabilityType, + NumberSearchPatternType, + NumberType, + OrderByType, + SmsConfigurationDict, + VoiceConfigurationDict, + VoiceConfigurationFAXDict, + VoiceConfigurationRTCDict, + VoiceConfigurationESTDict, + NumberPatternDict, +) +from sinch.domains.numbers.sinch_events.v1 import SinchEvents + + +class VirtualNumbers: + """ + Synchronous version of the Numbers domain. + + Documentation for Sinch virtual Numbers is found at + https://developers.sinch.com/docs/numbers/. + """ + + def __init__(self, sinch): + self._sinch = sinch + self.regions = AvailableRegions(self._sinch) + self.event_destinations = EventDestinations(self._sinch) + + self._active = ActiveNumbers(self._sinch) + self._available = AvailableNumbers(self._sinch) + + def sinch_events(self, callback_secret: str) -> SinchEvents: + """ + Create a Numbers Sinch Events handler with the specified callback secret. + + :param callback_secret: Secret used for webhook validation. + :type callback_secret: str + :returns: A configured Sinch Events handler + :rtype: SinchEvents + """ + return SinchEvents(callback_secret) + + # ====== High-Level Convenience Methods ====== + + def list( + self, + region_code: Optional[str] = None, + number_type: Optional[NumberType] = None, + number_pattern: Optional[str] = None, + number_search_pattern: Optional[NumberSearchPatternType] = None, + capabilities: Optional[List[CapabilityType]] = None, + page_size: Optional[int] = None, + page_token: Optional[str] = None, + order_by: Optional[OrderByType] = None, + **kwargs, + ) -> Paginator[ActiveNumber]: + """ + Search for all active virtual numbers associated with a certain project. + + :param region_code: Optional. ISO 3166-1 alpha-2 country code. Example: US, GB or SE. + :type region_code: Optional[str] + + :param number_type: Optional. Type of number (e.g., "MOBILE", "LOCAL", "TOLL_FREE"). + :type number_type: Optional[NumberType] + + :param number_pattern: Specific sequence of digits to search for. + :type number_pattern: Optional[str] + + :param number_search_pattern: Pattern to apply (e.g., "START", "CONTAINS", "END"). + :type number_search_pattern: Optional[NumberSearchPatternType] + + :param capabilities: Capabilities required for the number (e.g., ["SMS", "VOICE"]). + :type capabilities: Optional[List[CapabilityType]] + + :param page_size: Maximum number of items to return. + :type page_size: int + + :param page_token: Token for the next page of results. + :type page_token: Optional[str] + + :param order_by: Field to order the results by (e.g., "phoneNumber", "displayName"). + :type order_by: Optional[OrderByType] + + :param kwargs: Additional filters for the request. + :type kwargs: dict + + :returns: A paginator for iterating through the results. + :rtype: Paginator[ActiveNumber] + + For detailed documentation, visit https://developers.sinch.com + """ + return self._active.list( + region_code=region_code, + number_type=number_type, + page_size=page_size, + capabilities=capabilities, + number_pattern=number_pattern, + number_search_pattern=number_search_pattern, + page_token=page_token, + order_by=order_by, + **kwargs, + ) + + @overload + def update( + self, + phone_number: str, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationESTDict, + display_name: Optional[str] = None, + event_destination_target: Optional[str] = None, + ) -> ActiveNumber: + pass + + @overload + def update( + self, + phone_number: str, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationFAXDict, + display_name: Optional[str] = None, + event_destination_target: Optional[str] = None, + ) -> ActiveNumber: + pass + + @overload + def update( + self, + phone_number: str, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationRTCDict, + display_name: Optional[str] = None, + event_destination_target: Optional[str] = None, + ) -> ActiveNumber: + pass + + def update( + self, + phone_number: str, + display_name: Optional[str] = None, + sms_configuration: Optional[SmsConfigurationDict] = None, + voice_configuration: Optional[VoiceConfigurationDict] = None, + event_destination_target: Optional[str] = None, + **kwargs, + ) -> ActiveNumber: + """ + Make updates to the configuration of your virtual number. + Update the display name, change the currency type, or reconfigure for either SMS and/or Voice. + + :param phone_number: Phone number in E.164 format with leading '+'. Example: '+12025550134'. + :type phone_number: str + + :param display_name: The display name for the virtual number. + :type display_name: Optional[str] + + :param sms_configuration: A dictionary defining the SMS configuration, including fields such as: ``service_plan_id`` (str), ``campaign_id`` (optional, required for US 10DLC). + :type sms_configuration: Optional[SmsConfigurationDict] + + :param voice_configuration: A dictionary defining the Voice configuration. Supported types include:: + - ``VoiceConfigurationRTCDict``: type ``'RTC'`` with an ``app_id`` field. + - ``VoiceConfigurationESTDict``: type ``'EST'`` with a ``trunk_id`` field. + - ``VoiceConfigurationFAXDict``: type ``'FAX'`` with a ``service_id`` field. + :type voice_configuration: Optional[VoiceConfigurationDict] + + :param event_destination_target: The event destination URL for the virtual number. + :type event_destination_target: Optional[str] + + :param kwargs: Additional parameters for the request. + :type kwargs: dict + + For detailed documentation, visit https://developers.sinch.com + """ + return self._active.update( + phone_number=phone_number, + display_name=display_name, + sms_configuration=sms_configuration, + voice_configuration=voice_configuration, + event_destination_target=event_destination_target, + **kwargs, + ) + + def get(self, phone_number: str, **kwargs) -> ActiveNumber: + """ + Get the configuration settings for your virtual number. + + :param phone_number: Phone number in E.164 format with leading '+'. Example: '+12025550134'. + :type phone_number: str + + :param kwargs: Additional parameters for the request. + :type kwargs: dict + + :returns: The configuration settings for the virtual number. + :rtype: ActiveNumber + + For detailed documentation, visit https://developers.sinch.com + """ + return self._active.get(phone_number=phone_number, **kwargs) + + def release(self, phone_number: str, **kwargs) -> ActiveNumber: + """ + Release virtual numbers you no longer need from your project. + + :param phone_number: Phone number in E.164 format with leading '+'. Example: '+12025550134'. + :type phone_number: str + + :param kwargs: Additional parameters for the request. + :type kwargs: dict + + :returns: The configuration settings of the released virtual number. + :rtype: ActiveNumber + + For detailed documentation, visit https://developers.sinch.com + """ + return self._active.release(phone_number=phone_number, **kwargs) + + def check_availability( + self, phone_number: str, **kwargs + ) -> AvailableNumber: + """ + Enter a specific phone number to check availability. + + :param phone_number: Phone number in E.164 format with leading '+'. Example: '+12025550134'. + :type phone_number: str + + :param kwargs: Additional parameters for the request. + :type kwargs: dict + + :returns: A response object with the availability status of the number. + :rtype: AvailableNumber + + For detailed documentation, visit: https://developers.sinch.com + """ + return self._available.check_availability( + phone_number=phone_number, **kwargs + ) + + @overload + def rent( + self, + phone_number: str, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationESTDict, + event_destination_target: Optional[str] = None, + ) -> ActiveNumber: + pass + + @overload + def rent( + self, + phone_number: str, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationFAXDict, + event_destination_target: Optional[str] = None, + ) -> ActiveNumber: + pass + + @overload + def rent( + self, + phone_number: str, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationRTCDict, + event_destination_target: Optional[str] = None, + ) -> ActiveNumber: + pass + + def rent( + self, + phone_number: str, + sms_configuration: Optional[SmsConfigurationDict] = None, + voice_configuration: Optional[VoiceConfigurationDict] = None, + event_destination_target: Optional[str] = None, + **kwargs, + ) -> ActiveNumber: + """ + Rent a virtual number to use with SMS, Voice, or both products. + + :param phone_number: Phone number in E.164 format with leading '+'. Example: '+12025550134'. + :type phone_number: str + :param sms_configuration: A dictionary defining the SMS configuration, including fields such as: ``service_plan_id`` (str), ``campaign_id`` (optional, required for US 10DLC). + :type sms_configuration: Optional[SmsConfigurationDict] + :param voice_configuration: A dictionary defining the Voice configuration. Supported types include:: + - ``VoiceConfigurationRTCDict``: type ``'RTC'`` with an ``app_id`` field. + - ``VoiceConfigurationESTDict``: type ``'EST'`` with a ``trunk_id`` field. + - ``VoiceConfigurationFAXDict``: type ``'FAX'`` with a ``service_id`` field. + :type voice_configuration: Optional[VoiceConfigurationDict] + :param event_destination_target: The event destination URL to be called. + :type event_destination_target: Optional[str] + :param kwargs: Additional parameters for the request. + :type kwargs: dict + + :returns: A response object with the rented number and its details. + :rtype: ActiveNumber + + For detailed documentation, visit https://developers.sinch.com + """ + return self._available.rent( + phone_number=phone_number, + sms_configuration=sms_configuration, + voice_configuration=voice_configuration, + event_destination_target=event_destination_target, + **kwargs, + ) + + @overload + def rent_any( + self, + region_code: str, + number_type: NumberType, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationRTCDict, + number_pattern: NumberPatternDict, + capabilities: Optional[CapabilityType] = None, + event_destination_target: Optional[str] = None, + ) -> ActiveNumber: + pass + + @overload + def rent_any( + self, + region_code: str, + number_type: NumberType, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationFAXDict, + number_pattern: NumberPatternDict, + capabilities: Optional[List[CapabilityType]] = None, + event_destination_target: Optional[str] = None, + ) -> ActiveNumber: + pass + + @overload + def rent_any( + self, + region_code: str, + number_type: NumberType, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationESTDict, + number_pattern: NumberPatternDict, + capabilities: Optional[List[CapabilityType]] = None, + event_destination_target: Optional[str] = None, + ) -> ActiveNumber: + pass + + def rent_any( + self, + region_code: str, + number_type: NumberType, + number_pattern: Optional[NumberPatternDict] = None, + capabilities: Optional[List[CapabilityType]] = None, + sms_configuration: Optional[SmsConfigurationDict] = None, + voice_configuration: Optional[VoiceConfigurationDict] = None, + event_destination_target: Optional[str] = None, + **kwargs, + ) -> ActiveNumber: + """ + Search for and activate an available Sinch virtual number all in one API call. + Currently, the ``rent_any`` operation works only for US 10DLC numbers. + + :param region_code: ISO 3166-1 alpha-2 country code. Example: US, GB or SE. + :type region_code: str + + :param number_type: Type of number (e.g., ``"MOBILE"``, ``"LOCAL"``, ``"TOLL_FREE"``). Defaults to ``"MOBILE"``. + :type number_type: NumberType + + :param number_pattern: Optional dict with ``pattern`` (str) and ``search_pattern`` (e.g., ``"START"``, ``"CONTAINS"``, ``"END"``). + :type number_pattern: Optional[NumberPatternDict] + + :param capabilities: Capabilities required for the number (e.g., ``["SMS", "VOICE"]``). + :type capabilities: Optional[List[CapabilityType]] + + :param sms_configuration: A dictionary defining the SMS configuration, including fields such as: ``service_plan_id`` (str), ``campaign_id`` (optional, required for US 10DLC). + :type sms_configuration: Optional[SmsConfigurationDict] + + :param voice_configuration: A dictionary defining the Voice configuration. Supported types include:: + - ``VoiceConfigurationRTCDict``: type ``'RTC'`` with an ``app_id`` field. + - ``VoiceConfigurationESTDict``: type ``'EST'`` with a ``trunk_id`` field. + - ``VoiceConfigurationFAXDict``: type ``'FAX'`` with a ``service_id`` field. + :type voice_configuration: Optional[VoiceConfigurationDict] + + :param event_destination_target: The event destination URL to receive notifications. + :type event_destination_target: Optional[str] + + :param kwargs: Additional parameters for the request. + :type kwargs: dict + + :returns: A response object with the activated number and its details. + :rtype: ActiveNumber + + For detailed documentation, visit: https://developers.sinch.com + """ + return self._available.rent_any( + region_code=region_code, + number_type=number_type, + number_pattern=number_pattern, + capabilities=capabilities, + sms_configuration=sms_configuration, + voice_configuration=voice_configuration, + event_destination_target=event_destination_target, + **kwargs, + ) + + def search_for_available_numbers( + self, + region_code: str, + number_type: NumberType, + number_pattern: Optional[str] = None, + number_search_pattern: Optional[NumberSearchPatternType] = None, + capabilities: Optional[List[CapabilityType]] = None, + page_size: Optional[int] = None, + **kwargs, + ) -> Paginator[AvailableNumber]: + """ + Search for available virtual numbers for you to rent using a variety of parameters to filter results. + + :param region_code: ISO 3166-1 alpha-2 country code. Example: US, GB or SE. + :type region_code: str + + :param number_type: Type of number (e.g., ``"MOBILE"``, ``"LOCAL"``, ``"TOLL_FREE"``). + :type number_type: NumberType + + :param number_pattern: Specific sequence of digits to search for. + :type number_pattern: Optional[str] + + :param number_search_pattern: Pattern to apply (e.g., ``"START"``, ``"CONTAINS"``, ``"END"``). + :type number_search_pattern: Optional[NumberSearchPatternType] + + :param capabilities: Capabilities required for the number (e.g., ``["SMS", "VOICE"]``). + :type capabilities: Optional[List[CapabilityType]] + + :param page_size: Maximum number of items to return. + :type page_size: int + + :param kwargs: Additional filters for the request. + :type kwargs: dict + + :returns: A paginator for iterating through the results. + :rtype: Paginator[AvailableNumber] + + For detailed documentation, visit: https://developers.sinch.com + """ + return self._available.search_for_available_numbers( + region_code=region_code, + number_type=number_type, + page_size=page_size, + capabilities=capabilities, + number_pattern=number_pattern, + number_search_pattern=number_search_pattern, + **kwargs, + ) diff --git a/sinch/domains/sms/__init__.py b/sinch/domains/sms/__init__.py index d2223066..120e6d28 100644 --- a/sinch/domains/sms/__init__.py +++ b/sinch/domains/sms/__init__.py @@ -1,669 +1,3 @@ -from sinch.core.pagination import IntBasedPaginator -from sinch.core.pagination import AsyncIntBasedPaginator +from sinch.domains.sms.sms import SMS -from sinch.domains.sms.endpoints.batches.send_batch import SendBatchSMSEndpoint -from sinch.domains.sms.endpoints.batches.list_batches import ListSMSBatchesEndpoint -from sinch.domains.sms.endpoints.batches.get_batch import GetSMSEndpoint -from sinch.domains.sms.endpoints.batches.cancel_batch import CancelBatchEndpoint -from sinch.domains.sms.endpoints.batches.update_batch import UpdateBatchSMSEndpoint -from sinch.domains.sms.endpoints.batches.replace_batch import ReplaceBatchSMSEndpoint -from sinch.domains.sms.endpoints.batches.send_delivery_feedback import SendDeliveryReportEndpoint -from sinch.domains.sms.endpoints.batches.send_batch_dry_run import SendBatchSMSDryRunEndpoint - -from sinch.domains.sms.endpoints.groups.create_group import CreateSMSGroupEndpoint -from sinch.domains.sms.endpoints.groups.list_groups import ListSMSGroupEndpoint -from sinch.domains.sms.endpoints.groups.delete_group import DeleteSMSGroupEndpoint -from sinch.domains.sms.endpoints.groups.get_group import GetSMSGroupEndpoint -from sinch.domains.sms.endpoints.groups.update_group import UpdateSMSGroupEndpoint -from sinch.domains.sms.endpoints.groups.replace_group import ReplaceSMSGroupEndpoint -from sinch.domains.sms.endpoints.groups.get_phone_numbers_for_group import GetSMSGroupPhoneNumbersEndpoint - -from sinch.domains.sms.endpoints.inbounds.list_incoming_messages import ListInboundMessagesEndpoint -from sinch.domains.sms.endpoints.inbounds.get_incoming_message import GetInboundMessagesEndpoint - -from sinch.domains.sms.endpoints.delivery_reports.get_delivery_report_for_number import ( - GetDeliveryReportForNumberEndpoint -) -from sinch.domains.sms.endpoints.delivery_reports.get_delivery_report_for_batch import GetDeliveryReportForBatchEndpoint -from sinch.domains.sms.endpoints.delivery_reports.get_all_delivery_reports_for_project import ( - ListDeliveryReportsEndpoint -) - -from sinch.domains.sms.models.batches.requests import ( - SendBatchRequest, - ListBatchesRequest, - GetBatchRequest, - BatchDryRunRequest, - CancelBatchRequest, - UpdateBatchRequest, - ReplaceBatchRequest, - SendDeliveryFeedbackRequest -) - -from sinch.domains.sms.models.batches.responses import ( - SendSMSBatchResponse, - GetSMSBatchResponse, - CancelSMSBatchResponse, - SendSMSDeliveryFeedbackResponse, - ListSMSBatchesResponse, - UpdateSMSBatchResponse, - ReplaceSMSBatchResponse, - SendSMSBatchDryRunResponse -) - -from sinch.domains.sms.models.groups.requests import ( - CreateSMSGroupRequest, - ListSMSGroupRequest, - DeleteSMSGroupRequest, - GetSMSGroupRequest, - GetSMSGroupPhoneNumbersRequest, - UpdateSMSGroupRequest, - ReplaceSMSGroupPhoneNumbersRequest -) - -from sinch.domains.sms.models.groups.responses import ( - CreateSMSGroupResponse, - SinchDeleteSMSGroupResponse, - UpdateSMSGroupResponse, - SinchListSMSGroupResponse, - ReplaceSMSGroupResponse, - GetSMSGroupResponse, - SinchGetSMSGroupPhoneNumbersResponse -) - -from sinch.domains.sms.models.inbounds.requests import ( - ListSMSInboundMessageRequest, - GetSMSInboundMessageRequest -) - -from sinch.domains.sms.models.inbounds.responses import ( - SinchListInboundMessagesResponse, - GetInboundMessagesResponse -) - -from sinch.domains.sms.models.delivery_reports.requests import ( - ListSMSDeliveryReportsRequest, - GetSMSDeliveryReportForBatchRequest, - GetSMSDeliveryReportForNumberRequest -) - -from sinch.domains.sms.models.delivery_reports.responses import ( - ListSMSDeliveryReportsResponse, - GetSMSDeliveryReportForBatchResponse, - GetSMSDeliveryReportForNumberResponse -) - - -class SMSDeliveryReports: - def __init__(self, sinch): - self._sinch = sinch - - def list( - self, - page: int = 0, - start_date: str = None, - end_date: str = None, - status: str = None, - code: str = None, - page_size: int = None, - client_reference: str = None - ) -> ListSMSDeliveryReportsResponse: - return IntBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListDeliveryReportsEndpoint( - sinch=self._sinch, - request_data=ListSMSDeliveryReportsRequest( - page=page, - page_size=page_size, - start_date=start_date, - end_date=end_date, - status=status, - code=code, - client_reference=client_reference - ) - ) - ) - - def get_for_batch( - self, - batch_id: str, - type_: str = None, - code: list = None, - status: list = None - ) -> GetSMSDeliveryReportForBatchResponse: - return self._sinch.configuration.transport.request( - GetDeliveryReportForBatchEndpoint( - sinch=self._sinch, - request_data=GetSMSDeliveryReportForBatchRequest( - batch_id=batch_id, - type_=type_, - code=code, - status=status - ) - ) - ) - - def get_for_number( - self, - batch_id: str, - recipient_number: str - ) -> GetSMSDeliveryReportForNumberResponse: - return self._sinch.configuration.transport.request( - GetDeliveryReportForNumberEndpoint( - sinch=self._sinch, - request_data=GetSMSDeliveryReportForNumberRequest( - batch_id=batch_id, - recipient_number=recipient_number - ) - ) - ) - - -class SMSDeliveryReportsWithAsyncPagination(SMSDeliveryReports): - async def list( - self, - page: int = 0, - start_date: str = None, - end_date: str = None, - status: str = None, - code: str = None, - page_size: int = None, - client_reference: str = None - ) -> ListSMSDeliveryReportsResponse: - return await AsyncIntBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListDeliveryReportsEndpoint( - sinch=self._sinch, - request_data=ListSMSDeliveryReportsRequest( - page=page, - page_size=page_size, - start_date=start_date, - end_date=end_date, - status=status, - code=code, - client_reference=client_reference - ) - ) - ) - - -class SMSInbounds: - def __init__(self, sinch): - self._sinch = sinch - - def list( - self, - page: int = 0, - start_date: str = None, - to: str = None, - end_date: str = None, - page_size: int = None, - client_reference: str = None - ) -> SinchListInboundMessagesResponse: - return IntBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListInboundMessagesEndpoint( - sinch=self._sinch, - request_data=ListSMSInboundMessageRequest( - page=page, - page_size=page_size, - to=to, - end_date=end_date, - start_date=start_date, - client_reference=client_reference - ) - ) - ) - - def get(self, inbound_id: str) -> GetInboundMessagesResponse: - return self._sinch.configuration.transport.request( - GetInboundMessagesEndpoint( - sinch=self._sinch, - request_data=GetSMSInboundMessageRequest( - inbound_id=inbound_id - ) - ) - ) - - -class SMSInboundsWithAsyncPagination(SMSInbounds): - async def list( - self, - page: int = 0, - start_date: str = None, - to: str = None, - end_date: str = None, - page_size: int = None, - client_reference: str = None - ) -> SinchListInboundMessagesResponse: - return await AsyncIntBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListInboundMessagesEndpoint( - sinch=self._sinch, - request_data=ListSMSInboundMessageRequest( - page=page, - page_size=page_size, - to=to, - end_date=end_date, - start_date=start_date, - client_reference=client_reference - ) - ) - ) - - -class SMSBatches: - def __init__(self, sinch): - self._sinch = sinch - - def send( - self, - body: str, - delivery_report: str, - to: list, - from_: str = None, - parameters: dict = None, - type_: str = None, - send_at: str = None, - expire_at: str = None, - callback_url: str = None, - client_reference: str = None, - feedback_enabled: bool = None, - flash_message: bool = None, - truncate_concat: bool = None, - max_number_of_message_parts: int = None, - from_ton: int = None, - from_npi: int = None - ) -> SendSMSBatchResponse: - return self._sinch.configuration.transport.request( - SendBatchSMSEndpoint( - sinch=self._sinch, - request_data=SendBatchRequest( - to=to, - body=body, - from_=from_, - delivery_report=delivery_report, - feedback_enabled=feedback_enabled, - parameters=parameters, - type_=type_, - send_at=send_at, - expire_at=expire_at, - callback_url=callback_url, - client_reference=client_reference, - flash_message=flash_message, - truncate_concat=truncate_concat, - max_number_of_message_parts=max_number_of_message_parts, - from_npi=from_npi, - from_ton=from_ton - ) - ) - ) - - def list( - self, - page: int = 0, - page_size: int = None, - from_s: str = None, - start_date: str = None, - end_date: str = None, - client_reference: str = None - ) -> ListSMSBatchesResponse: - return IntBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListSMSBatchesEndpoint( - sinch=self._sinch, - request_data=ListBatchesRequest( - page=page, - page_size=page_size, - from_s=from_s, - start_date=start_date, - end_date=end_date, - client_reference=client_reference - ) - ) - ) - - def get(self, batch_id: str) -> GetSMSBatchResponse: - return self._sinch.configuration.transport.request( - GetSMSEndpoint( - sinch=self._sinch, - request_data=GetBatchRequest( - batch_id=batch_id - ) - ) - ) - - def send_dry_run( - self, - to: str, - body: str, - per_recipient: bool = None, - number_of_recipients: int = None, - from_: str = None, - type_: str = None, - udh: str = None, - delivery_report: str = None, - send_at: str = None, - expire_at: str = None, - callback_url: str = None, - flash_message: bool = None, - parameters: dict = None, - client_reference: str = None, - max_number_of_message_parts: int = None - ) -> SendSMSBatchDryRunResponse: - return self._sinch.configuration.transport.request( - SendBatchSMSDryRunEndpoint( - sinch=self._sinch, - request_data=BatchDryRunRequest( - per_recipient=per_recipient, - number_of_recipients=number_of_recipients, - to=to, - body=body, - from_=from_, - delivery_report=delivery_report, - type_=type_, - udh=udh, - send_at=send_at, - expire_at=expire_at, - callback_url=callback_url, - flash_message=flash_message, - parameters=parameters, - client_reference=client_reference, - max_number_of_message_parts=max_number_of_message_parts - ) - ) - ) - - def cancel(self, batch_id: str) -> CancelSMSBatchResponse: - return self._sinch.configuration.transport.request( - CancelBatchEndpoint( - sinch=self._sinch, - request_data=CancelBatchRequest( - batch_id=batch_id - ) - ) - ) - - def update( - self, - batch_id: str, - to_add: list = None, - to_remove: list = None, - from_: str = None, - body: str = None, - delivery_report: str = None, - send_at: str = None, - expire_at: str = None, - callback_url: str = None, - ) -> UpdateSMSBatchResponse: - return self._sinch.configuration.transport.request( - UpdateBatchSMSEndpoint( - sinch=self._sinch, - request_data=UpdateBatchRequest( - batch_id=batch_id, - to_add=to_add, - to_remove=to_remove, - from_=from_, - body=body, - delivery_report=delivery_report, - send_at=send_at, - expire_at=expire_at, - callback_url=callback_url - ) - ) - ) - - def replace( - self, - batch_id: str, - to: str, - body: str, - from_: str = None, - type_: str = None, - udh: str = None, - delivery_report: str = None, - send_at: str = None, - expire_at: str = None, - callback_url: str = None, - flash_message: bool = None, - parameters: dict = None, - client_reference: str = None, - max_number_of_message_parts: int = None - ) -> ReplaceSMSBatchResponse: - return self._sinch.configuration.transport.request( - ReplaceBatchSMSEndpoint( - sinch=self._sinch, - request_data=ReplaceBatchRequest( - batch_id=batch_id, - to=to, - body=body, - from_=from_, - delivery_report=delivery_report, - type_=type_, - udh=udh, - send_at=send_at, - expire_at=expire_at, - callback_url=callback_url, - flash_message=flash_message, - client_reference=client_reference, - max_number_of_message_parts=max_number_of_message_parts, - parameters=parameters - ) - ) - ) - - def send_delivery_feedback( - self, - batch_id: str, - recipients: list - ) -> SendSMSDeliveryFeedbackResponse: - return self._sinch.configuration.transport.request( - SendDeliveryReportEndpoint( - sinch=self._sinch, - request_data=SendDeliveryFeedbackRequest( - batch_id=batch_id, - recipients=recipients - ) - ) - ) - - -class SMSBatchesWithAsyncPagination(SMSBatches): - async def list( - self, - page: int = 0, - page_size: int = None, - from_s: str = None, - start_date: str = None, - end_date: str = None, - client_reference: str = None - ) -> ListSMSBatchesResponse: - return await AsyncIntBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListSMSBatchesEndpoint( - sinch=self._sinch, - request_data=ListBatchesRequest( - page=page, - page_size=page_size, - from_s=from_s, - start_date=start_date, - end_date=end_date, - client_reference=client_reference - ) - ) - ) - - -class SMSGroups: - def __init__(self, sinch): - self._sinch = sinch - - def create( - self, - name: str, - members: list = None, - child_groups: list = None, - auto_update: dict = None - ) -> CreateSMSGroupResponse: - return self._sinch.configuration.transport.request( - CreateSMSGroupEndpoint( - sinch=self._sinch, - request_data=CreateSMSGroupRequest( - name=name, - members=members, - child_groups=child_groups, - auto_update=auto_update - ) - ) - ) - - def list( - self, - page=0, - page_size=None - ) -> SinchListSMSGroupResponse: - return IntBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListSMSGroupEndpoint( - sinch=self._sinch, - request_data=ListSMSGroupRequest( - page=page, - page_size=page_size - ) - ) - ) - - def delete( - self, - group_id: str - ) -> SinchDeleteSMSGroupResponse: - return self._sinch.configuration.transport.request( - DeleteSMSGroupEndpoint( - sinch=self._sinch, - request_data=DeleteSMSGroupRequest( - group_id=group_id - ) - ) - ) - - def get( - self, - group_id: str - ) -> GetSMSGroupResponse: - return self._sinch.configuration.transport.request( - GetSMSGroupEndpoint( - sinch=self._sinch, - request_data=GetSMSGroupRequest( - group_id=group_id - ) - ) - ) - - def get_group_phone_numbers( - self, - group_id: str - ) -> SinchGetSMSGroupPhoneNumbersResponse: - return self._sinch.configuration.transport.request( - GetSMSGroupPhoneNumbersEndpoint( - sinch=self._sinch, - request_data=GetSMSGroupPhoneNumbersRequest( - group_id=group_id - ) - ) - ) - - def update( - self, - group_id: str, - name: str = None, - add: list = None, - remove: list = None, - add_from_group: str = None, - remove_from_group: str = None, - auto_update: dict = None - ) -> UpdateSMSGroupResponse: - return self._sinch.configuration.transport.request( - UpdateSMSGroupEndpoint( - sinch=self._sinch, - request_data=UpdateSMSGroupRequest( - group_id=group_id, - name=name, - add=add, - remove=remove, - add_from_group=add_from_group, - remove_from_group=remove_from_group, - auto_update=auto_update - ) - ) - ) - - def replace( - self, - group_id: str, - members: list, - name: str = None - ) -> ReplaceSMSGroupResponse: - return self._sinch.configuration.transport.request( - ReplaceSMSGroupEndpoint( - sinch=self._sinch, - request_data=ReplaceSMSGroupPhoneNumbersRequest( - group_id=group_id, - members=members, - name=name - ) - ) - ) - - -class SMSGroupsWithAsyncPagination(SMSGroups): - async def list( - self, - page=0, - page_size=None - ) -> SinchListSMSGroupResponse: - return await AsyncIntBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListSMSGroupEndpoint( - sinch=self._sinch, - request_data=ListSMSGroupRequest( - page=page, - page_size=page_size - ) - ) - ) - - -class SMSBase: - """ - Documentation for the SMS API: https://developers.sinch.com/docs/sms/ - """ - def __init__(self, sinch): - self._sinch = sinch - - -class SMS(SMSBase): - """ - Synchronous version of the SMS Domain - """ - __doc__ += SMSBase.__doc__ - - def __init__(self, sinch): - super(SMS, self).__init__(sinch) - self.groups = SMSGroups(self._sinch) - self.batches = SMSBatches(self._sinch) - self.inbounds = SMSInbounds(self._sinch) - self.delivery_reports = SMSDeliveryReports(self._sinch) - - -class SMSAsync(SMSBase): - """ - Asynchronous version of the SMS Domain - """ - __doc__ += SMSBase.__doc__ - - def __init__(self, sinch): - super(SMSAsync, self).__init__(sinch) - self.groups = SMSGroupsWithAsyncPagination(self._sinch) - self.batches = SMSBatchesWithAsyncPagination(self._sinch) - self.inbounds = SMSInboundsWithAsyncPagination(self._sinch) - self.delivery_reports = SMSDeliveryReportsWithAsyncPagination(self._sinch) +__all__ = ["SMS"] diff --git a/sinch/domains/sms/api/__init__.py b/sinch/domains/sms/api/__init__.py new file mode 100644 index 00000000..932b7982 --- /dev/null +++ b/sinch/domains/sms/api/__init__.py @@ -0,0 +1 @@ +# Empty file diff --git a/sinch/domains/sms/api/v1/__init__.py b/sinch/domains/sms/api/v1/__init__.py new file mode 100644 index 00000000..db903927 --- /dev/null +++ b/sinch/domains/sms/api/v1/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.sms.api.v1.batches_apis import Batches +from sinch.domains.sms.api.v1.delivery_reports_apis import DeliveryReports + +__all__ = [ + "Batches", + "DeliveryReports", +] diff --git a/sinch/domains/sms/api/v1/base/__init__.py b/sinch/domains/sms/api/v1/base/__init__.py new file mode 100644 index 00000000..e4cdc085 --- /dev/null +++ b/sinch/domains/sms/api/v1/base/__init__.py @@ -0,0 +1,3 @@ +from sinch.domains.sms.api.v1.base.base_sms import BaseSms + +__all__ = ["BaseSms"] diff --git a/sinch/domains/sms/api/v1/base/base_sms.py b/sinch/domains/sms/api/v1/base/base_sms.py new file mode 100644 index 00000000..a1b01b46 --- /dev/null +++ b/sinch/domains/sms/api/v1/base/base_sms.py @@ -0,0 +1,42 @@ +class BaseSms: + """Base class for handling Sinch Sms operations.""" + + def __init__(self, sinch): + self._sinch = sinch + + def _get_path_identifier(self) -> str: + """ + Returns the appropriate path identifier based on authentication method. + - SMS auth: returns service_plan_id + - Project auth: returns project_id + + Returns: + str: The path identifier to use for the endpoint. + """ + if self._sinch.configuration.authentication_method == "sms_auth": + return self._sinch.configuration.service_plan_id + else: + return self._sinch.configuration.project_id + + def _request(self, endpoint_class, request_data): + """ + A helper method to make requests to endpoints. + + Args: + endpoint_class: The endpoint class to call. + request_data: The request data to pass to the endpoint. + + Returns: + The response from the Sinch transport request. + """ + self._sinch.configuration.validate_authentication_parameters() + + endpoint = endpoint_class( + project_id=self._get_path_identifier(), + request_data=request_data, + ) + + # Set the authentication method based on configuration + endpoint.set_authentication_method(self._sinch) + + return self._sinch.configuration.transport.request(endpoint) diff --git a/sinch/domains/sms/api/v1/batches_apis.py b/sinch/domains/sms/api/v1/batches_apis.py new file mode 100644 index 00000000..e4a34576 --- /dev/null +++ b/sinch/domains/sms/api/v1/batches_apis.py @@ -0,0 +1,1284 @@ +from datetime import datetime +from typing import Optional, List, Dict +from pydantic import TypeAdapter, BaseModel +from sinch.core.pagination import Paginator, SMSPaginator +from sinch.domains.sms.models.v1.response.dry_run_response import ( + DryRunResponse, +) +from sinch.domains.sms.models.v1.internal import ( + BatchIdRequest, + DeliveryFeedbackRequest, + DryRunRequest, + ListBatchesRequest, + ReplaceBatchRequest, + SendSMSRequest, + UpdateBatchMessageRequest, +) +from sinch.domains.sms.models.v1.internal.dry_run_request import ( + DryRunTextRequest, + DryRunBinaryRequest, + DryRunMediaRequest, +) +from sinch.domains.sms.models.v1.internal.update_batch_message_request import ( + UpdateTextRequestWithBatchId, + UpdateBinaryRequestWithBatchId, + UpdateMediaRequestWithBatchId, +) +from sinch.domains.sms.models.v1.internal.replace_batch_request import ( + ReplaceTextRequest, + ReplaceBinaryRequest, + ReplaceMediaRequest, +) +from sinch.domains.sms.models.v1.shared import ( + TextRequest, + BinaryRequest, + MediaRequest, +) +from sinch.domains.sms.models.v1.types import DeliveryReportType, MediaBodyDict +from sinch.domains.sms.api.v1.internal import ( + CancelBatchMessageEndpoint, + DryRunEndpoint, + GetBatchMessageEndpoint, + ListBatchesEndpoint, + ReplaceBatchEndpoint, + SendSMSEndpoint, + DeliveryFeedbackEndpoint, + UpdateBatchMessageEndpoint, +) +from sinch.domains.sms.api.v1.base import BaseSms +from sinch.domains.sms.models.v1.types import BatchResponse + + +class Batches(BaseSms): + def cancel(self, batch_id: str, **kwargs) -> BatchResponse: + """ + A batch can be canceled at any point. If a batch is canceled while it's currently being delivered some messages + currently being processed might still be delivered. The delivery report will indicate which messages were + canceled and which weren't. + + Canceling a batch scheduled in the future will result in an empty delivery report while canceling an already + sent batch would result in no change to the completed delivery report. + + :param batch_id: The batch ID you received from sending a message. (required) + :type batch_id: str + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: BatchResponse + :rtype: BatchResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request_data = BatchIdRequest(batch_id=batch_id, **kwargs) + return self._request(CancelBatchMessageEndpoint, request_data) + + def dry_run( + self, + request: Optional[DryRunRequest] = None, + per_recipient: Optional[bool] = None, + number_of_recipients: Optional[int] = None, + **kwargs, + ) -> DryRunResponse: + """ + This operation will perform a dry run of a batch which calculates the bodies and number of parts for all + messages in the batch without actually sending any messages. + + :param request: The request object. (optional) + :type request: Optional[DryRunRequest] + :param per_recipient: Whether to include per recipient details in the response (optional) + :type per_recipient: Optional[bool] + + :param number_of_recipients: Max number of recipients to include per recipient details for in the response (optional) + :type number_of_recipients: Optional[int] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: DryRunResponse + :rtype: DryRunResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + # DryRunRequest is a Union type, so we need to use TypeAdapter to validate + adapter = TypeAdapter(DryRunRequest) + + # Check if we have any overrides (kwargs or explicit per_recipient/number_of_recipients) + has_overrides = ( + bool(kwargs) + or per_recipient is not None + or number_of_recipients is not None + ) + + if ( + request is not None + and isinstance(request, BaseModel) + and not has_overrides + ): + request_data = request + else: + # Build input data from all sources and merge overrides + input_data = {} + if request is not None: + if isinstance(request, BaseModel): + input_data = request.model_dump(exclude_none=True) + + # Merge overrides: kwargs, per_recipient, number_of_recipients + input_data.update(kwargs) + if per_recipient is not None: + input_data["per_recipient"] = per_recipient + if number_of_recipients is not None: + input_data["number_of_recipients"] = number_of_recipients + + request_data = adapter.validate_python(input_data) + + return self._request(DryRunEndpoint, request_data) + + def dry_run_sms( + self, + to: List[str], + from_: str, + body: str, + per_recipient: Optional[bool] = None, + number_of_recipients: Optional[int] = None, + parameters: Optional[Dict[str, Dict[str, str]]] = None, + delivery_report: Optional[DeliveryReportType] = None, + send_at: Optional[datetime] = None, + expire_at: Optional[datetime] = None, + event_destination_target: Optional[str] = None, + client_reference: Optional[str] = None, + feedback_enabled: Optional[bool] = None, + flash_message: Optional[bool] = None, + max_number_of_message_parts: Optional[int] = None, + truncate_concat: Optional[bool] = None, + from_ton: Optional[int] = None, + from_npi: Optional[int] = None, + **kwargs, + ) -> DryRunResponse: + """ + This operation will perform a dry run of a batch which calculates the bodies and number of parts for all + messages in the batch without actually sending any messages (SMS). + + :param to: The list of phone numbers to send the message to. (required) + :type to: List[str] + :param from_: The sender phone number. (required) + :type from_: str + :param body: The message body. (required) + :type body: str + :param per_recipient: Whether to include per recipient details in the response (optional) + :type per_recipient: Optional[bool] + :param number_of_recipients: Max number of recipients to include per recipient details for in the response (optional) + :type number_of_recipients: Optional[int] + :param parameters: The parameters for the message. (optional) + :type parameters: Optional[Dict[str, Dict[str, str]]] + :param delivery_report: The delivery report type. (optional) + :type delivery_report: Optional[DeliveryReportType] + :param send_at: The time to send the message at. (optional) + :type send_at: Optional[datetime] + :param expire_at: The time to expire the message at. (optional) + :type expire_at: Optional[datetime] + :param event_destination_target: The callback URL to receive the delivery report. (optional) + :type event_destination_target: Optional[str] + :param client_reference: The client reference to identify the message. (optional) + :type client_reference: Optional[str] + :param feedback_enabled: Whether to enable feedback. (optional) + :type feedback_enabled: Optional[bool] + :param flash_message: Whether to enable flash message. (optional) + :type flash_message: Optional[bool] + :param max_number_of_message_parts: The maximum number of message parts. (optional) + :type max_number_of_message_parts: Optional[int] + :param truncate_concat: Whether to truncate the message if it is too long. (optional) + :type truncate_concat: Optional[bool] + :param from_ton: The type of number for the sender number. (optional) + :type from_ton: Optional[int] + :param from_npi: The number plan indicator for the sender number. (optional) + :type from_npi: Optional[int] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: DryRunResponse + :rtype: DryRunResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request = DryRunTextRequest( + to=to, + from_=from_, + body=body, + per_recipient=per_recipient, + number_of_recipients=number_of_recipients, + parameters=parameters, + delivery_report=delivery_report, + send_at=send_at, + expire_at=expire_at, + event_destination_target=event_destination_target, + client_reference=client_reference, + feedback_enabled=feedback_enabled, + flash_message=flash_message, + max_number_of_message_parts=max_number_of_message_parts, + truncate_concat=truncate_concat, + from_ton=from_ton, + from_npi=from_npi, + **kwargs, + ) + return self.dry_run(request=request) + + def dry_run_binary( + self, + to: List[str], + from_: str, + body: str, + udh: str, + per_recipient: Optional[bool] = None, + number_of_recipients: Optional[int] = None, + delivery_report: Optional[DeliveryReportType] = None, + send_at: Optional[datetime] = None, + expire_at: Optional[datetime] = None, + event_destination_target: Optional[str] = None, + client_reference: Optional[str] = None, + feedback_enabled: Optional[bool] = None, + from_ton: Optional[int] = None, + from_npi: Optional[int] = None, + **kwargs, + ) -> DryRunResponse: + """ + This operation will perform a dry run of a batch which calculates the bodies and number of parts for all + messages in the batch without actually sending any messages (Binary). + + :param to: The list of phone numbers to send the message to. (required) + :type to: List[str] + :param from_: The sender phone number. (required) + :type from_: str + :param body: The message body. (required) + :type body: str + :param udh: The user data header. (required) + :type udh: str + :param per_recipient: Whether to include per recipient details in the response (optional) + :type per_recipient: Optional[bool] + :param number_of_recipients: Max number of recipients to include per recipient details for in the response (optional) + :type number_of_recipients: Optional[int] + :param delivery_report: The delivery report type. (optional) + :type delivery_report: Optional[DeliveryReportType] + :param send_at: The time to send the message at. (optional) + :type send_at: Optional[datetime] + :param expire_at: The time to expire the message at. (optional) + :type expire_at: Optional[datetime] + :param event_destination_target: The callback URL to receive the delivery report. (optional) + :type event_destination_target: Optional[str] + :param client_reference: The client reference to identify the message. (optional) + :type client_reference: Optional[str] + :param feedback_enabled: Whether to enable feedback. (optional) + :type feedback_enabled: Optional[bool] + :param from_ton: The type of number for the sender number. (optional) + :type from_ton: Optional[int] + :param from_npi: The number plan indicator for the sender number. (optional) + :type from_npi: Optional[int] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: DryRunResponse + :rtype: DryRunResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request = DryRunBinaryRequest( + to=to, + from_=from_, + body=body, + udh=udh, + per_recipient=per_recipient, + number_of_recipients=number_of_recipients, + delivery_report=delivery_report, + send_at=send_at, + expire_at=expire_at, + event_destination_target=event_destination_target, + client_reference=client_reference, + feedback_enabled=feedback_enabled, + from_ton=from_ton, + from_npi=from_npi, + **kwargs, + ) + return self.dry_run(request=request) + + def dry_run_mms( + self, + to: List[str], + from_: str, + body: MediaBodyDict, + per_recipient: Optional[bool] = None, + number_of_recipients: Optional[int] = None, + parameters: Optional[Dict[str, Dict[str, str]]] = None, + delivery_report: Optional[DeliveryReportType] = None, + send_at: Optional[datetime] = None, + expire_at: Optional[datetime] = None, + event_destination_target: Optional[str] = None, + client_reference: Optional[str] = None, + feedback_enabled: Optional[bool] = None, + strict_validation: Optional[bool] = None, + **kwargs, + ) -> DryRunResponse: + """ + This operation will perform a dry run of a batch which calculates the bodies and number of parts for all + messages in the batch without actually sending any messages (MMS). + + :param to: The list of phone numbers to send the message to. (required) + :type to: List[str] + :param from_: The sender phone number. (required) + :type from_: str + :param body: The message body. (required) + :type body: MediaBodyDict + :param per_recipient: Whether to include per recipient details in the response (optional) + :type per_recipient: Optional[bool] + :param number_of_recipients: Max number of recipients to include per recipient details for in the response (optional) + :type number_of_recipients: Optional[int] + :param parameters: The parameters for the message. (optional) + :type parameters: Optional[Dict[str, Dict[str, str]]] + :param delivery_report: The delivery report type. (optional) + :type delivery_report: Optional[DeliveryReportType] + :param send_at: The time to send the message at. (optional) + :type send_at: Optional[datetime] + :param expire_at: The time to expire the message at. (optional) + :type expire_at: Optional[datetime] + :param event_destination_target: The callback URL to receive the delivery report. (optional) + :type event_destination_target: Optional[str] + :param client_reference: The client reference to identify the message. (optional) + :type client_reference: Optional[str] + :param feedback_enabled: Whether to enable feedback. (optional) + :type feedback_enabled: Optional[bool] + :param strict_validation: Whether to enable strict validation. (optional) + :type strict_validation: Optional[bool] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: DryRunResponse + :rtype: DryRunResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request = DryRunMediaRequest( + to=to, + from_=from_, + body=body, + per_recipient=per_recipient, + number_of_recipients=number_of_recipients, + parameters=parameters, + delivery_report=delivery_report, + send_at=send_at, + expire_at=expire_at, + event_destination_target=event_destination_target, + client_reference=client_reference, + feedback_enabled=feedback_enabled, + strict_validation=strict_validation, + **kwargs, + ) + return self.dry_run(request=request) + + def get(self, batch_id: str, **kwargs) -> BatchResponse: + """ + This operation returns a specific batch that matches the provided batch ID. + + :param batch_id: The batch ID you received from sending a message. (required) + :type batch_id: str + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: BatchResponse + :rtype: BatchResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request_data = BatchIdRequest(batch_id=batch_id, **kwargs) + return self._request(GetBatchMessageEndpoint, request_data) + + def list( + self, + page: Optional[int] = None, + page_size: Optional[int] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + from_: Optional[List[str]] = None, + client_reference: Optional[str] = None, + **kwargs, + ) -> Paginator[BatchResponse]: + """ + With the list operation you can list batch messages created in the last 14 days that you have created. + This operation supports pagination. + + :param page: The page number starting from 0. (optional) + :type page: Optional[int] + :param page_size: Determines the size of a page. (optional) + :type page_size: Optional[int] + :param start_date: Only list messages received at or after this date/time. Formatted as + [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`. Default: Now-24 + (optional) + :type start_date: Optional[datetime] + + :param end_date: Only list messages received before this date/time. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`. (optional) + :type end_date: Optional[datetime] + + :param from_: Only list messages sent from this sender number. Multiple originating numbers can be comma separated. Must be phone numbers or short code. (optional) + :type from_: Optional[List[str]] + :param client_reference: Client reference to include (optional) + :type client_reference: Optional[str] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: Paginator[BatchResponse] + :rtype: Paginator[BatchResponse] + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + endpoint = ListBatchesEndpoint( + project_id=self._get_path_identifier(), + request_data=ListBatchesRequest( + page=page, + page_size=page_size, + start_date=start_date, + end_date=end_date, + from_=from_, + client_reference=client_reference, + **kwargs, + ), + ) + endpoint.set_authentication_method(self._sinch) + + return SMSPaginator(sinch=self._sinch, endpoint=endpoint) + + def replace( + self, + batch_id: str, + request: Optional[ReplaceBatchRequest] = None, + **kwargs, + ) -> BatchResponse: + """ + This operation will replace all the parameters of a batch with the provided values. + It is the same as cancelling a batch and sending a new one instead. + + :param batch_id: The batch ID you received from sending a message. (required) + :type batch_id: str + :param request: The request object. (optional) + :type request: Optional[ReplaceBatchRequest] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: BatchResponse + :rtype: BatchResponse + + For additional documentation, see https://www.sinch.com and visit our developer portal. + """ + adapter = TypeAdapter(ReplaceBatchRequest) + + input_data = {} + if request is not None: + if isinstance(request, BaseModel): + input_data = request.model_dump(exclude_none=True) + + input_data.update(kwargs) + input_data["batch_id"] = batch_id + + request_data = adapter.validate_python(input_data) + + return self._request(ReplaceBatchEndpoint, request_data) + + def replace_sms( + self, + batch_id: str, + to: List[str], + from_: str, + body: str, + delivery_report: Optional[DeliveryReportType] = None, + send_at: Optional[datetime] = None, + expire_at: Optional[datetime] = None, + event_destination_target: Optional[str] = None, + client_reference: Optional[str] = None, + feedback_enabled: Optional[bool] = None, + flash_message: Optional[bool] = None, + max_number_of_message_parts: Optional[int] = None, + truncate_concat: Optional[bool] = None, + from_ton: Optional[int] = None, + from_npi: Optional[int] = None, + parameters: Optional[Dict[str, Dict[str, str]]] = None, + **kwargs, + ) -> BatchResponse: + """ + This operation will replace all the parameters of a batch with the provided values. + It is the same as cancelling a batch and sending a new one instead (MMS). + + :param batch_id: The batch ID you received from sending a message. (required) + :type batch_id: str + :param to: The list of phone numbers to send the message to. (required) + :type to: List[str] + :param from_: The sender phone number. (required) + :type from_: str + :param body: The message body. (required) + :type body: str + :param delivery_report: The delivery report type. (optional) + :type delivery_report: Optional[DeliveryReportType] + :param send_at: The time to send the message at. (optional) + :type send_at: Optional[datetime] + :param expire_at: The time to expire the message at. (optional) + :type expire_at: Optional[datetime] + :param event_destination_target: The callback URL to receive the delivery report. (optional) + :type event_destination_target: Optional[str] + :param client_reference: The client reference to identify the message. (optional) + :type client_reference: Optional[str] + :param feedback_enabled: Whether to enable feedback. (optional) + :type feedback_enabled: Optional[bool] + :param flash_message: Whether to enable flash message. (optional) + :type flash_message: Optional[bool] + :param max_number_of_message_parts: The maximum number of message parts. (optional) + :type max_number_of_message_parts: Optional[int] + :param truncate_concat: Whether to truncate the message if it is too long. (optional) + :type truncate_concat: Optional[bool] + :param from_ton: The type of number for the sender number. (optional) + :type from_ton: Optional[int] + :param from_npi: The number plan indicator for the sender number. (optional) + :type from_npi: Optional[int] + :param parameters: The parameters for the message. (optional) + :type parameters: Optional[Dict[str, Dict[str, str]]] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: BatchResponse + :rtype: BatchResponse + + For additional documentation, see https://www.sinch.com and visit our developer portal. + """ + request = ReplaceTextRequest( + batch_id=batch_id, + to=to, + from_=from_, + body=body, + delivery_report=delivery_report, + send_at=send_at, + expire_at=expire_at, + event_destination_target=event_destination_target, + client_reference=client_reference, + feedback_enabled=feedback_enabled, + flash_message=flash_message, + max_number_of_message_parts=max_number_of_message_parts, + truncate_concat=truncate_concat, + from_ton=from_ton, + from_npi=from_npi, + parameters=parameters, + **kwargs, + ) + return self.replace(batch_id=batch_id, request=request) + + def replace_binary( + self, + batch_id: str, + to: List[str], + from_: str, + body: str, + udh: str, + delivery_report: Optional[DeliveryReportType] = None, + send_at: Optional[datetime] = None, + expire_at: Optional[datetime] = None, + event_destination_target: Optional[str] = None, + client_reference: Optional[str] = None, + feedback_enabled: Optional[bool] = None, + from_ton: Optional[int] = None, + from_npi: Optional[int] = None, + **kwargs, + ) -> BatchResponse: + """ + This operation will replace all the parameters of a batch with the provided values. + It is the same as cancelling a batch and sending a new one instead (Binary). + + :param batch_id: The batch ID you received from sending a message. (required) + :type batch_id: str + :param to: The list of phone numbers to send the message to. (required) + :type to: List[str] + :param from_: The sender phone number. (required) + :type from_: str + :param body: The message body. (required) + :type body: str + :param udh: The user data header. (required) + :type udh: str + :param delivery_report: The delivery report type. (optional) + :type delivery_report: Optional[DeliveryReportType] + :param send_at: The time to send the message at. (optional) + :type send_at: Optional[datetime] + :param expire_at: The time to expire the message at. (optional) + :type expire_at: Optional[datetime] + :param event_destination_target: The callback URL to receive the delivery report. (optional) + :type event_destination_target: Optional[str] + :param client_reference: The client reference to identify the message. (optional) + :type client_reference: Optional[str] + :param feedback_enabled: Whether to enable feedback. (optional) + :type feedback_enabled: Optional[bool] + :param from_ton: The type of number for the sender number. (optional) + :type from_ton: Optional[int] + :param from_npi: The number plan indicator for the sender number. (optional) + :type from_npi: Optional[int] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: BatchResponse + :rtype: BatchResponse + + For additional documentation, see https://www.sinch.com and visit our developer portal. + """ + request = ReplaceBinaryRequest( + batch_id=batch_id, + to=to, + from_=from_, + body=body, + udh=udh, + delivery_report=delivery_report, + send_at=send_at, + expire_at=expire_at, + event_destination_target=event_destination_target, + client_reference=client_reference, + feedback_enabled=feedback_enabled, + from_ton=from_ton, + from_npi=from_npi, + **kwargs, + ) + return self.replace(batch_id=batch_id, request=request) + + def replace_mms( + self, + batch_id: str, + to: List[str], + from_: str, + body: MediaBodyDict, + delivery_report: Optional[DeliveryReportType] = None, + send_at: Optional[datetime] = None, + expire_at: Optional[datetime] = None, + event_destination_target: Optional[str] = None, + client_reference: Optional[str] = None, + feedback_enabled: Optional[bool] = None, + strict_validation: Optional[bool] = None, + parameters: Optional[Dict[str, Dict[str, str]]] = None, + **kwargs, + ) -> BatchResponse: + """ + This operation will replace all the parameters of a batch with the provided values. + It is the same as cancelling a batch and sending a new one instead (MMS). + + :param batch_id: The batch ID you received from sending a message. (required) + :type batch_id: str + :param to: The list of phone numbers to send the message to. (required) + :type to: List[str] + :param from_: The sender phone number. (required) + :type from_: str + :param body: The message body. (required) + :type body: MediaBodyDict + :param delivery_report: The delivery report type. (optional) + :type delivery_report: Optional[DeliveryReportType] + :param send_at: The time to send the message at. (optional) + :type send_at: Optional[datetime] + :param expire_at: The time to expire the message at. (optional) + :type expire_at: Optional[datetime] + :param event_destination_target: The callback URL to receive the delivery report. (optional) + :type event_destination_target: Optional[str] + :param client_reference: The client reference to identify the message. (optional) + :type client_reference: Optional[str] + :param feedback_enabled: Whether to enable feedback. (optional) + :type feedback_enabled: Optional[bool] + :param strict_validation: Whether to enable strict validation. (optional) + :type strict_validation: Optional[bool] + :param parameters: The parameters for the message. (optional) + :type parameters: Optional[Dict[str, Dict[str, str]]] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: BatchResponse + :rtype: BatchResponse + + For additional documentation, see https://www.sinch.com and visit our developer portal. + """ + request = ReplaceMediaRequest( + batch_id=batch_id, + to=to, + from_=from_, + body=body, + delivery_report=delivery_report, + send_at=send_at, + expire_at=expire_at, + event_destination_target=event_destination_target, + client_reference=client_reference, + feedback_enabled=feedback_enabled, + strict_validation=strict_validation, + parameters=parameters, + **kwargs, + ) + return self.replace(batch_id=batch_id, request=request) + + def send( + self, request: Optional[SendSMSRequest] = None, **kwargs + ) -> BatchResponse: + """ + Send a message or a batch of messages. + + Depending on the length of the body, one message might be split into multiple parts and charged accordingly. + + Any groups targeted in a scheduled batch will be evaluated at the time of sending. + If a group is deleted between batch creation and scheduled date, it will be considered empty. + + Be sure to use the correct [region](/docs/sms/api-reference/#base-url) in the server URL. + + :param request: The request object. (optional) + :type request: Optional[SendSMSRequest] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: BatchResponse + :rtype: BatchResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + # SendSMSRequest is a Union type, so we need to use TypeAdapter to validate + adapter = TypeAdapter(SendSMSRequest) + + # If request is provided and is already a BaseModel instance, use it directly + # Otherwise, validate the input (either request dict or kwargs) + if request is not None and isinstance(request, BaseModel): + request_data = request + else: + # Validate either the request dict or kwargs + request_data = adapter.validate_python( + request if request is not None else kwargs + ) + + return self._request(SendSMSEndpoint, request_data) + + def send_sms( + self, + to: List[str], + from_: str, + body: str, + delivery_report: Optional[DeliveryReportType] = None, + send_at: Optional[datetime] = None, + expire_at: Optional[datetime] = None, + event_destination_target: Optional[str] = None, + client_reference: Optional[str] = None, + feedback_enabled: Optional[bool] = None, + flash_message: Optional[bool] = None, + max_number_of_message_parts: Optional[int] = None, + truncate_concat: Optional[bool] = None, + from_ton: Optional[int] = None, + from_npi: Optional[int] = None, + parameters: Optional[Dict[str, Dict[str, str]]] = None, + **kwargs, + ) -> BatchResponse: + """ + Send a message or a batch of messages (SMS). + + Depending on the length of the body, one message might be split into multiple parts and charged accordingly. + + Any groups targeted in a scheduled batch will be evaluated at the time of sending. + If a group is deleted between batch creation and scheduled date, it will be considered empty. + + Be sure to use the correct [region](/docs/sms/api-reference/#base-url) in the server URL. + + :param to: The list of phone numbers to send the message to. (required) + :type to: List[str] + :param from_: The sender phone number. (required) + :type from_: str + :param body: The message body. (required) + :type body: str + :param delivery_report: The delivery report type. (optional) + :type delivery_report: Optional[DeliveryReportType] + :param send_at: The time to send the message at. (optional) + :type send_at: Optional[datetime] + :param expire_at: The time to expire the message at. (optional) + :type expire_at: Optional[datetime] + :param event_destination_target: The callback URL to receive the delivery report. (optional) + :type event_destination_target: Optional[str] + :param client_reference: The client reference to identify the message. (optional) + :type client_reference: Optional[str] + :param feedback_enabled: Whether to enable feedback. (optional) + :type feedback_enabled: Optional[bool] + :param flash_message: Whether to enable flash message. (optional) + :type flash_message: Optional[bool] + :param max_number_of_message_parts: The maximum number of message parts. (optional) + :type max_number_of_message_parts: Optional[int] + :param truncate_concat: Whether to truncate the message if it is too long. (optional) + :type truncate_concat: Optional[bool] + :param from_ton: The type of number for the sender number. (optional) + :type from_ton: Optional[int] + :param from_npi: The number plan indicator for the sender number. (optional) + :type from_npi: Optional[int] + :param parameters: The parameters for the message. (optional) + :type parameters: Optional[Dict[str, Dict[str, str]]] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :return: BatchResponse + :rtype: BatchResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request = TextRequest( + to=to, + from_=from_, + body=body, + delivery_report=delivery_report, + send_at=send_at, + expire_at=expire_at, + event_destination_target=event_destination_target, + client_reference=client_reference, + feedback_enabled=feedback_enabled, + flash_message=flash_message, + max_number_of_message_parts=max_number_of_message_parts, + truncate_concat=truncate_concat, + from_ton=from_ton, + from_npi=from_npi, + parameters=parameters, + **kwargs, + ) + return self.send(request=request) + + def send_binary( + self, + to: List[str], + from_: str, + body: str, + udh: str, + delivery_report: Optional[DeliveryReportType] = None, + send_at: Optional[datetime] = None, + expire_at: Optional[datetime] = None, + event_destination_target: Optional[str] = None, + client_reference: Optional[str] = None, + feedback_enabled: Optional[bool] = None, + from_ton: Optional[int] = None, + from_npi: Optional[int] = None, + **kwargs, + ) -> BatchResponse: + """ + Send a message or a batch of messages (Binary). + + Depending on the length of the body, one message might be split into multiple parts and charged accordingly. + + Any groups targeted in a scheduled batch will be evaluated at the time of sending. + If a group is deleted between batch creation and scheduled date, it will be considered empty. + + Be sure to use the correct [region](/docs/sms/api-reference/#base-url) in the server URL. + + :param to: The list of phone numbers to send the message to. (required) + :type to: List[str] + :param from_: The sender phone number. (required) + :type from_: str + :param body: The message body. (required) + :type body: str + :param udh: The user data header. (required) + :type udh: str + :param delivery_report: The delivery report type. (optional) + :type delivery_report: Optional[DeliveryReportType] + :param send_at: The time to send the message at. (optional) + :type send_at: Optional[datetime] + :param expire_at: The time to expire the message at. (optional) + :type expire_at: Optional[datetime] + :param event_destination_target: The callback URL to receive the delivery report. (optional) + :type event_destination_target: Optional[str] + :param client_reference: The client reference to identify the message. (optional) + :type client_reference: Optional[str] + :param feedback_enabled: Whether to enable feedback. (optional) + :type feedback_enabled: Optional[bool] + :param from_ton: The type of number for the sender number. (optional) + :type from_ton: Optional[int] + :param from_npi: The number plan indicator for the sender number. (optional) + :type from_npi: Optional[int] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :return: BatchResponse + :rtype: BatchResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request = BinaryRequest( + to=to, + from_=from_, + body=body, + udh=udh, + delivery_report=delivery_report, + send_at=send_at, + expire_at=expire_at, + event_destination_target=event_destination_target, + client_reference=client_reference, + feedback_enabled=feedback_enabled, + from_ton=from_ton, + from_npi=from_npi, + **kwargs, + ) + return self.send(request=request) + + def send_mms( + self, + to: List[str], + from_: str, + body: MediaBodyDict, + delivery_report: Optional[DeliveryReportType] = None, + send_at: Optional[datetime] = None, + expire_at: Optional[datetime] = None, + event_destination_target: Optional[str] = None, + client_reference: Optional[str] = None, + feedback_enabled: Optional[bool] = None, + strict_validation: Optional[bool] = None, + parameters: Optional[Dict[str, Dict[str, str]]] = None, + **kwargs, + ) -> BatchResponse: + """ + Send a message or a batch of messages (MMS). + + Depending on the length of the body, one message might be split into multiple parts and charged accordingly. + + Any groups targeted in a scheduled batch will be evaluated at the time of sending. + If a group is deleted between batch creation and scheduled date, it will be considered empty. + + Be sure to use the correct [region](/docs/sms/api-reference/#base-url) in the server URL. + + :param to: The list of phone numbers to send the message to. (required) + :type to: List[str] + :param from_: The sender phone number. (required) + :type from_: str + :param body: The message body. (required) + :type body: MediaBodyDict + :param delivery_report: The delivery report type. (optional) + :type delivery_report: Optional[DeliveryReportType] + :param send_at: The time to send the message at. (optional) + :type send_at: Optional[datetime] + :param expire_at: The time to expire the message at. (optional) + :type expire_at: Optional[datetime] + :param event_destination_target: The callback URL to receive the delivery report. (optional) + :type event_destination_target: Optional[str] + :param client_reference: The client reference to identify the message. (optional) + :type client_reference: Optional[str] + :param feedback_enabled: Whether to enable feedback. (optional) + :type feedback_enabled: Optional[bool] + :param strict_validation: Whether to enable strict validation. (optional) + :type strict_validation: Optional[bool] + :param parameters: The parameters for the message. (optional) + :type parameters: Optional[Dict[str, Dict[str, str]]] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :return: BatchResponse + :rtype: BatchResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request = MediaRequest( + to=to, + from_=from_, + body=body, + delivery_report=delivery_report, + send_at=send_at, + expire_at=expire_at, + event_destination_target=event_destination_target, + client_reference=client_reference, + feedback_enabled=feedback_enabled, + strict_validation=strict_validation, + parameters=parameters, + **kwargs, + ) + return self.send(request=request) + + def send_delivery_feedback( + self, batch_id: str, recipients: List[str], **kwargs + ) -> None: + """ + Send feedback if your system can confirm successful message delivery. + + Feedback can only be provided if `feedback_enabled` was set when batch was submitted. + + **Batches**: It is possible to submit feedback multiple times for the same batch for different recipients. + Feedback without specified recipients is treated as successful message delivery to all recipients referenced + in the batch. Note that the `recipients` key is still required even if the value is empty. + + **Groups**: If the batch message was creating using a group ID, at least one recipient is required. + Excluding recipients (an empty recipient list) does not work and will result in a failed request. + + :param batch_id: The batch ID you received from sending a message. (required) + :type batch_id: str + :param recipients: A list of phone numbers (MSISDNs) that have successfully received the message. The key is + required, however, the value can be an empty array (`[]`) for *a batch*. If the feedback was enabled for *a group*, + at least one phone number is required. (required) + :type recipients: List[str] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: None + :rtype: None + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request_data = DeliveryFeedbackRequest( + batch_id=batch_id, recipients=recipients, **kwargs + ) + return self._request(DeliveryFeedbackEndpoint, request_data) + + def update( + self, + batch_id: str, + request: Optional[UpdateBatchMessageRequest] = None, + **kwargs, + ) -> BatchResponse: + """ + This operation updates all specified parameters of a batch that matches the provided batch ID. + + :param batch_id: The batch ID you received from sending a message. (required) + :type batch_id: str + :param request: The request object. (optional) + :type request: Optional[UpdateBatchMessageRequest] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: BatchResponse + :rtype: BatchResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + adapter = TypeAdapter(UpdateBatchMessageRequest) + + input_data = {} + if request is not None: + if isinstance(request, BaseModel): + input_data = request.model_dump(exclude_none=True) + elif isinstance(request, dict): + input_data = dict(request) + + input_data.update(kwargs) + input_data["batch_id"] = batch_id + + request_data = adapter.validate_python(input_data) + + return self._request(UpdateBatchMessageEndpoint, request_data) + + def update_sms( + self, + batch_id: str, + from_: Optional[str] = None, + to_add: Optional[List[str]] = None, + to_remove: Optional[List[str]] = None, + body: Optional[str] = None, + delivery_report: Optional[DeliveryReportType] = None, + send_at: Optional[datetime] = None, + expire_at: Optional[datetime] = None, + event_destination_target: Optional[str] = None, + client_reference: Optional[str] = None, + feedback_enabled: Optional[bool] = None, + parameters: Optional[Dict[str, Dict[str, str]]] = None, + from_ton: Optional[int] = None, + from_npi: Optional[int] = None, + max_number_of_message_parts: Optional[int] = None, + truncate_concat: Optional[bool] = None, + flash_message: Optional[bool] = None, + **kwargs, + ) -> BatchResponse: + """ + This operation updates all specified parameters of a batch that matches the provided batch ID. (SMS) + + :param batch_id: The batch ID you received from sending a message. (required) + :type batch_id: str + :param from_: The sender phone number. (optional) + :type from_: Optional[str] + :param to_add: The list of phone numbers to add to the batch. (optional) + :type to_add: Optional[List[str]] + :param to_remove: The list of phone numbers to remove from the batch. (optional) + :type to_remove: Optional[List[str]] + :param body: The message body. (optional) + :type body: Optional[str] + :param delivery_report: The delivery report type. (optional) + :type delivery_report: Optional[DeliveryReportType] + :param send_at: The time to send the message at. (optional) + :type send_at: Optional[datetime] + :param expire_at: The time to expire the message at. (optional) + :type expire_at: Optional[datetime] + :param event_destination_target: The callback URL to receive the delivery report. (optional) + :type event_destination_target: Optional[str] + :param client_reference: The client reference to identify the message. (optional) + :type client_reference: Optional[str] + :param feedback_enabled: Whether to enable feedback. (optional) + :type feedback_enabled: Optional[bool] + :param parameters: The parameters for the message. (optional) + :type parameters: Optional[Dict[str, Dict[str, str]]] + :param from_ton: The type of number for the sender number. (optional) + :type from_ton: Optional[int] + :param from_npi: The number plan indicator for the sender number. (optional) + :type from_npi: Optional[int] + :param max_number_of_message_parts: The maximum number of message parts. (optional) + :type max_number_of_message_parts: Optional[int] + :param truncate_concat: Whether to truncate the message if it is too long. (optional) + :type truncate_concat: Optional[bool] + :param flash_message: Whether to enable flash message. (optional) + :type flash_message: Optional[bool] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: BatchResponse + :rtype: BatchResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request = UpdateTextRequestWithBatchId( + batch_id=batch_id, + from_=from_, + to_add=to_add, + to_remove=to_remove, + body=body, + delivery_report=delivery_report, + send_at=send_at, + expire_at=expire_at, + event_destination_target=event_destination_target, + client_reference=client_reference, + feedback_enabled=feedback_enabled, + parameters=parameters, + from_ton=from_ton, + from_npi=from_npi, + max_number_of_message_parts=max_number_of_message_parts, + truncate_concat=truncate_concat, + flash_message=flash_message, + **kwargs, + ) + return self.update(batch_id=batch_id, request=request) + + def update_binary( + self, + batch_id: str, + udh: str, + from_: Optional[str] = None, + to_add: Optional[List[str]] = None, + to_remove: Optional[List[str]] = None, + body: Optional[str] = None, + delivery_report: Optional[DeliveryReportType] = None, + send_at: Optional[datetime] = None, + expire_at: Optional[datetime] = None, + event_destination_target: Optional[str] = None, + client_reference: Optional[str] = None, + feedback_enabled: Optional[bool] = None, + from_ton: Optional[int] = None, + from_npi: Optional[int] = None, + **kwargs, + ) -> BatchResponse: + """ + This operation updates all specified parameters of a batch that matches the provided batch ID. (Binary) + + :param batch_id: The batch ID you received from sending a message. (required) + :type batch_id: str + :param udh: The user data header. (required) + :type udh: str + :param from_: The sender phone number. (optional) + :type from_: Optional[str] + :param to_add: The list of phone numbers to add to the batch. (optional) + :type to_add: Optional[List[str]] + :param to_remove: The list of phone numbers to remove from the batch. (optional) + :type to_remove: Optional[List[str]] + :param body: The message body. (optional) + :type body: Optional[str] + :param delivery_report: The delivery report type. (optional) + :type delivery_report: Optional[DeliveryReportType] + :param send_at: The time to send the message at. (optional) + :type send_at: Optional[datetime] + :param expire_at: The time to expire the message at. (optional) + :type expire_at: Optional[datetime] + :param event_destination_target: The callback URL to receive the delivery report. (optional) + :type event_destination_target: Optional[str] + :param client_reference: The client reference to identify the message. (optional) + :type client_reference: Optional[str] + :param feedback_enabled: Whether to enable feedback. (optional) + :type feedback_enabled: Optional[bool] + :param from_ton: The type of number for the sender number. (optional) + :type from_ton: Optional[int] + :param from_npi: The number plan indicator for the sender number. (optional) + :type from_npi: Optional[int] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: BatchResponse + :rtype: BatchResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request = UpdateBinaryRequestWithBatchId( + batch_id=batch_id, + udh=udh, + from_=from_, + to_add=to_add, + to_remove=to_remove, + body=body, + delivery_report=delivery_report, + send_at=send_at, + expire_at=expire_at, + event_destination_target=event_destination_target, + client_reference=client_reference, + feedback_enabled=feedback_enabled, + from_ton=from_ton, + from_npi=from_npi, + **kwargs, + ) + return self.update(batch_id=batch_id, request=request) + + def update_mms( + self, + batch_id: str, + from_: Optional[str] = None, + to_add: Optional[List[str]] = None, + to_remove: Optional[List[str]] = None, + body: Optional[MediaBodyDict] = None, + delivery_report: Optional[DeliveryReportType] = None, + send_at: Optional[datetime] = None, + expire_at: Optional[datetime] = None, + event_destination_target: Optional[str] = None, + client_reference: Optional[str] = None, + feedback_enabled: Optional[bool] = None, + parameters: Optional[Dict[str, Dict[str, str]]] = None, + strict_validation: Optional[bool] = None, + **kwargs, + ) -> BatchResponse: + """ + This operation updates all specified parameters of a batch that matches the provided batch ID. (MMS) + + :param batch_id: The batch ID you received from sending a message. (required) + :type batch_id: str + :param from_: The sender phone number. (optional) + :type from_: Optional[str] + :param to_add: The list of phone numbers to add to the batch. (optional) + :type to_add: Optional[List[str]] + :param to_remove: The list of phone numbers to remove from the batch. (optional) + :type to_remove: Optional[List[str]] + :param body: The message body. (optional) + :type body: Optional[MediaBodyDict] + :param delivery_report: The delivery report type. (optional) + :type delivery_report: Optional[DeliveryReportType] + :param send_at: The time to send the message at. (optional) + :type send_at: Optional[datetime] + :param expire_at: The time to expire the message at. (optional) + :type expire_at: Optional[datetime] + :param event_destination_target: The callback URL to receive the delivery report. (optional) + :type event_destination_target: Optional[str] + :param client_reference: The client reference to identify the message. (optional) + :type client_reference: Optional[str] + :param feedback_enabled: Whether to enable feedback. (optional) + :type feedback_enabled: Optional[bool] + :param parameters: The parameters for the message. (optional) + :type parameters: Optional[Dict[str, Dict[str, str]]] + :param strict_validation: Whether to enable strict validation. (optional) + :type strict_validation: Optional[bool] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: BatchResponse + :rtype: BatchResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request = UpdateMediaRequestWithBatchId( + batch_id=batch_id, + from_=from_, + to_add=to_add, + to_remove=to_remove, + body=body, + delivery_report=delivery_report, + send_at=send_at, + expire_at=expire_at, + event_destination_target=event_destination_target, + client_reference=client_reference, + feedback_enabled=feedback_enabled, + parameters=parameters, + strict_validation=strict_validation, + **kwargs, + ) + return self.update(batch_id=batch_id, request=request) diff --git a/sinch/domains/sms/api/v1/delivery_reports_apis.py b/sinch/domains/sms/api/v1/delivery_reports_apis.py new file mode 100644 index 00000000..ede3cc36 --- /dev/null +++ b/sinch/domains/sms/api/v1/delivery_reports_apis.py @@ -0,0 +1,145 @@ +from datetime import datetime +from typing import List, Optional + +from sinch.core.pagination import Paginator, SMSPaginator +from sinch.domains.sms.api.v1.base import BaseSms +from sinch.domains.sms.api.v1.internal import ( + GetBatchDeliveryReportEndpoint, + GetRecipientDeliveryReportEndpoint, + ListDeliveryReportsEndpoint, +) +from sinch.domains.sms.models.v1.internal import ( + GetRecipientDeliveryReportRequest, + ListDeliveryReportsRequest, + GetBatchDeliveryReportRequest, +) +from sinch.domains.sms.models.v1.response import ( + BatchDeliveryReport, + RecipientDeliveryReport, +) +from sinch.domains.sms.models.v1.types import ( + DeliveryStatusType, + DeliveryReceiptStatusCodeType, +) + + +class DeliveryReports(BaseSms): + def get( + self, + batch_id: str, + report_type: Optional[str] = None, + status: Optional[List[DeliveryStatusType]] = None, + code: Optional[List[DeliveryReceiptStatusCodeType]] = None, + client_reference: Optional[str] = None, + **kwargs, + ) -> BatchDeliveryReport: + """ + Delivery reports can be retrieved even if no callback was requested. The difference between a summary and a full + report is only that the full report contains the phone numbers in + [E.164](https://community.sinch.com/t5/Glossary/E-164/ta-p/7537) format for each status code. + + :param batch_id: The batch ID you received from sending a message. (required) + :type batch_id: str + :param report_type: The type of delivery report. - A `summary` will count the number of messages sent per status. - + A `full` report give that of a `summary` report but in addition, lists phone numbers. (optional) + :type report_type: Optional[str] + :param status: Comma separated list of delivery_report_statuses to include (optional) + :type status: Optional[List[DeliveryStatusType]] + :param code: Comma separated list of delivery_receipt_error_codes to include (optional) + :type code: Optional[List[DeliveryReceiptStatusCodeType]] + :param client_reference: The client identifier of the batch this delivery report belongs to, if set when submitting batch. (optional) + :type client_reference: Optional[str] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: BatchDeliveryReport + :rtype: BatchDeliveryReport + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request_data = GetBatchDeliveryReportRequest( + batch_id=batch_id, + type=report_type, + status=status, + code=code, + client_reference=client_reference, + **kwargs, + ) + return self._request(GetBatchDeliveryReportEndpoint, request_data) + + def get_for_number( + self, batch_id: str, recipient: str, **kwargs + ) -> RecipientDeliveryReport: + """ + A recipient delivery report contains the message status for a single recipient phone number. + + :param batch_id: The batch ID you received from sending a message. (required) + :type batch_id: str + :param recipient: Phone number for which you want to search. (required) + :type recipient: str + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: RecipientDeliveryReport + :rtype: RecipientDeliveryReport + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request_data = GetRecipientDeliveryReportRequest( + batch_id=batch_id, recipient_msisdn=recipient, **kwargs + ) + return self._request(GetRecipientDeliveryReportEndpoint, request_data) + + def list( + self, + page: Optional[int] = None, + page_size: Optional[int] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + status: Optional[List[DeliveryStatusType]] = None, + code: Optional[List[DeliveryReceiptStatusCodeType]] = None, + client_reference: Optional[str] = None, + **kwargs, + ) -> Paginator[RecipientDeliveryReport]: + """ + Get a list of finished delivery reports. + This operation supports pagination. + + :param page: The page number starting from 0. (optional) + :type page: Optional[int] + :param page_size: Determines the size of a page. (optional) + :type page_size: Optional[int] + :param start_date: Only list messages received at or after this date/time. Default: 24h ago (optional) + :type start_date: Optional[datetime] + :param end_date: Only list messages received before this date/time. (optional) + :type end_date: Optional[datetime] + :param status: Comma separated list of delivery report statuses to include. (optional) + :type status: Optional[List[DeliveryStatusType]] + :param code: Comma separated list of delivery receipt error codes to include. (optional) + :type code: Optional[List[DeliveryReceiptStatusCodeType]] + :param client_reference: Client reference to include (optional) + :type client_reference: Optional[str] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: Paginator[RecipientDeliveryReport] + :rtype: Paginator[RecipientDeliveryReport] + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + endpoint = ListDeliveryReportsEndpoint( + project_id=self._get_path_identifier(), + request_data=ListDeliveryReportsRequest( + page=page, + page_size=page_size, + start_date=start_date, + end_date=end_date, + status=status, + code=code, + client_reference=client_reference, + **kwargs, + ), + ) + endpoint.set_authentication_method(self._sinch) + + return SMSPaginator(sinch=self._sinch, endpoint=endpoint) diff --git a/sinch/domains/voice/exceptions.py b/sinch/domains/sms/api/v1/exceptions.py similarity index 61% rename from sinch/domains/voice/exceptions.py rename to sinch/domains/sms/api/v1/exceptions.py index 630a7802..3165b7e9 100644 --- a/sinch/domains/voice/exceptions.py +++ b/sinch/domains/sms/api/v1/exceptions.py @@ -1,5 +1,5 @@ from sinch.core.exceptions import SinchException -class VoiceException(SinchException): +class SmsException(SinchException): pass diff --git a/sinch/domains/sms/api/v1/internal/__init__.py b/sinch/domains/sms/api/v1/internal/__init__.py new file mode 100644 index 00000000..1c3d0a24 --- /dev/null +++ b/sinch/domains/sms/api/v1/internal/__init__.py @@ -0,0 +1,30 @@ +from sinch.domains.sms.api.v1.internal.batches_endpoints import ( + CancelBatchMessageEndpoint, + DryRunEndpoint, + GetBatchMessageEndpoint, + ListBatchesEndpoint, + ReplaceBatchEndpoint, + SendSMSEndpoint, + DeliveryFeedbackEndpoint, + UpdateBatchMessageEndpoint, +) +from sinch.domains.sms.api.v1.internal.delivery_reports_endpoints import ( + GetBatchDeliveryReportEndpoint, + GetRecipientDeliveryReportEndpoint, + ListDeliveryReportsEndpoint, +) + + +__all__ = [ + "CancelBatchMessageEndpoint", + "DryRunEndpoint", + "GetBatchMessageEndpoint", + "ListBatchesEndpoint", + "ReplaceBatchEndpoint", + "SendSMSEndpoint", + "DeliveryFeedbackEndpoint", + "UpdateBatchMessageEndpoint", + "GetBatchDeliveryReportEndpoint", + "GetRecipientDeliveryReportEndpoint", + "ListDeliveryReportsEndpoint", +] diff --git a/sinch/domains/sms/api/v1/internal/base/__init__.py b/sinch/domains/sms/api/v1/internal/base/__init__.py new file mode 100644 index 00000000..b5bce8aa --- /dev/null +++ b/sinch/domains/sms/api/v1/internal/base/__init__.py @@ -0,0 +1,3 @@ +from sinch.domains.sms.api.v1.internal.base.sms_endpoint import SmsEndpoint + +__all__ = ["SmsEndpoint"] diff --git a/sinch/domains/sms/api/v1/internal/base/sms_endpoint.py b/sinch/domains/sms/api/v1/internal/base/sms_endpoint.py new file mode 100644 index 00000000..19623f3c --- /dev/null +++ b/sinch/domains/sms/api/v1/internal/base/sms_endpoint.py @@ -0,0 +1,117 @@ +import re +from abc import ABC +from typing import Annotated, Type, Union, get_origin, get_args +from pydantic import TypeAdapter +from sinch.core.models.http_response import HTTPResponse +from sinch.core.endpoint import HTTPEndpoint +from sinch.core.types import BM +from sinch.core.enums import HTTPAuthentication +from sinch.domains.sms.api.v1.exceptions import SmsException + + +class SmsEndpoint(HTTPEndpoint, ABC): + def __init__(self, project_id: str, request_data: BM): + super().__init__(project_id, request_data) + + def set_authentication_method(self, sinch): + """ + Sets the authentication method based on the sinch client configuration. + """ + if sinch.configuration.authentication_method == "sms_auth": + self.HTTP_AUTHENTICATION = HTTPAuthentication.SMS_TOKEN.value + else: + self.HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def build_url(self, sinch) -> str: + if not self.ENDPOINT_URL: + raise NotImplementedError( + "ENDPOINT_URL must be defined in the SMS endpoint subclass " + ) + + # Use the appropriate SMS origin based on authentication method + origin = sinch.configuration.get_sms_origin_for_auth() + + return self.ENDPOINT_URL.format( + origin=origin, + project_id=self.project_id, + **vars(self.request_data), + ) + + def _get_path_params_from_url(self) -> set: + """ + Extracts path parameters from ENDPOINT_URL template. + + Returns: + set: Set of path parameter names that should be excluded from request body. + """ + if not self.ENDPOINT_URL: + return set() + + # Extract all placeholders from the URL template (e.g., {batch_id}, {project_id}) + path_params = set(re.findall(r"\{(\w+)\}", self.ENDPOINT_URL)) + + # Exclude 'origin' and 'project_id' as they are always path params but not from request_data + path_params.discard("origin") + path_params.discard("project_id") + + return path_params + + def build_query_params(self) -> dict: + """ + Constructs the query parameters for the endpoint. + + Returns: + dict: The query parameters to be sent with the API request. + """ + return {} + + def request_body(self) -> str: + """ + Returns the request body as a JSON string. + + Returns: + str: The request body as a JSON string. + """ + return "" + + def process_response_model( + self, response_body: dict, response_model: Type[BM] + ) -> BM: + """ + Processes the response body and maps it to a response model. + + Args: + response_body (dict): The raw response body. + response_model (type): The Pydantic model class or Union type to map the response. + + Returns: + Parsed response object. + """ + try: + origin = get_origin(response_model) + # Check if response_model is an Annotated type (e.g., discriminated union) + if origin is Annotated: + args = get_args(response_model) + if args and get_origin(args[0]) is Union: + # Use TypeAdapter for Annotated Union types (discriminated unions) + adapter = TypeAdapter(response_model) + return adapter.validate_python(response_body) + # Check if response_model is a Union type + elif origin is Union: + # Use TypeAdapter for Union types + adapter = TypeAdapter(response_model) + return adapter.validate_python(response_body) + # Use standard model_validate for regular Pydantic models + return response_model.model_validate(response_body) + except Exception as e: + raise ValueError(f"Invalid response structure: {e}") from e + + def handle_response(self, response: HTTPResponse): + if response.status_code >= 400: + error_message = f"Error {response.status_code}" + + raise SmsException( + message=error_message, + response=response, + is_from_server=True, + ) diff --git a/sinch/domains/sms/api/v1/internal/batches_endpoints.py b/sinch/domains/sms/api/v1/internal/batches_endpoints.py new file mode 100644 index 00000000..f6270d69 --- /dev/null +++ b/sinch/domains/sms/api/v1/internal/batches_endpoints.py @@ -0,0 +1,262 @@ +import json +from sinch.core.enums import HTTPAuthentication, HTTPMethods +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.models.v1.internal import ( + BatchIdRequest, + DryRunRequest, + ListBatchesRequest, + ReplaceBatchRequest, + SendSMSRequest, + DeliveryFeedbackRequest, + UpdateBatchMessageRequest, +) +from sinch.domains.sms.models.v1.response.list_batches_response import ( + ListBatchesResponse, +) +from sinch.domains.sms.models.v1.types import BatchResponse +from sinch.domains.sms.models.v1.response.dry_run_response import ( + DryRunResponse, +) +from sinch.domains.sms.api.v1.internal.base import SmsEndpoint +from sinch.domains.sms.api.v1.exceptions import SmsException + + +class CancelBatchMessageEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/batches/{batch_id}" + HTTP_METHOD = HTTPMethods.DELETE.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: BatchIdRequest): + super(CancelBatchMessageEndpoint, self).__init__( + project_id, request_data + ) + self.project_id = project_id + self.request_data = request_data + + def handle_response(self, response: HTTPResponse) -> BatchResponse: + try: + super(CancelBatchMessageEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, BatchResponse) + + +class DryRunEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/batches/dry_run" + HTTP_METHOD = HTTPMethods.POST.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + # Define which fields are query parameters (not part of the request body) + QUERY_PARAM_FIELDS = {"per_recipient", "number_of_recipients"} + + def __init__(self, project_id: str, request_data: DryRunRequest): + super(DryRunEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def build_query_params(self) -> dict: + """Extract query parameters from request data.""" + # Extract only query param fields using include, and exclude None values + query_params = self.request_data.model_dump( + include=self.QUERY_PARAM_FIELDS, exclude_none=True, by_alias=True + ) + return query_params + + def request_body(self): + """Extract body (excluding query params) and serialize datetime to JSON.""" + # Exclude query params from body using the same constant + # Use mode='json' to serialize datetime objects to ISO-8601 strings + request_data = self.request_data.model_dump( + mode="json", + by_alias=True, + exclude_none=True, + exclude=self.QUERY_PARAM_FIELDS, + ) + return json.dumps(request_data) + + def handle_response(self, response: HTTPResponse) -> DryRunResponse: + try: + super(DryRunEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, DryRunResponse) + + +class GetBatchMessageEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/batches/{batch_id}" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: BatchIdRequest): + super(GetBatchMessageEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def handle_response(self, response: HTTPResponse) -> BatchResponse: + try: + super(GetBatchMessageEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, BatchResponse) + + +class ListBatchesEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/batches" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: ListBatchesRequest): + super(ListBatchesEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def build_query_params(self) -> dict: + return self.request_data.model_dump(exclude_none=True, by_alias=True) + + def handle_response(self, response: HTTPResponse) -> ListBatchesResponse: + try: + super(ListBatchesEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, ListBatchesResponse) + + +class ReplaceBatchEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/batches/{batch_id}" + HTTP_METHOD = HTTPMethods.PUT.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: ReplaceBatchRequest): + super(ReplaceBatchEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def request_body(self): + # Use mode='json' to serialize datetime objects to ISO-8601 strings + path_params = self._get_path_params_from_url() + request_data = self.request_data.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude=path_params + ) + return json.dumps(request_data) + + def handle_response(self, response: HTTPResponse) -> BatchResponse: + try: + super(ReplaceBatchEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, BatchResponse) + + +class SendSMSEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/batches" + HTTP_METHOD = HTTPMethods.POST.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: SendSMSRequest): + super(SendSMSEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def request_body(self): + # Use mode='json' to serialize datetime objects to ISO-8601 strings + request_data = self.request_data.model_dump( + mode="json", by_alias=True, exclude_none=True + ) + return json.dumps(request_data) + + def handle_response(self, response: HTTPResponse) -> BatchResponse: + try: + super(SendSMSEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, BatchResponse) + + +class DeliveryFeedbackEndpoint(SmsEndpoint): + ENDPOINT_URL = ( + "{origin}/xms/v1/{project_id}/batches/{batch_id}/delivery_feedback" + ) + HTTP_METHOD = HTTPMethods.POST.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: DeliveryFeedbackRequest): + super(DeliveryFeedbackEndpoint, self).__init__( + project_id, request_data + ) + self.project_id = project_id + self.request_data = request_data + + def request_body(self): + path_params = self._get_path_params_from_url() + request_data = self.request_data.model_dump( + by_alias=True, exclude_none=True, exclude=path_params + ) + return json.dumps(request_data) + + def handle_response(self, response: HTTPResponse): + try: + super(DeliveryFeedbackEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + + +class UpdateBatchMessageEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/batches/{batch_id}" + HTTP_METHOD = HTTPMethods.POST.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__( + self, project_id: str, request_data: UpdateBatchMessageRequest + ): + super(UpdateBatchMessageEndpoint, self).__init__( + project_id, request_data + ) + self.project_id = project_id + self.request_data = request_data + + def request_body(self): + # Use mode='json' to serialize datetime objects to ISO-8601 strings + path_params = self._get_path_params_from_url() + request_data = self.request_data.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude=path_params + ) + return json.dumps(request_data) + + def handle_response(self, response: HTTPResponse) -> BatchResponse: + try: + super(UpdateBatchMessageEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, BatchResponse) diff --git a/sinch/domains/sms/api/v1/internal/delivery_reports_endpoints.py b/sinch/domains/sms/api/v1/internal/delivery_reports_endpoints.py new file mode 100644 index 00000000..ca4aa6e5 --- /dev/null +++ b/sinch/domains/sms/api/v1/internal/delivery_reports_endpoints.py @@ -0,0 +1,115 @@ +from sinch.core.enums import HTTPAuthentication, HTTPMethods +from sinch.core.models.http_response import HTTPResponse +from sinch.core.models.utils import model_dump_for_query_params +from sinch.domains.sms.api.v1.exceptions import SmsException +from sinch.domains.sms.models.v1.internal import ( + GetBatchDeliveryReportRequest, + GetRecipientDeliveryReportRequest, + ListDeliveryReportsRequest, + ListDeliveryReportsResponse, +) +from sinch.domains.sms.api.v1.internal.base import SmsEndpoint +from sinch.domains.sms.models.v1.response import ( + BatchDeliveryReport, + RecipientDeliveryReport, +) + + +class GetBatchDeliveryReportEndpoint(SmsEndpoint): + ENDPOINT_URL = ( + "{origin}/xms/v1/{project_id}/batches/{batch_id}/delivery_report" + ) + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__( + self, project_id: str, request_data: GetBatchDeliveryReportRequest + ): + super(GetBatchDeliveryReportEndpoint, self).__init__( + project_id, request_data + ) + self.project_id = project_id + self.request_data = request_data + + def build_query_params(self) -> dict: + return model_dump_for_query_params(self.request_data) + + def handle_response(self, response: HTTPResponse) -> BatchDeliveryReport: + try: + super(GetBatchDeliveryReportEndpoint, self).handle_response( + response + ) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, BatchDeliveryReport) + + +class GetRecipientDeliveryReportEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/batches/{batch_id}/delivery_report/{recipient_msisdn}" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__( + self, + project_id: str, + request_data: GetRecipientDeliveryReportRequest, + ): + super(GetRecipientDeliveryReportEndpoint, self).__init__( + project_id, request_data + ) + self.project_id = project_id + self.request_data = request_data + + def handle_response( + self, response: HTTPResponse + ) -> RecipientDeliveryReport: + try: + super(GetRecipientDeliveryReportEndpoint, self).handle_response( + response + ) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model( + response.body, RecipientDeliveryReport + ) + + +class ListDeliveryReportsEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/delivery_reports" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__( + self, project_id: str, request_data: ListDeliveryReportsRequest + ): + super(ListDeliveryReportsEndpoint, self).__init__( + project_id, request_data + ) + self.project_id = project_id + self.request_data = request_data + + def build_query_params(self) -> dict: + return model_dump_for_query_params(self.request_data) + + def handle_response( + self, response: HTTPResponse + ) -> ListDeliveryReportsResponse: + try: + super(ListDeliveryReportsEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model( + response.body, ListDeliveryReportsResponse + ) diff --git a/sinch/domains/sms/endpoints/batches/cancel_batch.py b/sinch/domains/sms/endpoints/batches/cancel_batch.py deleted file mode 100644 index 091a966d..00000000 --- a/sinch/domains/sms/endpoints/batches/cancel_batch.py +++ /dev/null @@ -1,38 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.batches.requests import CancelBatchRequest -from sinch.domains.sms.models.batches.responses import CancelSMSBatchResponse - - -class CancelBatchEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/batches/{batch_id}" - HTTP_METHOD = HTTPMethods.DELETE.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: CancelBatchRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=self.sms_origin, - project_or_service_id=self.project_or_service_id, - batch_id=self.request_data.batch_id - ) - - def handle_response(self, response: HTTPResponse): - super(CancelBatchEndpoint, self).handle_response(response) - return CancelSMSBatchResponse( - id=response.body.get("id"), - to=response.body.get("to"), - from_=response.body.get("from"), - body=response.body.get("body"), - delivery_report=response.body.get("delivery_report"), - cancelled=response.body.get("cancelled"), - type=response.body.get("type"), - campaign_id=response.body.get("campaign_id"), - created_at=response.body.get("created_at"), - modified_at=response.body.get("modified_at"), - send_at=response.body.get("send_at"), - expire_at=response.body.get("expire_at") - ) diff --git a/sinch/domains/sms/endpoints/batches/get_batch.py b/sinch/domains/sms/endpoints/batches/get_batch.py deleted file mode 100644 index 555ab5d6..00000000 --- a/sinch/domains/sms/endpoints/batches/get_batch.py +++ /dev/null @@ -1,41 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.batches.requests import GetBatchRequest -from sinch.domains.sms.models.batches.responses import GetSMSBatchResponse - - -class GetSMSEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/batches/{batch_id}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: GetBatchRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=self.sms_origin, - project_or_service_id=self.project_or_service_id, - batch_id=self.request_data.batch_id - ) - - def build_query_params(self): - return self.request_data.as_dict() - - def handle_response(self, response: HTTPResponse): - super(GetSMSEndpoint, self).handle_response(response) - return GetSMSBatchResponse( - id=response.body.get("id"), - to=response.body.get("to"), - from_=response.body.get("from"), - body=response.body.get("body"), - delivery_report=response.body.get("delivery_report"), - cancelled=response.body.get("cancelled"), - type=response.body.get("type"), - campaign_id=response.body.get("campaign_id"), - created_at=response.body.get("created_at"), - modified_at=response.body.get("modified_at"), - send_at=response.body.get("send_at"), - expire_at=response.body.get("expire_at") - ) diff --git a/sinch/domains/sms/endpoints/batches/list_batches.py b/sinch/domains/sms/endpoints/batches/list_batches.py deleted file mode 100644 index b770efbf..00000000 --- a/sinch/domains/sms/endpoints/batches/list_batches.py +++ /dev/null @@ -1,48 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.batches import Batch -from sinch.domains.sms.models.batches.requests import ListBatchesRequest -from sinch.domains.sms.models.batches.responses import ListSMSBatchesResponse - - -class ListSMSBatchesEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/batches" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: ListBatchesRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=self.sms_origin, - project_or_service_id=self.project_or_service_id - ) - - def build_query_params(self): - return self.request_data.as_dict() - - def handle_response(self, response: HTTPResponse): - super(ListSMSBatchesEndpoint, self).handle_response(response) - return ListSMSBatchesResponse( - batches=[ - Batch( - id=batch.get("id"), - to=batch.get("to"), - from_=batch.get("from"), - body=batch.get("body"), - delivery_report=batch.get("delivery_report"), - cancelled=batch.get("cancelled"), - type=batch.get("type"), - campaign_id=batch.get("campaign_id"), - created_at=batch.get("created_at"), - modified_at=batch.get("modified_at"), - send_at=batch.get("send_at"), - expire_at=batch.get("expire_at") - ) for batch in response.body["batches"] - ], - page=response.body.get("page"), - page_size=response.body.get("page_size"), - count=response.body.get("count") - ) diff --git a/sinch/domains/sms/endpoints/batches/replace_batch.py b/sinch/domains/sms/endpoints/batches/replace_batch.py deleted file mode 100644 index 15fdd838..00000000 --- a/sinch/domains/sms/endpoints/batches/replace_batch.py +++ /dev/null @@ -1,42 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.batches.responses import ReplaceSMSBatchResponse -from sinch.domains.sms.models.batches.requests import ReplaceBatchRequest - - -class ReplaceBatchSMSEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/batches/{batch_id}" - HTTP_METHOD = HTTPMethods.PUT.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: ReplaceBatchRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=self.sms_origin, - project_or_service_id=self.project_or_service_id, - batch_id=self.request_data.batch_id - ) - - def request_body(self): - self.request_data.batch_id = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse): - super(ReplaceBatchSMSEndpoint, self).handle_response(response) - return ReplaceSMSBatchResponse( - id=response.body.get("id"), - to=response.body.get("to"), - from_=response.body.get("from"), - body=response.body.get("body"), - delivery_report=response.body.get("delivery_report"), - cancelled=response.body.get("cancelled"), - type=response.body.get("type"), - campaign_id=response.body.get("campaign_id"), - created_at=response.body.get("created_at"), - modified_at=response.body.get("modified_at"), - send_at=response.body.get("send_at"), - expire_at=response.body.get("expire_at") - ) diff --git a/sinch/domains/sms/endpoints/batches/send_batch.py b/sinch/domains/sms/endpoints/batches/send_batch.py deleted file mode 100644 index 4bb94f1a..00000000 --- a/sinch/domains/sms/endpoints/batches/send_batch.py +++ /dev/null @@ -1,41 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.batches.responses import SendSMSBatchResponse -from sinch.domains.sms.models.batches.requests import SendBatchRequest - - -class SendBatchSMSEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/batches" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: SendBatchRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=self.sms_origin, - project_or_service_id=self.project_or_service_id - ) - - def request_body(self): - self.request_data.batch_id = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse): - super(SendBatchSMSEndpoint, self).handle_response(response) - return SendSMSBatchResponse( - id=response.body.get("id"), - to=response.body.get("to"), - from_=response.body.get("from"), - body=response.body.get("body"), - delivery_report=response.body.get("delivery_report"), - cancelled=response.body.get("cancelled"), - type=response.body.get("type"), - campaign_id=response.body.get("campaign_id"), - created_at=response.body.get("created_at"), - modified_at=response.body.get("modified_at"), - send_at=response.body.get("send_at"), - expire_at=response.body.get("expire_at") - ) diff --git a/sinch/domains/sms/endpoints/batches/send_batch_dry_run.py b/sinch/domains/sms/endpoints/batches/send_batch_dry_run.py deleted file mode 100644 index b9c64f2a..00000000 --- a/sinch/domains/sms/endpoints/batches/send_batch_dry_run.py +++ /dev/null @@ -1,39 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.batches.responses import SendSMSBatchDryRunResponse -from sinch.domains.sms.models.batches.requests import BatchDryRunRequest - - -class SendBatchSMSDryRunEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/batches/dry_run" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: BatchDryRunRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id - ) - - def build_query_params(self): - return { - "per_recipient": str(self.request_data.per_recipient).lower(), - "number_of_recipients": self.request_data.number_of_recipients - } - - def request_body(self): - self.request_data.per_recipient = None - self.request_data.number_of_recipients = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse): - super(SendBatchSMSDryRunEndpoint, self).handle_response(response) - return SendSMSBatchDryRunResponse( - number_of_messages=response.body.get("number_of_messages"), - number_of_recipients=response.body.get("number_of_recipients"), - per_recipient=response.body.get("per_recipient") - ) diff --git a/sinch/domains/sms/endpoints/batches/send_delivery_feedback.py b/sinch/domains/sms/endpoints/batches/send_delivery_feedback.py deleted file mode 100644 index 3c3f7933..00000000 --- a/sinch/domains/sms/endpoints/batches/send_delivery_feedback.py +++ /dev/null @@ -1,29 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.batches.responses import SendSMSDeliveryFeedbackResponse -from sinch.domains.sms.models.batches.requests import SendDeliveryFeedbackRequest - - -class SendDeliveryReportEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/batches/{batch_id}/delivery_feedback" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: SendDeliveryFeedbackRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id, - batch_id=self.request_data.batch_id - ) - - def request_body(self): - self.request_data.batch_id = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse): - super(SendDeliveryReportEndpoint, self).handle_response(response) - return SendSMSDeliveryFeedbackResponse() diff --git a/sinch/domains/sms/endpoints/batches/update_batch.py b/sinch/domains/sms/endpoints/batches/update_batch.py deleted file mode 100644 index 28a88056..00000000 --- a/sinch/domains/sms/endpoints/batches/update_batch.py +++ /dev/null @@ -1,43 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.batches.responses import UpdateSMSBatchResponse -from sinch.domains.sms.models.batches.requests import UpdateBatchRequest - - -class UpdateBatchSMSEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/batches/{batch_id}" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: UpdateBatchRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id, - batch_id=self.request_data.batch_id - ) - - def request_body(self): - self.request_data.batch_id = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse): - super(UpdateBatchSMSEndpoint, self).handle_response(response) - return UpdateSMSBatchResponse( - id=response.body.get("id"), - to=response.body.get("to"), - from_=response.body.get("from"), - body=response.body.get("body"), - delivery_report=response.body.get("delivery_report"), - cancelled=response.body.get("cancelled"), - type=response.body.get("type"), - campaign_id=response.body.get("campaign_id"), - created_at=response.body.get("created_at"), - modified_at=response.body.get("modified_at"), - send_at=response.body.get("send_at"), - expire_at=response.body.get("expire_at"), - callback_url=response.body.get("callback_url") - ) diff --git a/sinch/domains/sms/endpoints/delivery_reports/__init__.py b/sinch/domains/sms/endpoints/delivery_reports/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/sms/endpoints/delivery_reports/get_all_delivery_reports_for_project.py b/sinch/domains/sms/endpoints/delivery_reports/get_all_delivery_reports_for_project.py deleted file mode 100644 index dc1b55d8..00000000 --- a/sinch/domains/sms/endpoints/delivery_reports/get_all_delivery_reports_for_project.py +++ /dev/null @@ -1,48 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.delivery_reports import DeliveryReport -from sinch.domains.sms.models.delivery_reports.requests import ListSMSDeliveryReportsRequest -from sinch.domains.sms.models.delivery_reports.responses import ListSMSDeliveryReportsResponse - - -class ListDeliveryReportsEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/delivery_reports" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: ListSMSDeliveryReportsRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id - ) - - def build_query_params(self): - return self.request_data.as_dict() - - def handle_response(self, response: HTTPResponse): - super(ListDeliveryReportsEndpoint, self).handle_response(response) - return ListSMSDeliveryReportsResponse( - delivery_reports=[ - DeliveryReport( - at=delivery_report.get("at"), - batch_id=delivery_report.get("batch_id"), - code=delivery_report.get("code"), - recipient=delivery_report.get("recipient"), - status=delivery_report.get("status"), - applied_originator=delivery_report.get("applied_originator"), - client_reference=delivery_report.get("client_reference"), - encoding=delivery_report.get("encoding"), - number_of_message_parts=delivery_report.get("number_of_message_parts"), - operator=delivery_report.get("operator"), - operator_status_at=delivery_report.get("operator_status_at"), - type=delivery_report.get("type") - ) for delivery_report in response.body["delivery_reports"] - ], - page=response.body.get("page"), - page_size=response.body.get("page_size"), - count=response.body.get("count") - ) diff --git a/sinch/domains/sms/endpoints/delivery_reports/get_delivery_report_for_batch.py b/sinch/domains/sms/endpoints/delivery_reports/get_delivery_report_for_batch.py deleted file mode 100644 index 4018d3bf..00000000 --- a/sinch/domains/sms/endpoints/delivery_reports/get_delivery_report_for_batch.py +++ /dev/null @@ -1,44 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.delivery_reports.requests import GetSMSDeliveryReportForBatchRequest -from sinch.domains.sms.models.delivery_reports.responses import GetSMSDeliveryReportForBatchResponse - - -class GetDeliveryReportForBatchEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/batches/{batch_id}/delivery_report" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: GetSMSDeliveryReportForBatchRequest, sinch): - super().__init__(request_data, sinch) - - def build_query_params(self): - params = {} - if self.request_data.type_: - params["type"] = self.request_data.type_ - - if self.request_data.status: - params["status"] = self.request_data.status - - if self.request_data.code: - params["code"] = list(map(str, self.request_data.code)) - - return params - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id, - batch_id=self.request_data.batch_id - ) - - def handle_response(self, response: HTTPResponse): - super(GetDeliveryReportForBatchEndpoint, self).handle_response(response) - return GetSMSDeliveryReportForBatchResponse( - type=response.body.get("type"), - batch_id=response.body.get("batch_id"), - total_message_count=response.body.get("total_message_count"), - statuses=response.body.get("statuses"), - client_reference=response.body.get("client_reference") - ) diff --git a/sinch/domains/sms/endpoints/delivery_reports/get_delivery_report_for_number.py b/sinch/domains/sms/endpoints/delivery_reports/get_delivery_report_for_number.py deleted file mode 100644 index 97dcdf9d..00000000 --- a/sinch/domains/sms/endpoints/delivery_reports/get_delivery_report_for_number.py +++ /dev/null @@ -1,38 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.delivery_reports.requests import GetSMSDeliveryReportForNumberRequest -from sinch.domains.sms.models.delivery_reports.responses import GetSMSDeliveryReportForNumberResponse - - -class GetDeliveryReportForNumberEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/batches/{batch_id}/delivery_report/{recipient_msisdn}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: GetSMSDeliveryReportForNumberRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id, - batch_id=self.request_data.batch_id, - recipient_msisdn=self.request_data.recipient_number - ) - - def handle_response(self, response: HTTPResponse): - super(GetDeliveryReportForNumberEndpoint, self).handle_response(response) - return GetSMSDeliveryReportForNumberResponse( - at=response.body.get("at"), - batch_id=response.body.get("batch_id"), - code=response.body.get("code"), - recipient=response.body.get("recipient"), - status=response.body.get("status"), - applied_originator=response.body.get("applied_originator"), - client_reference=response.body.get("client_reference"), - number_of_message_parts=response.body.get("number_of_message_parts"), - operator=response.body.get("operator"), - operator_status_at=response.body.get("operator_status_at"), - type=response.body.get("type") - ) diff --git a/sinch/domains/sms/endpoints/groups/__init__.py b/sinch/domains/sms/endpoints/groups/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/sms/endpoints/groups/create_group.py b/sinch/domains/sms/endpoints/groups/create_group.py deleted file mode 100644 index 3d57eb91..00000000 --- a/sinch/domains/sms/endpoints/groups/create_group.py +++ /dev/null @@ -1,35 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.groups.requests import CreateSMSGroupRequest -from sinch.domains.sms.models.groups.responses import CreateSMSGroupResponse - - -class CreateSMSGroupEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/groups" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: CreateSMSGroupRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse): - super(CreateSMSGroupEndpoint, self).handle_response(response) - return CreateSMSGroupResponse( - id=response.body.get("id"), - size=response.body.get("size"), - created_at=response.body.get("created_at"), - modified_at=response.body.get("modified_at"), - name=response.body.get("name"), - child_groups=response.body.get("child_groups"), - auto_update=response.body.get("auto_update") - ) diff --git a/sinch/domains/sms/endpoints/groups/delete_group.py b/sinch/domains/sms/endpoints/groups/delete_group.py deleted file mode 100644 index e4790c3c..00000000 --- a/sinch/domains/sms/endpoints/groups/delete_group.py +++ /dev/null @@ -1,25 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.groups.requests import DeleteSMSGroupRequest -from sinch.domains.sms.models.groups.responses import SinchDeleteSMSGroupResponse - - -class DeleteSMSGroupEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/groups/{group_id}" - HTTP_METHOD = HTTPMethods.DELETE.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: DeleteSMSGroupRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id, - group_id=self.request_data.group_id - ) - - def handle_response(self, response: HTTPResponse): - super(DeleteSMSGroupEndpoint, self).handle_response(response) - return SinchDeleteSMSGroupResponse() diff --git a/sinch/domains/sms/endpoints/groups/get_group.py b/sinch/domains/sms/endpoints/groups/get_group.py deleted file mode 100644 index 23c98848..00000000 --- a/sinch/domains/sms/endpoints/groups/get_group.py +++ /dev/null @@ -1,33 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.groups.requests import GetSMSGroupRequest -from sinch.domains.sms.models.groups.responses import GetSMSGroupResponse - - -class GetSMSGroupEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/groups/{group_id}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: GetSMSGroupRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id, - group_id=self.request_data.group_id - ) - - def handle_response(self, response: HTTPResponse): - super(GetSMSGroupEndpoint, self).handle_response(response) - return GetSMSGroupResponse( - id=response.body.get("id"), - size=response.body.get("size"), - created_at=response.body.get("created_at"), - modified_at=response.body.get("modified_at"), - name=response.body.get("name"), - child_groups=response.body.get("child_groups"), - auto_update=response.body.get("auto_update") - ) diff --git a/sinch/domains/sms/endpoints/groups/get_phone_numbers_for_group.py b/sinch/domains/sms/endpoints/groups/get_phone_numbers_for_group.py deleted file mode 100644 index 7e1e81ac..00000000 --- a/sinch/domains/sms/endpoints/groups/get_phone_numbers_for_group.py +++ /dev/null @@ -1,27 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.groups.requests import GetSMSGroupPhoneNumbersRequest -from sinch.domains.sms.models.groups.responses import SinchGetSMSGroupPhoneNumbersResponse - - -class GetSMSGroupPhoneNumbersEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/groups/{group_id}/members" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: GetSMSGroupPhoneNumbersRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id, - group_id=self.request_data.group_id - ) - - def handle_response(self, response: HTTPResponse): - super(GetSMSGroupPhoneNumbersEndpoint, self).handle_response(response) - return SinchGetSMSGroupPhoneNumbersResponse( - phone_numbers=response.body - ) diff --git a/sinch/domains/sms/endpoints/groups/list_groups.py b/sinch/domains/sms/endpoints/groups/list_groups.py deleted file mode 100644 index 758f3f71..00000000 --- a/sinch/domains/sms/endpoints/groups/list_groups.py +++ /dev/null @@ -1,43 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.groups.requests import ListSMSGroupRequest -from sinch.domains.sms.models.groups.responses import SinchListSMSGroupResponse -from sinch.domains.sms.models.groups import SMSGroup - - -class ListSMSGroupEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/groups" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: ListSMSGroupRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id - ) - - def build_query_params(self): - return self.request_data.as_dict() - - def handle_response(self, response: HTTPResponse): - super(ListSMSGroupEndpoint, self).handle_response(response) - return SinchListSMSGroupResponse( - groups=[ - SMSGroup( - id=group.get("id"), - size=group.get("size"), - created_at=group.get("created_at"), - modified_at=group.get("modified_at"), - name=group.get("name"), - child_groups=group.get("child_groups"), - auto_update=response.body.get("auto_update") - ) for group in response.body["groups"] - ], - page=response.body.get("page"), - page_size=response.body.get("page_size"), - count=response.body.get("count") - ) diff --git a/sinch/domains/sms/endpoints/groups/replace_group.py b/sinch/domains/sms/endpoints/groups/replace_group.py deleted file mode 100644 index 07234168..00000000 --- a/sinch/domains/sms/endpoints/groups/replace_group.py +++ /dev/null @@ -1,37 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.groups.requests import ReplaceSMSGroupPhoneNumbersRequest -from sinch.domains.sms.models.groups.responses import ReplaceSMSGroupResponse - - -class ReplaceSMSGroupEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/groups/{group_id}" - HTTP_METHOD = HTTPMethods.PUT.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: ReplaceSMSGroupPhoneNumbersRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id, - group_id=self.request_data.group_id - ) - - def request_body(self): - self.request_data.group_id = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse): - super(ReplaceSMSGroupEndpoint, self).handle_response(response) - return ReplaceSMSGroupResponse( - id=response.body.get("id"), - size=response.body.get("size"), - created_at=response.body.get("created_at"), - modified_at=response.body.get("modified_at"), - name=response.body.get("name"), - child_groups=response.body.get("child_groups"), - auto_update=response.body.get("auto_update") - ) diff --git a/sinch/domains/sms/endpoints/groups/update_group.py b/sinch/domains/sms/endpoints/groups/update_group.py deleted file mode 100644 index ccc4d0c2..00000000 --- a/sinch/domains/sms/endpoints/groups/update_group.py +++ /dev/null @@ -1,37 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.groups.requests import UpdateSMSGroupRequest -from sinch.domains.sms.models.groups.responses import UpdateSMSGroupResponse - - -class UpdateSMSGroupEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/groups/{group_id}" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: UpdateSMSGroupRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id, - group_id=self.request_data.group_id - ) - - def request_body(self): - self.request_data.group_id = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse): - super(UpdateSMSGroupEndpoint, self).handle_response(response) - return UpdateSMSGroupResponse( - id=response.body.get("id"), - size=response.body.get("size"), - created_at=response.body.get("created_at"), - modified_at=response.body.get("modified_at"), - name=response.body.get("name"), - child_groups=response.body.get("child_groups"), - auto_update=response.body.get("auto_update") - ) diff --git a/sinch/domains/sms/endpoints/inbounds/__init__.py b/sinch/domains/sms/endpoints/inbounds/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/sms/endpoints/inbounds/get_incoming_message.py b/sinch/domains/sms/endpoints/inbounds/get_incoming_message.py deleted file mode 100644 index 94f7d052..00000000 --- a/sinch/domains/sms/endpoints/inbounds/get_incoming_message.py +++ /dev/null @@ -1,34 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.inbounds.requests import GetSMSInboundMessageRequest -from sinch.domains.sms.models.inbounds.responses import GetInboundMessagesResponse - - -class GetInboundMessagesEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/inbounds/{inbound_id}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: GetSMSInboundMessageRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id, - inbound_id=self.request_data.inbound_id - ) - - def handle_response(self, response: HTTPResponse): - super(GetInboundMessagesEndpoint, self).handle_response(response) - return GetInboundMessagesResponse( - type=response.body.get("type"), - id=response.body.get("id"), - origin_number=response.body.get("from"), - destination_number=response.body.get("to"), - body=response.body.get("body"), - operator_id=response.body.get("operator_id"), - send_at=response.body.get("send_at"), - received_at=response.body.get("received_at") - ) diff --git a/sinch/domains/sms/endpoints/inbounds/list_incoming_messages.py b/sinch/domains/sms/endpoints/inbounds/list_incoming_messages.py deleted file mode 100644 index 4eb2d2d4..00000000 --- a/sinch/domains/sms/endpoints/inbounds/list_incoming_messages.py +++ /dev/null @@ -1,45 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.inbounds import InboundMessage -from sinch.domains.sms.models.inbounds.requests import ListSMSInboundMessageRequest -from sinch.domains.sms.models.inbounds.responses import SinchListInboundMessagesResponse - - -class ListInboundMessagesEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/inbounds" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: ListSMSInboundMessageRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id - ) - - def build_query_params(self): - return self.request_data.as_dict() - - def handle_response(self, response: HTTPResponse): - super(ListInboundMessagesEndpoint, self).handle_response(response) - return SinchListInboundMessagesResponse( - inbounds=[ - InboundMessage( - type=inbound.get("type"), - id=inbound.get("id"), - from_=inbound.get("from"), - to=inbound.get("to"), - body=inbound.get("body"), - operator_id=inbound.get("operator_id"), - send_at=inbound.get("send_at"), - received_at=inbound.get("received_at"), - client_reference=inbound.get("client_reference") - ) for inbound in response.body["inbounds"] - ], - page=response.body.get("page"), - page_size=response.body.get("page_size"), - count=response.body.get("count") - ) diff --git a/sinch/domains/sms/endpoints/sms_endpoint.py b/sinch/domains/sms/endpoints/sms_endpoint.py deleted file mode 100644 index 89bcb830..00000000 --- a/sinch/domains/sms/endpoints/sms_endpoint.py +++ /dev/null @@ -1,27 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.core.endpoint import HTTPEndpoint -from sinch.domains.sms.exceptions import SMSException -from sinch.core.enums import HTTPAuthentication - - -class SMSEndpoint(HTTPEndpoint): - def __init__(self, request_data, sinch): - self.request_data = request_data - self.sinch = sinch - - if sinch.configuration.service_plan_id: - self.project_or_service_id = sinch.configuration.service_plan_id - self.HTTP_AUTHENTICATION = HTTPAuthentication.SMS_TOKEN.value - self.sms_origin = self.sinch.configuration.sms_origin_with_service_plan_id - - else: - self.project_or_service_id = sinch.configuration.project_id - self.sms_origin = self.sinch.configuration.sms_origin - - def handle_response(self, response: HTTPResponse): - if response.status_code >= 400: - raise SMSException( - message=response.body["text"], - response=response, - is_from_server=True - ) diff --git a/sinch/domains/sms/models/batches/__init__.py b/sinch/domains/sms/models/batches/__init__.py deleted file mode 100644 index 1e352ded..00000000 --- a/sinch/domains/sms/models/batches/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class Batch(SinchRequestBaseModel): - id: str - to: list - from_: str - body: str - delivery_report: str - cancelled: str - type: str - campaign_id: str - created_at: str - modified_at: str - send_at: str - expire_at: str - callback_url: str = None - client_reference: str = None - feedback_enabled: bool = None - flash_message: bool = None diff --git a/sinch/domains/sms/models/batches/requests.py b/sinch/domains/sms/models/batches/requests.py deleted file mode 100644 index 99b6cb9d..00000000 --- a/sinch/domains/sms/models/batches/requests.py +++ /dev/null @@ -1,108 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class BatchRequest(SinchRequestBaseModel): - def as_dict(self): - payload = super(BatchRequest, self).as_dict() - payload["to"] = payload.pop("to") - if payload.get("from_"): - payload["from"] = payload.pop("from_") - return payload - - -@dataclass -class SendBatchRequest(BatchRequest): - to: list - from_: str - body: str - delivery_report: str - parameters: dict - send_at: str - expire_at: str - callback_url: str - client_reference: str - feedback_enabled: bool - flash_message: bool - truncate_concat: bool - type_: str - max_number_of_message_parts: int - from_ton: int - from_npi: int - - -@dataclass -class ListBatchesRequest(SinchRequestBaseModel): - page_size: int - from_s: str - start_date: str - end_date: str - client_reference: str - page: int = 0 - - -@dataclass -class GetBatchRequest(SinchRequestBaseModel): - batch_id: str - - -@dataclass -class CancelBatchRequest(SinchRequestBaseModel): - batch_id: str - - -@dataclass -class BatchDryRunRequest(BatchRequest): - per_recipient: bool - number_of_recipients: int - to: str - from_: str - body: str - type_: str - udh: str - delivery_report: str - parameters: dict - send_at: str - expire_at: str - callback_url: str - client_reference: str - flash_message: bool - max_number_of_message_parts: int - - -@dataclass -class UpdateBatchRequest(SinchRequestBaseModel): - batch_id: str - to_add: list - to_remove: list - from_: str - body: str - delivery_report: str - send_at: str - expire_at: str - callback_url: str - - -@dataclass -class ReplaceBatchRequest(BatchRequest): - batch_id: str - to: str - from_: str - body: str - delivery_report: str - parameters: dict - send_at: str - expire_at: str - type_: str - callback_url: str - client_reference: str - flash_message: bool - max_number_of_message_parts: int - udh: str - - -@dataclass -class SendDeliveryFeedbackRequest(SinchRequestBaseModel): - batch_id: str - recipients: list diff --git a/sinch/domains/sms/models/batches/responses.py b/sinch/domains/sms/models/batches/responses.py deleted file mode 100644 index a0e918f5..00000000 --- a/sinch/domains/sms/models/batches/responses.py +++ /dev/null @@ -1,60 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.sms.models.batches import Batch - - -@dataclass -class SendSMSBatchResponse(Batch): - pass - - -@dataclass -class ReplaceSMSBatchResponse(Batch): - pass - - -@dataclass -class ListSMSBatchesResponse(SinchBaseModel): - page: str - page_size: str - count: str - batches: list - - -@dataclass -class GetSMSBatchResponse(Batch): - pass - - -@dataclass -class CancelSMSBatchResponse(Batch): - pass - - -@dataclass -class SendSMSBatchDryRunResponse(SinchBaseModel): - number_of_recipients: int - number_of_messages: int - per_recipient: list - - -@dataclass -class UpdateSMSBatchResponse(SinchBaseModel): - id: str - to: list - from_: str - body: str - campaign_id: str - delivery_report: str - send_at: str - expire_at: str - callback_url: str - cancelled: bool - type: str - created_at: str - modified_at: str - - -@dataclass -class SendSMSDeliveryFeedbackResponse(SinchBaseModel): - pass diff --git a/sinch/domains/sms/models/delivery_reports/__init__.py b/sinch/domains/sms/models/delivery_reports/__init__.py deleted file mode 100644 index b6c40915..00000000 --- a/sinch/domains/sms/models/delivery_reports/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class DeliveryReport(SinchBaseModel): - at: str - batch_id: str - code: int - recipient: str - status: str - applied_originator: str - client_reference: str - encoding: str - number_of_message_parts: int - operator: str - operator_status_at: str - type: str diff --git a/sinch/domains/sms/models/delivery_reports/requests.py b/sinch/domains/sms/models/delivery_reports/requests.py deleted file mode 100644 index 09857fd3..00000000 --- a/sinch/domains/sms/models/delivery_reports/requests.py +++ /dev/null @@ -1,27 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class ListSMSDeliveryReportsRequest(SinchRequestBaseModel): - code: str - status: str - start_date: str - end_date: str - client_reference: str - page_size: int - page: int = 0 - - -@dataclass -class GetSMSDeliveryReportForBatchRequest(SinchRequestBaseModel): - batch_id: str - type_: str - status: list - code: list - - -@dataclass -class GetSMSDeliveryReportForNumberRequest(SinchRequestBaseModel): - batch_id: str - recipient_number: str diff --git a/sinch/domains/sms/models/delivery_reports/responses.py b/sinch/domains/sms/models/delivery_reports/responses.py deleted file mode 100644 index 18a53ba4..00000000 --- a/sinch/domains/sms/models/delivery_reports/responses.py +++ /dev/null @@ -1,34 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class ListSMSDeliveryReportsResponse(SinchBaseModel): - page: str - page_size: str - count: str - delivery_reports: list - - -@dataclass -class GetSMSDeliveryReportForBatchResponse(SinchBaseModel): - type: str - batch_id: str - total_message_count: str - statuses: list - client_reference: str - - -@dataclass -class GetSMSDeliveryReportForNumberResponse(SinchBaseModel): - at: str - batch_id: str - code: int - recipient: str - status: str - applied_originator: str - client_reference: str - number_of_message_parts: str - operator: str - operator_status_at: str - type: str diff --git a/sinch/domains/sms/endpoints/__init__.py b/sinch/domains/sms/models/v1/__init__.py similarity index 100% rename from sinch/domains/sms/endpoints/__init__.py rename to sinch/domains/sms/models/v1/__init__.py diff --git a/sinch/domains/sms/models/v1/internal/__init__.py b/sinch/domains/sms/models/v1/internal/__init__.py new file mode 100644 index 00000000..24ca21db --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/__init__.py @@ -0,0 +1,64 @@ +from sinch.domains.sms.models.v1.internal.batch_id_request import ( + BatchIdRequest, +) +from sinch.domains.sms.models.v1.internal.delivery_feedback_request import ( + DeliveryFeedbackRequest, +) +from sinch.domains.sms.models.v1.internal.list_batches_request import ( + ListBatchesRequest, +) +from sinch.domains.sms.models.v1.internal.list_delivery_reports_response import ( + ListDeliveryReportsResponse, +) +from sinch.domains.sms.models.v1.internal.get_recipient_delivery_report_request import ( + GetRecipientDeliveryReportRequest, +) +from sinch.domains.sms.models.v1.internal.get_batch_delivery_report_request import ( + GetBatchDeliveryReportRequest, +) +from sinch.domains.sms.models.v1.internal.list_delivery_reports_request import ( + ListDeliveryReportsRequest, +) + +__all__ = [ + "BatchIdRequest", + "DeliveryFeedbackRequest", + "ListBatchesRequest", + "ListDeliveryReportsResponse", + "GetRecipientDeliveryReportRequest", + "ListDeliveryReportsRequest", + "GetBatchDeliveryReportRequest", + "DryRunRequest", + "ReplaceBatchRequest", + "SendSMSRequest", + "UpdateBatchMessageRequest", +] + + +# Lazy import to avoid circular dependency +def __getattr__(name: str): + if name == "DryRunRequest": + from sinch.domains.sms.models.v1.internal.dry_run_request import ( + DryRunRequest, + ) + + return DryRunRequest + if name == "ReplaceBatchRequest": + from sinch.domains.sms.models.v1.internal.replace_batch_request import ( + ReplaceBatchRequest, + ) + + return ReplaceBatchRequest + if name == "SendSMSRequest": + from sinch.domains.sms.models.v1.internal.send_sms_request import ( + SendSMSRequest, + ) + + return SendSMSRequest + if name == "UpdateBatchMessageRequest": + from sinch.domains.sms.models.v1.internal.update_batch_message_request import ( + UpdateBatchMessageRequest, + ) + + return UpdateBatchMessageRequest + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/sms/models/v1/internal/base/__init__.py b/sinch/domains/sms/models/v1/internal/base/__init__.py new file mode 100644 index 00000000..33b1664b --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/base/__init__.py @@ -0,0 +1,9 @@ +from sinch.domains.sms.models.v1.internal.base.base_model_configuration import ( + BaseModelConfigurationRequest, + BaseModelConfigurationResponse, +) + +__all__ = [ + "BaseModelConfigurationRequest", + "BaseModelConfigurationResponse", +] diff --git a/sinch/domains/sms/models/v1/internal/base/base_model_configuration.py b/sinch/domains/sms/models/v1/internal/base/base_model_configuration.py new file mode 100644 index 00000000..204ea49d --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/base/base_model_configuration.py @@ -0,0 +1,44 @@ +import re +from typing import Any +from pydantic import BaseModel, ConfigDict + + +class BaseModelConfigurationRequest(BaseModel): + """ + A base model that allows extra fields and converts snake_case to camelCase. + """ + + model_config = ConfigDict( + # Allows using both alias (camelCase) and field name (snake_case) + populate_by_name=True, + # Allows extra values in input + extra="allow", + ) + + +class BaseModelConfigurationResponse(BaseModel): + """ + A base model that allows extra fields and converts camelCase to snake_case + """ + + @staticmethod + def _to_snake_case(camel_str: str) -> str: + """Helper to convert camelCase string to snake_case.""" + return re.sub(r"(? None: + """Converts unknown fields from camelCase to snake_case.""" + if self.__pydantic_extra__: + converted_extra = { + self._to_snake_case(key): value + for key, value in self.__pydantic_extra__.items() + } + self.__pydantic_extra__.clear() + self.__pydantic_extra__.update(converted_extra) diff --git a/sinch/domains/sms/models/v1/internal/batch_id_request.py b/sinch/domains/sms/models/v1/internal/batch_id_request.py new file mode 100644 index 00000000..c67d496e --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/batch_id_request.py @@ -0,0 +1,11 @@ +from pydantic import Field, StrictStr +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class BatchIdRequest(BaseModelConfigurationRequest): + batch_id: StrictStr = Field( + default=..., + description="The unique identifier of the batch message.", + ) diff --git a/sinch/domains/sms/models/v1/internal/delivery_feedback_request.py b/sinch/domains/sms/models/v1/internal/delivery_feedback_request.py new file mode 100644 index 00000000..6eebb7d8 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/delivery_feedback_request.py @@ -0,0 +1,15 @@ +from pydantic import Field, StrictStr, conlist +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class DeliveryFeedbackRequest(BaseModelConfigurationRequest): + batch_id: StrictStr = Field( + default=..., + description="The unique identifier of the batch message for which delivery feedback is being provided.", + ) + recipients: conlist(StrictStr) = Field( + default=..., + description="A list of phone numbers (MSISDNs) that have successfully received the message. The key is required, however, the value can be an empty array (`[]`) for *a batch*. If the feedback was enabled for *a group*, at least one phone number is required.", + ) diff --git a/sinch/domains/sms/models/v1/internal/dry_run_request.py b/sinch/domains/sms/models/v1/internal/dry_run_request.py new file mode 100644 index 00000000..e33f3e4b --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/dry_run_request.py @@ -0,0 +1,47 @@ +from typing import Union, Optional +from pydantic import BaseModel, Field, StrictBool, StrictInt +from sinch.domains.sms.models.v1.shared.text_request import TextRequest +from sinch.domains.sms.models.v1.shared.binary_request import ( + BinaryRequest, +) +from sinch.domains.sms.models.v1.shared.media_request import ( + MediaRequest, +) + + +class DryRunMixin(BaseModel): + """Mixin that adds dry run query parameters to request models.""" + + per_recipient: Optional[StrictBool] = Field( + default=None, + description="Whether to include per recipient details in the response", + ) + number_of_recipients: Optional[StrictInt] = Field( + default=None, + description="Max number of recipients to include per recipient details for in the response", + ) + + +class DryRunTextRequest(DryRunMixin, TextRequest): + """Request model for dry run with a text message.""" + + pass + + +class DryRunBinaryRequest(DryRunMixin, BinaryRequest): + """Request model for dry run with a binary message.""" + + pass + + +class DryRunMediaRequest(DryRunMixin, MediaRequest): + """Request model for dry run with a media message.""" + + pass + + +DryRunRequest = Union[ + DryRunTextRequest, + DryRunBinaryRequest, + DryRunMediaRequest, +] diff --git a/sinch/domains/sms/models/v1/internal/get_batch_delivery_report_request.py b/sinch/domains/sms/models/v1/internal/get_batch_delivery_report_request.py new file mode 100644 index 00000000..e6f951e7 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/get_batch_delivery_report_request.py @@ -0,0 +1,30 @@ +from typing import Optional, List +from pydantic import StrictStr, Field +from sinch.domains.sms.models.v1.types import ( + DeliveryReceiptStatusCodeType, + DeliveryReportType, + DeliveryStatusType, +) +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class GetBatchDeliveryReportRequest(BaseModelConfigurationRequest): + batch_id: StrictStr + type: Optional[DeliveryReportType] = Field( + default=None, + description="The type of delivery report.", + ) + status: Optional[List[DeliveryStatusType]] = Field( + default=None, + description="Comma separated list of delivery_report_statuses to include", + ) + code: Optional[List[DeliveryReceiptStatusCodeType]] = Field( + default=None, + description="Comma separated list of delivery receipt error codes to include", + ) + client_reference: Optional[StrictStr] = Field( + default=None, + description="The client identifier of the batch this delivery report belongs to, if set when submitting batch.", + ) diff --git a/sinch/domains/sms/models/v1/internal/get_recipient_delivery_report_request.py b/sinch/domains/sms/models/v1/internal/get_recipient_delivery_report_request.py new file mode 100644 index 00000000..b4a52f38 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/get_recipient_delivery_report_request.py @@ -0,0 +1,14 @@ +from pydantic import Field, StrictStr +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class GetRecipientDeliveryReportRequest(BaseModelConfigurationRequest): + batch_id: StrictStr = Field( + default=..., + description="The batch ID you received from sending a message.", + ) + recipient_msisdn: StrictStr = Field( + default=..., description="The recipient phone number in E.164 format." + ) diff --git a/sinch/domains/sms/models/v1/internal/list_batches_request.py b/sinch/domains/sms/models/v1/internal/list_batches_request.py new file mode 100644 index 00000000..ffe38212 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/list_batches_request.py @@ -0,0 +1,15 @@ +from typing import Optional +from datetime import datetime +from pydantic import Field, StrictStr, conlist, StrictInt +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class ListBatchesRequest(BaseModelConfigurationRequest): + page: Optional[StrictInt] = None + page_size: Optional[StrictInt] = None + start_date: Optional[datetime] = None + end_date: Optional[datetime] = None + from_: Optional[conlist(StrictStr)] = Field(default=None, alias="from") + client_reference: Optional[StrictStr] = None diff --git a/sinch/domains/sms/models/v1/internal/list_delivery_reports_request.py b/sinch/domains/sms/models/v1/internal/list_delivery_reports_request.py new file mode 100644 index 00000000..8594e5aa --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/list_delivery_reports_request.py @@ -0,0 +1,18 @@ +from typing import Optional +from datetime import datetime +from pydantic import conlist, StrictInt, StrictStr +from sinch.domains.sms.models.v1.types import DeliveryReceiptStatusCodeType +from sinch.domains.sms.models.v1.types import DeliveryStatusType +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class ListDeliveryReportsRequest(BaseModelConfigurationRequest): + page: Optional[StrictInt] = None + page_size: Optional[StrictInt] = None + start_date: Optional[datetime] = None + end_date: Optional[datetime] = None + status: Optional[conlist(DeliveryStatusType)] = None + code: Optional[conlist(DeliveryReceiptStatusCodeType)] = None + client_reference: Optional[StrictStr] = None diff --git a/sinch/domains/sms/models/v1/internal/list_delivery_reports_response.py b/sinch/domains/sms/models/v1/internal/list_delivery_reports_response.py new file mode 100644 index 00000000..1b22519a --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/list_delivery_reports_response.py @@ -0,0 +1,31 @@ +from typing import Optional +from pydantic import Field, StrictInt, conlist +from sinch.domains.sms.models.v1.response.recipient_delivery_report import ( + RecipientDeliveryReport, +) +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class ListDeliveryReportsResponse(BaseModelConfigurationResponse): + count: Optional[StrictInt] = Field( + default=None, + description="The total number of entries matching the given filters.", + ) + page: Optional[StrictInt] = Field( + default=None, description="The requested page." + ) + page_size: Optional[StrictInt] = Field( + default=None, + description="The number of entries returned in this request.", + ) + delivery_reports: Optional[conlist(RecipientDeliveryReport)] = Field( + default=None, + description="The page of delivery reports matching the given filters.", + ) + + @property + def content(self): + """Returns the content of the delivery report list.""" + return self.delivery_reports or [] diff --git a/sinch/domains/sms/models/v1/internal/replace_batch_request.py b/sinch/domains/sms/models/v1/internal/replace_batch_request.py new file mode 100644 index 00000000..7fe3ff9d --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/replace_batch_request.py @@ -0,0 +1,34 @@ +from typing import Union +from sinch.domains.sms.models.v1.shared.text_request import TextRequest +from sinch.domains.sms.models.v1.shared.binary_request import ( + BinaryRequest, +) +from sinch.domains.sms.models.v1.shared.media_request import ( + MediaRequest, +) +from sinch.domains.sms.models.v1.shared.batch_id_mixin import BatchIdMixin + + +class ReplaceTextRequest(BatchIdMixin, TextRequest): + """Request model for replacing a batch with a text message.""" + + pass + + +class ReplaceBinaryRequest(BatchIdMixin, BinaryRequest): + """Request model for replacing a batch with a binary message.""" + + pass + + +class ReplaceMediaRequest(BatchIdMixin, MediaRequest): + """Request model for replacing a batch with a media message.""" + + pass + + +ReplaceBatchRequest = Union[ + ReplaceTextRequest, + ReplaceBinaryRequest, + ReplaceMediaRequest, +] diff --git a/sinch/domains/sms/models/v1/internal/send_sms_request.py b/sinch/domains/sms/models/v1/internal/send_sms_request.py new file mode 100644 index 00000000..45aed821 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/send_sms_request.py @@ -0,0 +1,15 @@ +from typing import Union +from sinch.domains.sms.models.v1.shared.text_request import TextRequest +from sinch.domains.sms.models.v1.shared.binary_request import ( + BinaryRequest, +) +from sinch.domains.sms.models.v1.shared.media_request import ( + MediaRequest, +) + + +SendSMSRequest = Union[ + TextRequest, + BinaryRequest, + MediaRequest, +] diff --git a/sinch/domains/sms/models/v1/internal/update_batch_message_request.py b/sinch/domains/sms/models/v1/internal/update_batch_message_request.py new file mode 100644 index 00000000..71e05ab0 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/update_batch_message_request.py @@ -0,0 +1,36 @@ +from typing import Union +from sinch.domains.sms.models.v1.internal.update_text_request import ( + UpdateTextRequest, +) +from sinch.domains.sms.models.v1.internal.update_binary_request import ( + UpdateBinaryRequest, +) +from sinch.domains.sms.models.v1.internal.update_media_request import ( + UpdateMediaRequest, +) +from sinch.domains.sms.models.v1.shared.batch_id_mixin import BatchIdMixin + + +class UpdateTextRequestWithBatchId(BatchIdMixin, UpdateTextRequest): + """Request model for updating a batch with a text message.""" + + pass + + +class UpdateBinaryRequestWithBatchId(BatchIdMixin, UpdateBinaryRequest): + """Request model for updating a batch with a binary message.""" + + pass + + +class UpdateMediaRequestWithBatchId(BatchIdMixin, UpdateMediaRequest): + """Request model for updating a batch with a media message.""" + + pass + + +UpdateBatchMessageRequest = Union[ + UpdateTextRequestWithBatchId, + UpdateBinaryRequestWithBatchId, + UpdateMediaRequestWithBatchId, +] diff --git a/sinch/domains/sms/models/v1/internal/update_binary_request.py b/sinch/domains/sms/models/v1/internal/update_binary_request.py new file mode 100644 index 00000000..f23c5182 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/update_binary_request.py @@ -0,0 +1,65 @@ +from typing import Optional +from datetime import datetime +from pydantic import Field, StrictBool, StrictStr, conlist, StrictInt +from sinch.domains.sms.models.v1.types import DeliveryReportType +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class UpdateBinaryRequest(BaseModelConfigurationRequest): + from_: Optional[StrictStr] = Field( + default=None, + alias="from", + description="Sender number. Must be valid phone number, short code or alphanumeric.", + ) + type: Optional[StrictStr] = Field( + default="mt_binary", + description="SMS in [binary](https://community.sinch.com/t5/Glossary/Binary-SMS/ta-p/7470) format.", + ) + to_add: Optional[conlist(StrictStr)] = Field( + default=None, + description="List of phone numbers and group IDs to add to the batch.", + ) + to_remove: Optional[conlist(StrictStr)] = Field( + default=None, + description="List of phone numbers and group IDs to remove from the batch.", + ) + delivery_report: Optional[DeliveryReportType] = None + send_at: Optional[datetime] = Field( + default=None, + description="If set, in the future the message will be delayed until `send_at` occurs. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`. Constraints: Must be before expire_at. If set in the past, messages will be sent immediately. ", + ) + expire_at: Optional[datetime] = Field( + default=None, + description="If set, the system will stop trying to deliver the message at this point. Constraints: Must be after `send_at` Default: 3 days after `send_at` ", + ) + event_destination_target: Optional[StrictStr] = Field( + default=None, + alias="callback_url", + description="Override the default callback URL for this batch. Constraints: Must be valid URL. ", + ) + client_reference: Optional[StrictStr] = Field( + default=None, + description="The client identifier of a batch message. If set, the identifier will be added in the delivery report/callback of this batch", + ) + feedback_enabled: Optional[StrictBool] = Field( + default=None, + description="If set to `true`, then [feedback](/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/deliveryFeedback) is expected after successful delivery.", + ) + body: Optional[StrictStr] = Field( + default=None, + description="The message content Base64 encoded. Max 140 bytes together with udh.", + ) + udh: StrictStr = Field( + default=..., + description="The UDH header of a binary message HEX encoded. Max 140 bytes together with body.", + ) + from_ton: Optional[StrictInt] = Field( + default=None, + description="The type of number for the sender number. Use to override the automatic detection.", + ) + from_npi: Optional[StrictInt] = Field( + default=None, + description="Number Plan Indicator for the sender number. Use to override the automatic detection.", + ) diff --git a/sinch/domains/sms/models/v1/internal/update_media_request.py b/sinch/domains/sms/models/v1/internal/update_media_request.py new file mode 100644 index 00000000..ed347d3d --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/update_media_request.py @@ -0,0 +1,56 @@ +from typing import Dict, Optional +from datetime import datetime +from pydantic import Field, StrictBool, StrictStr, conlist +from sinch.domains.sms.models.v1.types import DeliveryReportType +from sinch.domains.sms.models.v1.shared import MediaBody +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class UpdateMediaRequest(BaseModelConfigurationRequest): + from_: Optional[StrictStr] = Field( + default=None, + alias="from", + description="Sender number. Must be valid phone number, short code or alphanumeric.", + ) + type: Optional[StrictStr] = Field(default="mt_media", description="MMS") + to_add: Optional[conlist(StrictStr)] = Field( + default=None, + description="List of phone numbers and group IDs to add to the batch.", + ) + to_remove: Optional[conlist(StrictStr)] = Field( + default=None, + description="List of phone numbers and group IDs to remove from the batch.", + ) + delivery_report: Optional[DeliveryReportType] = None + send_at: Optional[datetime] = Field( + default=None, + description="If set, in the future the message will be delayed until `send_at` occurs. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`. Constraints: Must be before expire_at. If set in the past, messages will be sent immediately. ", + ) + expire_at: Optional[datetime] = Field( + default=None, + description="If set, the system will stop trying to deliver the message at this point. Constraints: Must be after `send_at` Default: 3 days after `send_at` ", + ) + event_destination_target: Optional[StrictStr] = Field( + default=None, + alias="callback_url", + description="Override the default callback URL for this batch. Constraints: Must be valid URL. ", + ) + client_reference: Optional[StrictStr] = Field( + default=None, + description="The client identifier of a batch message. If set, the identifier will be added in the delivery report/callback of this batch", + ) + feedback_enabled: Optional[StrictBool] = Field( + default=None, + description="If set to `true`, then [feedback](/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/deliveryFeedback) is expected after successful delivery.", + ) + body: Optional[MediaBody] = None + parameters: Optional[Dict[StrictStr, Dict[StrictStr, StrictStr]]] = Field( + default=None, + description="Contains the parameters that will be used for customizing the message for each recipient. [Click here to learn more about parameterization](/docs/sms/resources/message-info/message-parameterization).", + ) + strict_validation: Optional[StrictBool] = Field( + default=None, + description="Whether or not you want the media included in your message to be checked against [Sinch MMS channel best practices](/docs/mms/bestpractices/). If set to true, your message will be rejected if it doesn't conform to the listed recommendations, otherwise no validation will be performed. ", + ) diff --git a/sinch/domains/sms/models/v1/internal/update_text_request.py b/sinch/domains/sms/models/v1/internal/update_text_request.py new file mode 100644 index 00000000..d43ee83f --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/update_text_request.py @@ -0,0 +1,81 @@ +from typing import Dict, Optional +from datetime import datetime +from pydantic import ( + Field, + StrictBool, + StrictStr, + conlist, + StrictInt, +) +from sinch.domains.sms.models.v1.types import DeliveryReportType +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class UpdateTextRequest(BaseModelConfigurationRequest): + from_: Optional[StrictStr] = Field( + default=None, + alias="from", + description="Sender number. Must be valid phone number, short code or alphanumeric.", + ) + type: Optional[StrictStr] = Field( + default="mt_text", description="Regular SMS" + ) + to_add: Optional[conlist(StrictStr)] = Field( + default=None, + description="List of phone numbers and group IDs to add to the batch.", + ) + to_remove: Optional[conlist(StrictStr)] = Field( + default=None, + description="List of phone numbers and group IDs to remove from the batch.", + ) + delivery_report: Optional[DeliveryReportType] = None + send_at: Optional[datetime] = Field( + default=None, + description="If set, in the future the message will be delayed until `send_at` occurs. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`. Constraints: Must be before expire_at. If set in the past, messages will be sent immediately. ", + ) + expire_at: Optional[datetime] = Field( + default=None, + description="If set, the system will stop trying to deliver the message at this point. Constraints: Must be after `send_at` Default: 3 days after `send_at` ", + ) + event_destination_target: Optional[StrictStr] = Field( + default=None, + alias="callback_url", + description="Override the default callback URL for this batch. Constraints: Must be valid URL. ", + ) + client_reference: Optional[StrictStr] = Field( + default=None, + description="The client identifier of a batch message. If set, the identifier will be added in the delivery report/callback of this batch", + ) + feedback_enabled: Optional[StrictBool] = Field( + default=None, + description="If set to `true`, then [feedback](/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/deliveryFeedback) is expected after successful delivery.", + ) + parameters: Optional[Dict[StrictStr, Dict[StrictStr, StrictStr]]] = Field( + default=None, + description="Contains the parameters that will be used for customizing the message for each recipient. [Click here to learn more about parameterization](/docs/sms/resources/message-info/message-parameterization).", + ) + body: Optional[StrictStr] = Field( + default=None, description="The message content" + ) + from_ton: Optional[StrictInt] = Field( + default=None, + description="The type of number for the sender number. Use to override the automatic detection.", + ) + from_npi: Optional[StrictInt] = Field( + default=None, + description="Number Plan Indicator for the sender number. Use to override the automatic detection.", + ) + max_number_of_message_parts: Optional[StrictInt] = Field( + default=None, + description="Message will be dispatched only if it is not split to more parts than Max Number of Message Parts", + ) + truncate_concat: Optional[StrictBool] = Field( + default=None, + description="If set to true the message will be shortened when exceeding one part.", + ) + flash_message: Optional[StrictBool] = Field( + default=None, + description="Shows message on screen without user interaction while not saving the message to the inbox.", + ) diff --git a/sinch/domains/sms/models/v1/response/__init__.py b/sinch/domains/sms/models/v1/response/__init__.py new file mode 100644 index 00000000..9648cf44 --- /dev/null +++ b/sinch/domains/sms/models/v1/response/__init__.py @@ -0,0 +1,19 @@ +from sinch.domains.sms.models.v1.response.batch_delivery_report import ( + BatchDeliveryReport, +) +from sinch.domains.sms.models.v1.response.dry_run_response import ( + DryRunResponse, +) +from sinch.domains.sms.models.v1.response.list_batches_response import ( + ListBatchesResponse, +) +from sinch.domains.sms.models.v1.response.recipient_delivery_report import ( + RecipientDeliveryReport, +) + +__all__ = [ + "BatchDeliveryReport", + "DryRunResponse", + "ListBatchesResponse", + "RecipientDeliveryReport", +] diff --git a/sinch/domains/sms/models/v1/response/batch_delivery_report.py b/sinch/domains/sms/models/v1/response/batch_delivery_report.py new file mode 100644 index 00000000..7a50509f --- /dev/null +++ b/sinch/domains/sms/models/v1/response/batch_delivery_report.py @@ -0,0 +1,27 @@ +from typing import Optional +from pydantic import Field, StrictStr, conlist, StrictInt +from sinch.domains.sms.models.v1.shared import MessageDeliveryStatus +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class BatchDeliveryReport(BaseModelConfigurationResponse): + batch_id: StrictStr = Field( + default=..., + description="The ID of the batch this delivery report belongs to.", + ) + client_reference: Optional[StrictStr] = Field( + default=None, + description="The client identifier of the batch this delivery report belongs to, if set when submitting batch.", + ) + statuses: conlist(MessageDeliveryStatus) = Field( + default=..., + description="Array with status objects. Only status codes with at least one recipient will be listed.", + ) + total_message_count: StrictInt = Field( + default=..., description="The total number of messages in the batch." + ) + type: StrictStr = Field( + default=..., description="The delivery report type." + ) diff --git a/sinch/domains/sms/models/v1/response/dry_run_response.py b/sinch/domains/sms/models/v1/response/dry_run_response.py new file mode 100644 index 00000000..e2342cef --- /dev/null +++ b/sinch/domains/sms/models/v1/response/dry_run_response.py @@ -0,0 +1,17 @@ +from typing import Optional +from pydantic import Field, StrictInt, conlist +from sinch.domains.sms.models.v1.shared import DryRunPerRecipientDetails +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class DryRunResponse(BaseModelConfigurationResponse): + number_of_recipients: StrictInt = Field( + default=..., description="The number of recipients in the batch" + ) + number_of_messages: StrictInt = Field( + default=..., + description="The total number of SMS message parts to be sent in the batch", + ) + per_recipient: Optional[conlist(DryRunPerRecipientDetails)] = None diff --git a/sinch/domains/sms/models/v1/response/list_batches_response.py b/sinch/domains/sms/models/v1/response/list_batches_response.py new file mode 100644 index 00000000..fd60674a --- /dev/null +++ b/sinch/domains/sms/models/v1/response/list_batches_response.py @@ -0,0 +1,29 @@ +from typing import Optional +from pydantic import Field, StrictInt, conlist +from sinch.domains.sms.models.v1.types import BatchResponse +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class ListBatchesResponse(BaseModelConfigurationResponse): + count: Optional[StrictInt] = Field( + default=None, + description="The total number of entries matching the given filters.", + ) + page: Optional[StrictInt] = Field( + default=None, description="The requested page." + ) + batches: Optional[conlist(BatchResponse)] = Field( + default=None, + description="The page of batches matching the given filters.", + ) + page_size: Optional[StrictInt] = Field( + default=None, + description="The number of entries returned in this request.", + ) + + @property + def content(self): + """Returns the content of batches list.""" + return self.batches or [] diff --git a/sinch/domains/sms/models/v1/response/recipient_delivery_report.py b/sinch/domains/sms/models/v1/response/recipient_delivery_report.py new file mode 100644 index 00000000..ddc605ce --- /dev/null +++ b/sinch/domains/sms/models/v1/response/recipient_delivery_report.py @@ -0,0 +1,78 @@ +from typing import Optional, Union +from datetime import datetime +from pydantic import Field, StrictInt, StrictStr, field_validator +from sinch.domains.sms.models.v1.types.delivery_receipt_status_code_type import ( + DeliveryReceiptStatusCodeType, +) +from sinch.domains.sms.models.v1.types.delivery_status_type import ( + DeliveryStatusType, +) +from sinch.domains.sms.models.v1.types.encoding_type import ( + EncodingType, +) +from sinch.domains.sms.models.v1.types.recipient_delivery_report_type import ( + RecipientDeliveryReportType, +) +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.authentication.sinch_events.v1.sinch_event_utils import ( + normalize_iso_timestamp, +) + + +class RecipientDeliveryReport(BaseModelConfigurationResponse): + applied_originator: Optional[StrictStr] = Field( + default=None, + description="The default originator used for the recipient this delivery report belongs to, if default originator pool configured and no originator set when submitting batch.", + ) + at: datetime = Field( + default=..., + description="A timestamp of when the Delivery Report was created in the Sinch service. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + batch_id: StrictStr = Field( + default=..., + description="The ID of the batch this delivery report belongs to", + ) + client_reference: Optional[StrictStr] = Field( + default=None, + description="The client identifier of the batch this delivery report belongs to, if set when submitting batch.", + ) + code: DeliveryReceiptStatusCodeType = Field( + default=..., + description="The detailed [status code](https://developers.sinch.com/docs/sms/api-reference/sms/tag/Delivery-reports/#tag/Delivery-reports/section/Delivery-report-error-codes).", + ) + encoding: Optional[EncodingType] = Field( + default=None, + description="Applied encoding for message. Present only if smart encoding is enabled.", + ) + number_of_message_parts: Optional[StrictInt] = Field( + default=None, + description="The number of parts the message was split into. Present only if `max_number_of_message_parts` parameter was set.", + ) + operator: Optional[StrictStr] = Field( + default=None, + description="The operator that was used for delivering the message to this recipient, if enabled on the account by Sinch.", + ) + operator_status_at: Optional[datetime] = Field( + default=None, + description="A timestamp extracted from the Delivery Receipt from the originating SMSC. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + recipient: StrictStr = Field( + default=..., description="Phone number that was queried." + ) + status: DeliveryStatusType = Field( + default=..., description="The delivery status." + ) + type: RecipientDeliveryReportType = Field( + default=..., description="The recipient delivery report type." + ) + + @field_validator("at", "operator_status_at", mode="before") + @classmethod + def normalize_timestamp( + cls, value: Optional[Union[str, datetime]] + ) -> Optional[Union[str, datetime]]: + if isinstance(value, str): + return normalize_iso_timestamp(value) + return value diff --git a/sinch/domains/sms/models/v1/shared/__init__.py b/sinch/domains/sms/models/v1/shared/__init__.py new file mode 100644 index 00000000..5139c795 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/__init__.py @@ -0,0 +1,25 @@ +from sinch.domains.sms.models.v1.shared.binary_request import BinaryRequest +from sinch.domains.sms.models.v1.shared.binary_response import BinaryResponse +from sinch.domains.sms.models.v1.shared.dry_run_per_recipient_details import ( + DryRunPerRecipientDetails, +) +from sinch.domains.sms.models.v1.shared.media_body import MediaBody +from sinch.domains.sms.models.v1.shared.media_request import MediaRequest +from sinch.domains.sms.models.v1.shared.media_response import MediaResponse +from sinch.domains.sms.models.v1.shared.message_delivery_status import ( + MessageDeliveryStatus, +) +from sinch.domains.sms.models.v1.shared.text_request import TextRequest +from sinch.domains.sms.models.v1.shared.text_response import TextResponse + +__all__ = [ + "BinaryRequest", + "BinaryResponse", + "DryRunPerRecipientDetails", + "MediaBody", + "MediaRequest", + "MediaResponse", + "MessageDeliveryStatus", + "TextRequest", + "TextResponse", +] diff --git a/sinch/domains/sms/models/v1/shared/batch_id_mixin.py b/sinch/domains/sms/models/v1/shared/batch_id_mixin.py new file mode 100644 index 00000000..776b58c3 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/batch_id_mixin.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel, Field, StrictStr + + +class BatchIdMixin(BaseModel): + """Mixin that adds batch_id field to request models.""" + + batch_id: StrictStr = Field( + default=..., + description="The batch ID you received from sending a message.", + ) diff --git a/sinch/domains/sms/models/v1/shared/binary_request.py b/sinch/domains/sms/models/v1/shared/binary_request.py new file mode 100644 index 00000000..35fc4146 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/binary_request.py @@ -0,0 +1,63 @@ +from typing import Optional +from datetime import datetime +from pydantic import Field, StrictBool, StrictStr, conlist, StrictInt +from sinch.domains.sms.models.v1.types import ( + DeliveryReportType, +) +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class BinaryRequest(BaseModelConfigurationRequest): + to: conlist(StrictStr) = Field( + default=..., + description="A list of phone numbers and group IDs that will receive the batch. [More info](https://community.sinch.com/t5/Glossary/MSISDN/ta-p/7628).", + ) + from_: Optional[StrictStr] = Field( + default=None, + alias="from", + description="Sender number. Must be valid phone number, short code or alphanumeric. Required if Automatic Default Originator not configured.", + ) + body: StrictStr = Field( + default=..., + description="The message content Base64 encoded. Max 140 bytes including `udh`.", + ) + udh: StrictStr = Field( + default=..., + description="The UDH header of a binary message HEX encoded. Max 140 bytes including the `body`.", + ) + type: Optional[StrictStr] = Field( + default="mt_binary", + description="SMS in [binary](https://community.sinch.com/t5/Glossary/Binary-SMS/ta-p/7470) format.", + ) + delivery_report: Optional[DeliveryReportType] = None + send_at: Optional[datetime] = Field( + default=None, + description="If set in the future the message will be delayed until `send_at` occurs. Must be before `expire_at`. If set in the past, messages will be sent immediately. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601). For example: `YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + expire_at: Optional[datetime] = Field( + default=None, + description="If set, the system will stop trying to deliver the message at this point. Must be after `send_at`. Default and max is 3 days after `send_at`. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601). For example: `YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + event_destination_target: Optional[StrictStr] = Field( + default=None, + alias="callback_url", + description="Override the *default* callback URL for this batch. Must be a valid URL. Learn how to set a default callback URL [here](https://community.sinch.com/t5/SMS/How-do-I-assign-a-callback-URL-to-an-SMS-service-plan/ta-p/8414).", + ) + client_reference: Optional[StrictStr] = Field( + default=None, + description="The client identifier of a batch message. If set, the identifier will be added in the delivery report/callback of this batch.", + ) + feedback_enabled: Optional[StrictBool] = Field( + default=None, + description="If set to true then [feedback](/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/deliveryFeedback) is expected after successful delivery.", + ) + from_ton: Optional[StrictInt] = Field( + default=None, + description="The type of number for the sender number. Use to override the automatic detection.", + ) + from_npi: Optional[StrictInt] = Field( + default=None, + description="Number Plan Indicator for the sender number. Use to override the automatic detection.", + ) diff --git a/sinch/domains/sms/models/v1/shared/binary_response.py b/sinch/domains/sms/models/v1/shared/binary_response.py new file mode 100644 index 00000000..96309913 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/binary_response.py @@ -0,0 +1,83 @@ +from typing import Literal, Optional +from datetime import datetime +from pydantic import ( + Field, + StrictBool, + StrictStr, + conlist, + StrictInt, +) +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class BinaryResponse(BaseModelConfigurationResponse): + id: Optional[StrictStr] = Field( + default=None, description="Unique identifier for batch." + ) + to: Optional[conlist(StrictStr)] = Field( + default=None, + description="A list of phone numbers and group IDs that have received the batch. [More info](https://community.sinch.com/t5/Glossary/MSISDN/ta-p/7628).", + ) + from_: Optional[StrictStr] = Field( + default=None, + alias="from", + description="The sender number provided. Required if the Automatic Default Originator is not configured.", + ) + canceled: Optional[StrictBool] = Field( + default=None, + description="Indicates whether or not the batch has been canceled.", + ) + body: Optional[StrictStr] = Field( + default=None, + description="The message content provided. Base64 encoded. ", + ) + udh: Optional[StrictStr] = Field( + default=None, + description="The [UDH](https://community.sinch.com/t5/Glossary/UDH-User-Data-Header/ta-p/7776) header of a binary message HEX encoded. Max 140 bytes including the `body`.", + ) + type: Literal["mt_binary"] = Field( + default=..., + description="SMS in [binary](https://community.sinch.com/t5/Glossary/Binary-SMS/ta-p/7470) format.", + ) + created_at: Optional[datetime] = Field( + default=None, + description="Timestamp for when batch was created. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601). For example: `YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + modified_at: Optional[datetime] = Field( + default=None, + description="Timestamp for when batch was last updated. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601). For example: `YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + delivery_report: Optional[StrictStr] = Field( + default=None, + description="The delivery report callback option selected. Will be either `none`, `summary`, `full`, `per_recipient`, or `per_recipient_final`.", + ) + send_at: Optional[datetime] = Field( + default=None, + description="If set, the date and time the message should be delivered. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601). For example: `YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + expire_at: Optional[datetime] = Field( + default=None, + description="If set, the date and time the message will expire. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601). For example: `YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + event_destination_target: Optional[StrictStr] = Field( + default=None, + alias="callback_url", + description="The callback URL provided in the request.", + ) + client_reference: Optional[StrictStr] = Field( + default=None, + description="The string input to identify this batch message. If set, the identifier will be added in the delivery report/callback of this batch.", + ) + feedback_enabled: Optional[StrictBool] = Field( + default=None, + description="If set to true, then [feedback](/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/deliveryFeedback) is expected after successful delivery.", + ) + from_ton: Optional[StrictInt] = Field( + default=None, description="The type of number for the sender number." + ) + from_npi: Optional[StrictInt] = Field( + default=None, + description="Number Plan Indicator for the sender number.", + ) diff --git a/sinch/domains/sms/models/v1/shared/dry_run_per_recipient_details.py b/sinch/domains/sms/models/v1/shared/dry_run_per_recipient_details.py new file mode 100644 index 00000000..0ca68837 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/dry_run_per_recipient_details.py @@ -0,0 +1,15 @@ +from pydantic import Field, StrictInt, StrictStr +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.sms.models.v1.types import EncodingType + + +class DryRunPerRecipientDetails(BaseModelConfigurationResponse): + recipient: StrictStr = Field( + default=..., + description="Sender number. Required if Automatic Default Originator not configured.", + ) + body: StrictStr = Field(...) + number_of_parts: StrictInt = Field(...) + encoding: EncodingType = Field(...) diff --git a/sinch/domains/sms/models/v1/shared/media_body.py b/sinch/domains/sms/models/v1/shared/media_body.py new file mode 100644 index 00000000..20f30d98 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/media_body.py @@ -0,0 +1,16 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class MediaBody(BaseModelConfigurationResponse): + subject: Optional[StrictStr] = Field( + default=None, description="The subject text" + ) + message: Optional[StrictStr] = Field( + default=None, + description="The message text. Text only media messages will be rejected, please use SMS instead.", + ) + url: StrictStr = Field(default=..., description="URL to the media file") diff --git a/sinch/domains/sms/models/v1/shared/media_request.py b/sinch/domains/sms/models/v1/shared/media_request.py new file mode 100644 index 00000000..57d5311a --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/media_request.py @@ -0,0 +1,54 @@ +from typing import Dict, Optional +from datetime import datetime +from pydantic import Field, StrictBool, StrictStr, conlist +from sinch.domains.sms.models.v1.types import ( + DeliveryReportType, +) +from sinch.domains.sms.models.v1.shared import MediaBody +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class MediaRequest(BaseModelConfigurationRequest): + to: conlist(StrictStr) = Field( + default=..., + description="List of Phone numbers and group IDs that will receive the batch. [More info](https://community.sinch.com/t5/Glossary/MSISDN/ta-p/7628)", + ) + from_: Optional[StrictStr] = Field( + default=None, + alias="from", + description="Sender number. Must be valid phone number, short code or alphanumeric. Required if Automatic Default Originator not configured.", + ) + body: MediaBody = Field(...) + parameters: Optional[Dict[StrictStr, Dict[StrictStr, StrictStr]]] = Field( + default=None, + description="Contains the parameters that will be used for customizing the message for each recipient. [Click here to learn more about parameterization](/docs/sms/resources/message-info/message-parameterization).", + ) + type: Optional[StrictStr] = Field(default="mt_media", description="MMS") + delivery_report: Optional[DeliveryReportType] = None + send_at: Optional[datetime] = Field( + default=None, + description="If set in the future, the message will be delayed until `send_at` occurs. Must be before `expire_at`. If set in the past, messages will be sent immediately. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`. ", + ) + expire_at: Optional[datetime] = Field( + default=None, + description="If set, the system will stop trying to deliver the message at this point. Must be after `send_at`. Default and max is 3 days after `send_at`. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`. ", + ) + event_destination_target: Optional[StrictStr] = Field( + default=None, + alias="callback_url", + description="Override the default callback URL for this batch. Must be valid URL.", + ) + client_reference: Optional[StrictStr] = Field( + default=None, + description="The client identifier of a batch message. If set, the identifier will be added in the delivery report/callback of this batch", + ) + feedback_enabled: Optional[StrictBool] = Field( + default=None, + description="If set to `true`, then [feedback](/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/deliveryFeedback) is expected after successful delivery.", + ) + strict_validation: Optional[StrictBool] = Field( + default=None, + description="Whether or not you want the media included in your message to be checked against [Sinch MMS channel best practices](/docs/mms/bestpractices/). If set to true, your message will be rejected if it doesn't conform to the listed recommendations, otherwise no validation will be performed. ", + ) diff --git a/sinch/domains/sms/models/v1/shared/media_response.py b/sinch/domains/sms/models/v1/shared/media_response.py new file mode 100644 index 00000000..5971208a --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/media_response.py @@ -0,0 +1,69 @@ +from typing import Dict, Literal, Optional +from datetime import datetime +from pydantic import Field, StrictBool, StrictStr, conlist +from sinch.domains.sms.models.v1.types.delivery_report_type import ( + DeliveryReportType, +) +from sinch.domains.sms.models.v1.shared.media_body import MediaBody +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class MediaResponse(BaseModelConfigurationResponse): + id: Optional[StrictStr] = Field( + default=None, description="Unique identifier for batch" + ) + to: Optional[conlist(StrictStr)] = Field( + default=None, + description="List of Phone numbers and group IDs that will receive the batch. [More info](https://community.sinch.com/t5/Glossary/MSISDN/ta-p/7628)", + ) + from_: Optional[StrictStr] = Field( + default=None, + alias="from", + description="Sender number. Required if Automatic Default Originator not configured.", + ) + canceled: Optional[StrictBool] = Field( + default=None, + description="Indicates if the batch has been canceled or not.", + ) + body: Optional[MediaBody] = None + parameters: Optional[Dict[StrictStr, Dict[StrictStr, StrictStr]]] = Field( + default=None, + description="Contains the parameters that will be used for customizing the message for each recipient. [Click here to learn more about parameterization](/docs/sms/resources/message-info/message-parameterization).", + ) + type: Literal["mt_media"] = Field(default=..., description="Media message") + created_at: Optional[datetime] = Field( + default=None, + description="Timestamp for when batch was created. YYYY-MM-DDThh:mm:ss.SSSZ format", + ) + modified_at: Optional[datetime] = Field( + default=None, + description="Timestamp for when batch was last updated. YYYY-MM-DDThh:mm:ss.SSSZ format", + ) + delivery_report: Optional[DeliveryReportType] = None + send_at: Optional[datetime] = Field( + default=None, + description="If set in the future the message will be delayed until send_at occurs. Must be before `expire_at`. If set in the past messages will be sent immediately. YYYY-MM-DDThh:mm:ss.SSSZ format", + ) + expire_at: Optional[datetime] = Field( + default=None, + description="If set the system will stop trying to deliver the message at this point. Must be after `send_at`. Default and max is 3 days after send_at. YYYY-MM-DDThh:mm:ss.SSSZ format", + ) + event_destination_target: Optional[StrictStr] = Field( + default=None, + alias="callback_url", + description="Override the default callback URL for this batch. Must be valid URL.", + ) + client_reference: Optional[StrictStr] = Field( + default=None, + description="The client identifier of a batch message. If set, the identifier will be added in the delivery report/callback of this batch", + ) + feedback_enabled: Optional[StrictBool] = Field( + default=None, + description="If set to true then [feedback](/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/deliveryFeedback) is expected after successful delivery.", + ) + strict_validation: Optional[StrictBool] = Field( + default=None, + description="Whether or not you want the media included in your message to be checked against [Sinch MMS channel best practices](/docs/mms/bestpractices/). If set to true, your message will be rejected if it doesn't conform to the listed recommendations, otherwise no validation will be performed. ", + ) diff --git a/sinch/domains/sms/models/v1/shared/message_delivery_status.py b/sinch/domains/sms/models/v1/shared/message_delivery_status.py new file mode 100644 index 00000000..27057a41 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/message_delivery_status.py @@ -0,0 +1,25 @@ +from typing import Optional +from pydantic import Field, StrictInt, StrictStr, conlist +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.sms.models.v1.types import ( + DeliveryReceiptStatusCodeType, + DeliveryStatusType, +) + + +class MessageDeliveryStatus(BaseModelConfigurationResponse): + code: DeliveryReceiptStatusCodeType = Field( + default=..., + description="The detailed [status code](/docs/sms/api-reference/sms/tag/Delivery-reports/#tag/Delivery-reports/section/Delivery-report-error-codes).", + ) + count: StrictInt = Field( + default=..., + description="The number of messages that currently has this code.", + ) + recipients: Optional[conlist(StrictStr)] = Field( + default=None, + description="Only for `full` report. A list of the phone number recipients which messages has this status code.", + ) + status: DeliveryStatusType = Field(..., description="The delivery status.") diff --git a/sinch/domains/sms/models/v1/shared/text_request.py b/sinch/domains/sms/models/v1/shared/text_request.py new file mode 100644 index 00000000..e5ecce0f --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/text_request.py @@ -0,0 +1,77 @@ +from typing import Dict, Optional +from datetime import datetime +from pydantic import ( + Field, + StrictBool, + StrictStr, + conlist, + StrictInt, +) +from sinch.domains.sms.models.v1.types.delivery_report_type import ( + DeliveryReportType, +) +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class TextRequest(BaseModelConfigurationRequest): + to: conlist(StrictStr) = Field( + default=..., + description="List of Phone numbers and group IDs that will receive the batch. [More info](https://community.sinch.com/t5/Glossary/MSISDN/ta-p/7628)", + ) + from_: Optional[StrictStr] = Field( + default=None, + alias="from", + description="Sender number. Must be valid phone number, short code or alphanumeric. Required if Automatic Default Originator not configured.", + ) + parameters: Optional[Dict[StrictStr, Dict[StrictStr, StrictStr]]] = Field( + default=None, + description="Contains the parameters that will be used for customizing the message for each recipient. [Click here to learn more about parameterization](/docs/sms/resources/message-info/message-parameterization).", + ) + body: StrictStr = Field(default=..., description="The message content") + type: Optional[StrictStr] = Field( + default="mt_text", description="Regular SMS" + ) + delivery_report: Optional[DeliveryReportType] = None + send_at: Optional[datetime] = Field( + default=None, + description="If set in the future, the message will be delayed until `send_at` occurs. Must be before `expire_at`. If set in the past, messages will be sent immediately. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + expire_at: Optional[datetime] = Field( + default=None, + description="If set, the system will stop trying to deliver the message at this point. Must be after `send_at`. Default and max is 3 days after `send_at`. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + event_destination_target: Optional[StrictStr] = Field( + default=None, + alias="callback_url", + description="Override the *default* callback URL for this batch. Must be a valid URL. Learn how to set a default callback URL [here](https://community.sinch.com/t5/SMS/How-do-I-assign-a-callback-URL-to-an-SMS-service-plan/ta-p/8414).", + ) + client_reference: Optional[StrictStr] = Field( + default=None, + description="The client identifier of a batch message. If set, the identifier will be added in the delivery report/callback of this batch", + ) + feedback_enabled: Optional[StrictBool] = Field( + default=None, + description="If set to `true`, then [feedback](/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/deliveryFeedback) is expected after successful delivery.", + ) + flash_message: Optional[StrictBool] = Field( + default=None, + description="Shows message on screen without user interaction while not saving the message to the inbox.", + ) + max_number_of_message_parts: Optional[StrictInt] = Field( + default=None, + description="Message will be dispatched only if it is not split to more parts than Max Number of Message Parts", + ) + truncate_concat: Optional[StrictBool] = Field( + default=None, + description="If set to `true` the message will be shortened when exceeding one part.", + ) + from_ton: Optional[StrictInt] = Field( + default=None, + description="The type of number for the sender number. Use to override the automatic detection.", + ) + from_npi: Optional[StrictInt] = Field( + default=None, + description="Number Plan Indicator for the sender number. Use to override the automatic detection.", + ) diff --git a/sinch/domains/sms/models/v1/shared/text_response.py b/sinch/domains/sms/models/v1/shared/text_response.py new file mode 100644 index 00000000..f5fc5975 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/text_response.py @@ -0,0 +1,86 @@ +from typing import Dict, Optional, Literal +from datetime import datetime +from pydantic import Field, StrictBool, StrictStr, conlist, StrictInt +from sinch.domains.sms.models.v1.types.delivery_report_type import ( + DeliveryReportType, +) +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class TextResponse(BaseModelConfigurationResponse): + id: Optional[StrictStr] = Field( + default=None, description="Unique identifier for batch" + ) + to: Optional[conlist(StrictStr)] = Field( + default=None, + description="List of Phone numbers and group IDs that will receive the batch. [More info](https://community.sinch.com/t5/Glossary/MSISDN/ta-p/7628)", + ) + from_: Optional[StrictStr] = Field( + default=None, + alias="from", + description="Sender number. Must be valid phone number, short code or alphanumeric. Required if Automatic Default Originator not configured.", + ) + canceled: Optional[StrictBool] = Field( + default=None, + description="Indicates if the batch has been canceled or not.", + ) + parameters: Optional[Dict[StrictStr, Dict[StrictStr, StrictStr]]] = Field( + default=None, + description="Contains the parameters that will be used for customizing the message for each recipient. [Click here to learn more about parameterization](/docs/sms/resources/message-info/message-parameterization).", + ) + body: Optional[StrictStr] = Field( + default=None, description="The message content" + ) + type: Literal["mt_text"] = Field(default=..., description="Regular SMS") + created_at: Optional[datetime] = Field( + default=None, + description="Timestamp for when batch was created. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601):`YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + modified_at: Optional[datetime] = Field( + default=None, + description="Timestamp for when batch was last updated. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601):`YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + delivery_report: Optional[DeliveryReportType] = None + send_at: Optional[datetime] = Field( + default=None, + description="If set in the future, the message will be delayed until `send_at` occurs. Must be before `expire_at`. If set in the past, messages will be sent immediately. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + expire_at: Optional[datetime] = Field( + default=None, + description="If set, the system will stop trying to deliver the message at this point. Must be after `send_at`. Default and max is 3 days after `send_at`. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + event_destination_target: Optional[StrictStr] = Field( + default=None, + alias="callback_url", + description="Override the default callback URL for this batch. Must be valid URL.", + ) + client_reference: Optional[StrictStr] = Field( + default=None, + description="The client identifier of a batch message. If set, the identifier will be added in the delivery report/callback of this batch", + ) + feedback_enabled: Optional[StrictBool] = Field( + default=None, + description="If set to `true`, then [feedback](/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/deliveryFeedback) is expected after successful delivery.", + ) + flash_message: Optional[StrictBool] = Field( + default=None, + description="Shows message on screen without user interaction while not saving the message to the inbox.", + ) + truncate_concat: Optional[StrictBool] = Field( + default=None, + description="If set to `true` the message will be shortened when exceeding one part.", + ) + max_number_of_message_parts: Optional[StrictInt] = Field( + default=None, + description="Message will be dispatched only if it is not split to more parts than Max Number of Message Parts", + ) + from_ton: Optional[StrictInt] = Field( + default=None, + description="The type of number for the sender number. Use to override the automatic detection.", + ) + from_npi: Optional[StrictInt] = Field( + default=None, + description="Number Plan Indicator for the sender number. Use to override the automatic detection.", + ) diff --git a/sinch/domains/sms/models/v1/types/__init__.py b/sinch/domains/sms/models/v1/types/__init__.py new file mode 100644 index 00000000..a52cfcc2 --- /dev/null +++ b/sinch/domains/sms/models/v1/types/__init__.py @@ -0,0 +1,36 @@ +from sinch.domains.sms.models.v1.types.delivery_receipt_status_code_type import ( + DeliveryReceiptStatusCodeType, +) +from sinch.domains.sms.models.v1.types.delivery_report_type import ( + DeliveryReportType, +) +from sinch.domains.sms.models.v1.types.delivery_status_type import ( + DeliveryStatusType, +) +from sinch.domains.sms.models.v1.types.encoding_type import EncodingType +from sinch.domains.sms.models.v1.types.media_body_dict import MediaBodyDict +from sinch.domains.sms.models.v1.types.recipient_delivery_report_type import ( + RecipientDeliveryReportType, +) + +__all__ = [ + "BatchResponse", + "DeliveryReceiptStatusCodeType", + "DeliveryReportType", + "DeliveryStatusType", + "EncodingType", + "MediaBodyDict", + "RecipientDeliveryReportType", +] + + +# Lazy import to avoid circular dependency +# BatchResponse imports from shared which may import from types +def __getattr__(name: str): + if name == "BatchResponse": + from sinch.domains.sms.models.v1.types.batch_response import ( + BatchResponse, + ) + + return BatchResponse + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/sms/models/v1/types/batch_response.py b/sinch/domains/sms/models/v1/types/batch_response.py new file mode 100644 index 00000000..6f63efd0 --- /dev/null +++ b/sinch/domains/sms/models/v1/types/batch_response.py @@ -0,0 +1,11 @@ +from typing import Annotated, Union +from pydantic import Field +from sinch.domains.sms.models.v1.shared.text_response import TextResponse +from sinch.domains.sms.models.v1.shared.binary_response import BinaryResponse +from sinch.domains.sms.models.v1.shared.media_response import MediaResponse + +# Union type for isinstance checks +_BatchResponseUnion = Union[TextResponse, BinaryResponse, MediaResponse] + +# Discriminated union for validation +BatchResponse = Annotated[_BatchResponseUnion, Field(discriminator="type")] diff --git a/sinch/domains/sms/models/v1/types/delivery_receipt_status_code_type.py b/sinch/domains/sms/models/v1/types/delivery_receipt_status_code_type.py new file mode 100644 index 00000000..fcff3ed0 --- /dev/null +++ b/sinch/domains/sms/models/v1/types/delivery_receipt_status_code_type.py @@ -0,0 +1,27 @@ +from typing import Literal, Union +from pydantic import StrictInt + + +DeliveryReceiptStatusCodeType = Union[ + Literal[ + 400, + 401, + 402, + 403, + 404, + 405, + 406, + 407, + 408, + 410, + 411, + 412, + 413, + 414, + 415, + 416, + 417, + 418, + ], + StrictInt, +] diff --git a/sinch/domains/sms/models/v1/types/delivery_report_type.py b/sinch/domains/sms/models/v1/types/delivery_report_type.py new file mode 100644 index 00000000..9933b0a3 --- /dev/null +++ b/sinch/domains/sms/models/v1/types/delivery_report_type.py @@ -0,0 +1,8 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +DeliveryReportType = Union[ + Literal["none", "summary", "full", "per_recipient", "per_recipient_final"], + StrictStr, +] diff --git a/sinch/domains/sms/models/v1/types/delivery_status_type.py b/sinch/domains/sms/models/v1/types/delivery_status_type.py new file mode 100644 index 00000000..6fd26822 --- /dev/null +++ b/sinch/domains/sms/models/v1/types/delivery_status_type.py @@ -0,0 +1,19 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +DeliveryStatusType = Union[ + Literal[ + "QUEUED", + "DISPATCHED", + "ABORTED", + "CANCELLED", + "FAILED", + "DELIVERED", + "EXPIRED", + "REJECTED", + "DELETED", + "UNKNOWN", + ], + StrictStr, +] diff --git a/sinch/domains/sms/models/v1/types/encoding_type.py b/sinch/domains/sms/models/v1/types/encoding_type.py new file mode 100644 index 00000000..eeb752cb --- /dev/null +++ b/sinch/domains/sms/models/v1/types/encoding_type.py @@ -0,0 +1,5 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +EncodingType = Union[Literal["GSM", "UNICODE"], StrictStr] diff --git a/sinch/domains/sms/models/v1/types/media_body_dict.py b/sinch/domains/sms/models/v1/types/media_body_dict.py new file mode 100644 index 00000000..5a3f9b8c --- /dev/null +++ b/sinch/domains/sms/models/v1/types/media_body_dict.py @@ -0,0 +1,8 @@ +from typing import TypedDict +from typing_extensions import NotRequired + + +class MediaBodyDict(TypedDict): + url: str + subject: NotRequired[str] + message: NotRequired[str] diff --git a/sinch/domains/sms/models/v1/types/recipient_delivery_report_type.py b/sinch/domains/sms/models/v1/types/recipient_delivery_report_type.py new file mode 100644 index 00000000..7952aee9 --- /dev/null +++ b/sinch/domains/sms/models/v1/types/recipient_delivery_report_type.py @@ -0,0 +1,8 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +RecipientDeliveryReportType = Union[ + Literal["recipient_delivery_report_sms", "recipient_delivery_report_mms"], + StrictStr, +] diff --git a/sinch/domains/sms/sinch_events/v1/__init__.py b/sinch/domains/sms/sinch_events/v1/__init__.py new file mode 100644 index 00000000..522d374f --- /dev/null +++ b/sinch/domains/sms/sinch_events/v1/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.sms.sinch_events.v1.sms_sinch_event import ( + SmsSinchEvent, +) + +__all__ = ["SmsSinchEvent"] diff --git a/sinch/domains/sms/sinch_events/v1/events/__init__.py b/sinch/domains/sms/sinch_events/v1/events/__init__.py new file mode 100644 index 00000000..00aba842 --- /dev/null +++ b/sinch/domains/sms/sinch_events/v1/events/__init__.py @@ -0,0 +1,17 @@ +from sinch.domains.sms.sinch_events.v1.events.sms_sinch_event import ( + IncomingSMSSinchEvent, + MOTextSinchEvent, + MOBinarySinchEvent, + MOMediaSinchEvent, + MediaBody, + MediaItem, +) + +__all__ = [ + "IncomingSMSSinchEvent", + "MOTextSinchEvent", + "MOBinarySinchEvent", + "MOMediaSinchEvent", + "MediaBody", + "MediaItem", +] diff --git a/sinch/domains/sms/sinch_events/v1/events/sms_sinch_event.py b/sinch/domains/sms/sinch_events/v1/events/sms_sinch_event.py new file mode 100644 index 00000000..fc87e608 --- /dev/null +++ b/sinch/domains/sms/sinch_events/v1/events/sms_sinch_event.py @@ -0,0 +1,97 @@ +from datetime import datetime +from typing import Optional, Union, Literal, Annotated +from pydantic import Field, StrictStr, StrictInt, conlist +from sinch.domains.sms.sinch_events.v1.internal import SinchEvent + + +class MediaItem(SinchEvent): + url: StrictStr = Field(..., description="URL to the media file") + content_type: StrictStr = Field( + ..., description="Content type of the media file" + ) + status: Union[Literal["Uploaded", "Failed"], StrictStr] = Field( + ..., description="Status of the media upload" + ) + code: StrictInt = Field(..., description="Status code") + + +class MediaBody(SinchEvent): + subject: Optional[StrictStr] = Field( + default=None, description="The subject text" + ) + message: Optional[StrictStr] = Field( + default=None, description="The message text" + ) + media: conlist(MediaItem) = Field(..., description="Array of media items") + + +class BaseIncomingSMSSinchEvent(SinchEvent): + from_: StrictStr = Field( + ..., + alias="from", + description="The phone number that sent the message.", + ) + id: StrictStr = Field(..., description="The ID of this inbound message.") + received_at: datetime = Field( + ..., + description="When the system received the message. Formatted as ISO-8601: YYYY-MM-DDThh:mm:ss.SSSZ.", + ) + to: StrictStr = Field( + ..., + description="The Sinch phone number or short code to which the message was sent.", + ) + client_reference: Optional[StrictStr] = Field( + default=None, + description="If this inbound message is in response to a previously sent message that contained a client reference, then this field contains that client reference. Utilizing this feature requires additional setup on your account.", + ) + operator_id: Optional[StrictStr] = Field( + default=None, + description="The MCC/MNC of the sender's operator if known.", + ) + sent_at: Optional[datetime] = Field( + default=None, + description="When the message left the originating device. Only available if provided by operator. Formatted as ISO-8601: YYYY-MM-DDThh:mm:ss.SSSZ.", + ) + + +class MOTextSinchEvent(BaseIncomingSMSSinchEvent): + body: StrictStr = Field( + ..., + description="The incoming message body. Maximum 2000 characters.", + ) + type: Literal["mo_text"] = Field( + ..., description="The type of incoming message. Regular SMS." + ) + + +class MOBinarySinchEvent(BaseIncomingSMSSinchEvent): + body: StrictStr = Field( + ..., description="The incoming message body (Base64 encoded)." + ) + type: Literal["mo_binary"] = Field( + ..., description="The type of incoming message. Binary SMS." + ) + udh: StrictStr = Field( + ..., description="The UDH header of a binary message HEX encoded." + ) + + +class MOMediaSinchEvent(BaseIncomingSMSSinchEvent): + body: MediaBody = Field( + ..., + description="The media message body containing subject, message, and media items.", + ) + type: Literal["mo_media"] = Field( + ..., description="The type of incoming message. MMS." + ) + + +# Union type for isinstance checks +_IncomingSMSSinchEventUnion = Union[ + MOTextSinchEvent, MOBinarySinchEvent, MOMediaSinchEvent +] + +# Discriminated union for validation +IncomingSMSSinchEvent = Annotated[ + _IncomingSMSSinchEventUnion, Field(discriminator="type") +] diff --git a/sinch/domains/sms/sinch_events/v1/internal/__init__.py b/sinch/domains/sms/sinch_events/v1/internal/__init__.py new file mode 100644 index 00000000..43b3a8dd --- /dev/null +++ b/sinch/domains/sms/sinch_events/v1/internal/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.sms.sinch_events.v1.internal.sinch_event import ( + SinchEvent, +) + +__all__ = ["SinchEvent"] diff --git a/sinch/domains/sms/sinch_events/v1/internal/sinch_event.py b/sinch/domains/sms/sinch_events/v1/internal/sinch_event.py new file mode 100644 index 00000000..184012f9 --- /dev/null +++ b/sinch/domains/sms/sinch_events/v1/internal/sinch_event.py @@ -0,0 +1,7 @@ +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class SinchEvent(BaseModelConfigurationResponse): + pass diff --git a/sinch/domains/sms/sinch_events/v1/sms_sinch_event.py b/sinch/domains/sms/sinch_events/v1/sms_sinch_event.py new file mode 100644 index 00000000..03f52892 --- /dev/null +++ b/sinch/domains/sms/sinch_events/v1/sms_sinch_event.py @@ -0,0 +1,120 @@ +import json +from typing import Any, Dict, Union, Optional +from pydantic import TypeAdapter +from sinch.domains.authentication.sinch_events.v1.authentication_validation import ( + validate_sinch_event_signature_with_nonce, +) +from sinch.domains.authentication.sinch_events.v1.sinch_event_utils import ( + decode_payload, + parse_json, + normalize_iso_timestamp, +) +from sinch.domains.sms.sinch_events.v1.events import ( + IncomingSMSSinchEvent, + MOTextSinchEvent, + MOBinarySinchEvent, + MOMediaSinchEvent, +) +from sinch.domains.sms.models.v1.response import ( + BatchDeliveryReport, + RecipientDeliveryReport, +) + + +SmsSinchEventPayload = Union[ + BatchDeliveryReport, + RecipientDeliveryReport, + MOTextSinchEvent, + MOBinarySinchEvent, + MOMediaSinchEvent, +] + + +class SmsSinchEvent: + def __init__(self, app_secret: Optional[str] = None): + self.app_secret = app_secret + + def validate_authentication_header( + self, + headers: Dict[str, str], + json_payload: Union[str, bytes], + ) -> bool: + """ + Validate the authorization header for a callback request. + + :param headers: Incoming request's headers + :type headers: Dict[str, str] + :param json_payload: Incoming request's raw body (str or bytes) + :type json_payload: Union[str, bytes] + :returns: True if the X-Sinch-Webhook-Signature header is valid + :rtype: bool + """ + if not self.app_secret: + return False + payload_str = ( + decode_payload(json_payload, headers) + if isinstance(json_payload, bytes) + else json_payload + ) + return validate_sinch_event_signature_with_nonce( + self.app_secret, headers, payload_str + ) + + def parse_event( + self, + event_body: Union[str, bytes, Dict[str, Any]], + headers: Optional[Dict[str, str]] = None, + ) -> SmsSinchEventPayload: + """ + Parse the event payload into an SMS callback object. + + Handles datetime conversion for timestamp fields and routes to the + appropriate event type based on the `type` field. + + :param event_body: The event payload (JSON string, raw bytes, or dict). + :type event_body: Union[str, bytes, Dict[str, Any]] + :param headers: Request headers (used to decode charset when event_body is bytes). + :type headers: Optional[Dict[str, str]] + :returns: A parsed SMS Sinch Event payload object. + :rtype: SmsSinchEventPayload + :raises ValueError: If the event type is unknown or parsing fails. + """ + if isinstance(event_body, bytes): + event_body = parse_json(decode_payload(event_body, headers)) + elif isinstance(event_body, str): + event_body = parse_json(event_body) + + event_type = event_body.get("type") + if not event_type: + raise ValueError(f"Unknown SMS event: {json.dumps(event_body)}") + + # Handle delivery reports + if event_type in ("delivery_report_sms", "delivery_report_mms"): + return BatchDeliveryReport(**event_body) + + # Handle recipient delivery reports + if event_type in ( + "recipient_delivery_report_sms", + "recipient_delivery_report_mms", + ): + return RecipientDeliveryReport(**event_body) + + # Handle incoming SMS messages using discriminated union + if event_type in ("mo_text", "mo_binary", "mo_media"): + if "received_at" in event_body and isinstance( + event_body["received_at"], str + ): + event_body["received_at"] = normalize_iso_timestamp( + event_body["received_at"] + ) + if "sent_at" in event_body and isinstance( + event_body["sent_at"], str + ): + event_body["sent_at"] = normalize_iso_timestamp( + event_body["sent_at"] + ) + + adapter = TypeAdapter(IncomingSMSSinchEvent) + return adapter.validate_python(event_body) + + raise ValueError(f"Unknown SMS event type: {event_type}") diff --git a/sinch/domains/sms/sms.py b/sinch/domains/sms/sms.py new file mode 100644 index 00000000..c312c2de --- /dev/null +++ b/sinch/domains/sms/sms.py @@ -0,0 +1,29 @@ +from sinch.domains.sms.api.v1 import ( + Batches, + DeliveryReports, +) +from sinch.domains.sms.sinch_events.v1.sms_sinch_event import SmsSinchEvent + + +class SMS: + """ + Documentation for Sinch SMS is found at + https://developers.sinch.com/docs/sms/. + """ + + def __init__(self, sinch): + self._sinch = sinch + + self.batches = Batches(self._sinch) + self.delivery_reports = DeliveryReports(self._sinch) + + def sinch_events(self, sinch_event_secret: str) -> SmsSinchEvent: + """ + Create an SMS Sinch Events handler with the specified Sinch Event secret. + + :param sinch_event_secret: Secret used for Sinch Event validation. + :type sinch_event_secret: str + :returns: A configured Sinch Events handler + :rtype: SmsSinchEvent + """ + return SmsSinchEvent(sinch_event_secret) diff --git a/sinch/domains/verification/__init__.py b/sinch/domains/verification/__init__.py deleted file mode 100644 index a8231116..00000000 --- a/sinch/domains/verification/__init__.py +++ /dev/null @@ -1,357 +0,0 @@ -from sinch.domains.verification.endpoints.start_verification import StartVerificationEndpoint -from sinch.domains.verification.endpoints.report_verification_using_identity import ( - ReportVerificationByIdentityEndpoint -) -from sinch.domains.verification.endpoints.report_verification_using_id import ( - ReportVerificationByIdEndpoint -) -from sinch.domains.verification.endpoints.get_verification_by_identity import ( - GetVerificationStatusByIdentityEndpoint -) -from sinch.domains.verification.endpoints.get_verification_by_reference import ( - GetVerificationStatusByReferenceEndpoint -) -from sinch.domains.verification.endpoints.get_verification_by_id import ( - GetVerificationStatusByIdEndpoint -) -from sinch.domains.verification.models.responses import ( - StartVerificationResponse, - ReportVerificationByIdentityResponse, - ReportVerificationByIdResponse, - GetVerificationStatusByIdentityResponse, - GetVerificationStatusByReferenceResponse, - GetVerificationStatusByIdResponse -) -from sinch.domains.verification.models.requests import ( - StartSMSVerificationRequest, - StartFlashCallVerificationRequest, - StartPhoneCallVerificationRequest, - StartCalloutVerificationRequest, - StartDataVerificationRequest, - ReportVerificationByIdentityRequestLegacy, - ReportVerificationByIdRequestLegacy, - ReportVerificationByIdentityAndSMSRequest, - ReportVerificationByIdentityAndFlashCallRequest, - ReportVerificationByIdentityAndPhoneCallRequest, - ReportVerificationByIdAndSMSRequest, - ReportVerificationByIdAndFlashCallRequest, - ReportVerificationByIdAndPhoneCallRequest, - GetVerificationStatusByIdentityRequest, - GetVerificationStatusByReferenceRequest, - GetVerificationStatusByIdRequest -) -from sinch.domains.verification.models import VerificationIdentity - - -class Verifications: - def __init__(self, sinch): - self._sinch = sinch - - def start_sms( - self, - identity: VerificationIdentity, - reference: str = None, - custom: str = None, - expiry: str = None, - code_type: str = None, - template: str = None - ) -> StartVerificationResponse: - return self._sinch.configuration.transport.request( - StartVerificationEndpoint( - request_data=StartSMSVerificationRequest( - identity=identity, - reference=reference, - custom=custom, - expiry=expiry, - code_type=code_type, - template=template - ) - ) - ) - - def start_flash_call( - self, - identity: VerificationIdentity, - reference: str = None, - dial_timeout: int = None, - custom: str = None - ) -> StartVerificationResponse: - return self._sinch.configuration.transport.request( - StartVerificationEndpoint( - request_data=StartFlashCallVerificationRequest( - identity=identity, - reference=reference, - dial_timeout=dial_timeout, - custom=custom - ) - ) - ) - - def start_phone_call( - self, - identity: VerificationIdentity, - reference: str = None, - custom: str = None - ) -> StartVerificationResponse: - return self._sinch.configuration.transport.request( - StartVerificationEndpoint( - request_data=StartPhoneCallVerificationRequest( - identity=identity, - reference=reference, - custom=custom - ) - ) - ) - - def start_callout( - self, - identity: VerificationIdentity, - reference: str = None, - custom: str = None, - speech_locale: str = None - ) -> StartVerificationResponse: - """ - This method is not supported anymore. - It should be used only for backward compatibility reasons. - Use start_phone_call method instead. - """ - return self._sinch.configuration.transport.request( - StartVerificationEndpoint( - request_data=StartCalloutVerificationRequest( - identity=identity, - reference=reference, - custom=custom, - speech_locale=speech_locale - ) - ) - ) - - def start_seamless( - self, - identity: VerificationIdentity, - reference: str = None, - custom: str = None - ) -> StartVerificationResponse: - """ - This method is not supported anymore. - It should be used only for backward compatibility reasons. - Use start_data method instead. - """ - return self._sinch.configuration.transport.request( - StartVerificationEndpoint( - request_data=StartDataVerificationRequest( - identity=identity, - reference=reference, - custom=custom - ) - ) - ) - - def start_data( - self, - identity: VerificationIdentity, - reference: str = None, - custom: str = None - ) -> StartVerificationResponse: - return self._sinch.configuration.transport.request( - StartVerificationEndpoint( - request_data=StartDataVerificationRequest( - identity=identity, - reference=reference, - custom=custom - ) - ) - ) - - def report_sms_by_id( - self, - id: str, - code: str, - cli: str = None - ) -> ReportVerificationByIdResponse: - return self._sinch.configuration.transport.request( - ReportVerificationByIdEndpoint( - request_data=ReportVerificationByIdAndSMSRequest( - id, - code, - cli - ) - ) - ) - - def report_flash_call_by_id( - self, - id: str, - cli: str - ) -> ReportVerificationByIdResponse: - return self._sinch.configuration.transport.request( - ReportVerificationByIdEndpoint( - request_data=ReportVerificationByIdAndFlashCallRequest( - id, - cli - ) - ) - ) - - def report_phone_call_by_id( - self, - id: str, - code: str = None - ) -> ReportVerificationByIdResponse: - return self._sinch.configuration.transport.request( - ReportVerificationByIdEndpoint( - request_data=ReportVerificationByIdAndPhoneCallRequest( - id, - code - ) - ) - ) - - def report_sms_by_identity( - self, - endpoint: str, - code: str, - cli: str = None - ) -> ReportVerificationByIdentityResponse: - return self._sinch.configuration.transport.request( - ReportVerificationByIdentityEndpoint( - request_data=ReportVerificationByIdentityAndSMSRequest( - endpoint, - code, - cli - ) - ) - ) - - def report_flash_call_by_identity( - self, - endpoint: str, - cli: str = None - ) -> ReportVerificationByIdentityResponse: - return self._sinch.configuration.transport.request( - ReportVerificationByIdentityEndpoint( - request_data=ReportVerificationByIdentityAndFlashCallRequest( - endpoint, - cli - ) - ) - ) - - def report_phone_call_by_identity( - self, - endpoint: str, - code: str - ) -> ReportVerificationByIdentityResponse: - return self._sinch.configuration.transport.request( - ReportVerificationByIdentityEndpoint( - request_data=ReportVerificationByIdentityAndPhoneCallRequest( - endpoint, - code - ) - ) - ) - - def report_by_id( - self, - id: str, - verification_report_request: dict - ) -> ReportVerificationByIdResponse: - """ - This method is not supported anymore. - It should be used only for backward compatibility reasons. - """ - return self._sinch.configuration.transport.request( - ReportVerificationByIdEndpoint( - request_data=ReportVerificationByIdRequestLegacy( - id, - verification_report_request - ) - ) - ) - - def report_by_identity( - self, - endpoint, - verification_report_request - ) -> ReportVerificationByIdentityResponse: - """ - This method is not supported anymore. - It should be used only for backward compatibility reasons. - """ - return self._sinch.configuration.transport.request( - ReportVerificationByIdentityEndpoint( - request_data=ReportVerificationByIdentityRequestLegacy( - endpoint, - verification_report_request - ) - ) - ) - - -class VerificationStatus: - def __init__(self, sinch): - self._sinch = sinch - - def get_by_id(self, id: str) -> GetVerificationStatusByIdResponse: - return self._sinch.configuration.transport.request( - GetVerificationStatusByIdEndpoint( - request_data=GetVerificationStatusByIdRequest( - id=id - ) - ) - ) - - def get_by_reference(self, reference: str) -> GetVerificationStatusByReferenceResponse: - return self._sinch.configuration.transport.request( - GetVerificationStatusByReferenceEndpoint( - request_data=GetVerificationStatusByReferenceRequest( - reference=reference - ) - ) - ) - - def get_by_identity( - self, - endpoint: str, - method: str - ) -> GetVerificationStatusByIdentityResponse: - return self._sinch.configuration.transport.request( - GetVerificationStatusByIdentityEndpoint( - request_data=GetVerificationStatusByIdentityRequest( - endpoint=endpoint, - method=method - ) - ) - ) - - -class VerificationBase: - """ - Documentation for the Verification API: https://developers.sinch.com/docs/verification/ - """ - def __init__(self, sinch): - self._sinch = sinch - - -class Verification(VerificationBase): - """ - Synchronous version of the Verification Domain - """ - __doc__ += VerificationBase.__doc__ - - def __init__(self, sinch): - super(Verification, self).__init__(sinch) - self.verifications = Verifications(self._sinch) - self.verification_status = VerificationStatus(self._sinch) - - -class VerificationAsync(VerificationBase): - """ - Asynchronous version of the Verification Domain - """ - __doc__ += VerificationBase.__doc__ - - def __init__(self, sinch): - super(VerificationAsync, self).__init__(sinch) - self.verifications = Verifications(self._sinch) - self.verification_status = VerificationStatus(self._sinch) diff --git a/sinch/domains/verification/endpoints/__init__.py b/sinch/domains/verification/endpoints/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/verification/endpoints/get_verification_by_id.py b/sinch/domains/verification/endpoints/get_verification_by_id.py deleted file mode 100644 index 131e1e75..00000000 --- a/sinch/domains/verification/endpoints/get_verification_by_id.py +++ /dev/null @@ -1,35 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.verification.endpoints.verification_endpoint import VerificationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.verification.models.requests import GetVerificationStatusByIdRequest -from sinch.domains.verification.models.responses import GetVerificationStatusByIdResponse - - -class GetVerificationStatusByIdEndpoint(VerificationEndpoint): - ENDPOINT_URL = "{origin}/verification/v1/verifications/id/{id}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: GetVerificationStatusByIdRequest): - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.verification_origin, - id=self.request_data.id - ) - - def handle_response(self, response: HTTPResponse) -> GetVerificationStatusByIdResponse: - super().handle_response(response) - return GetVerificationStatusByIdResponse( - id=response.body.get("id"), - method=response.body.get("method"), - status=response.body.get("status"), - price=response.body.get("price"), - identity=response.body.get("identity"), - country_id=response.body.get("country_id"), - verification_timestamp=response.body.get("verification_timestamp"), - reference=response.body.get("reference"), - reason=response.body.get("reason"), - call_complete=response.body.get("call_complete") - ) diff --git a/sinch/domains/verification/endpoints/get_verification_by_identity.py b/sinch/domains/verification/endpoints/get_verification_by_identity.py deleted file mode 100644 index 88008df5..00000000 --- a/sinch/domains/verification/endpoints/get_verification_by_identity.py +++ /dev/null @@ -1,36 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.verification.endpoints.verification_endpoint import VerificationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.verification.models.requests import GetVerificationStatusByIdentityRequest -from sinch.domains.verification.models.responses import GetVerificationStatusByIdentityResponse - - -class GetVerificationStatusByIdentityEndpoint(VerificationEndpoint): - ENDPOINT_URL = "{origin}/verification/v1/verifications/{method}/number/{endpoint}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: GetVerificationStatusByIdentityRequest): - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.verification_origin, - method=self.request_data.method, - endpoint=self.request_data.endpoint - ) - - def handle_response(self, response: HTTPResponse) -> GetVerificationStatusByIdentityResponse: - super().handle_response(response) - return GetVerificationStatusByIdentityResponse( - id=response.body.get("id"), - method=response.body.get("method"), - status=response.body.get("status"), - price=response.body.get("price"), - identity=response.body.get("identity"), - country_id=response.body.get("country_id"), - verification_timestamp=response.body.get("verification_timestamp"), - reference=response.body.get("reference"), - reason=response.body.get("reason"), - call_complete=response.body.get("call_complete") - ) diff --git a/sinch/domains/verification/endpoints/get_verification_by_reference.py b/sinch/domains/verification/endpoints/get_verification_by_reference.py deleted file mode 100644 index 3a5115bb..00000000 --- a/sinch/domains/verification/endpoints/get_verification_by_reference.py +++ /dev/null @@ -1,35 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.verification.endpoints.verification_endpoint import VerificationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.verification.models.requests import GetVerificationStatusByReferenceRequest -from sinch.domains.verification.models.responses import GetVerificationStatusByReferenceResponse - - -class GetVerificationStatusByReferenceEndpoint(VerificationEndpoint): - ENDPOINT_URL = "{origin}/verification/v1/verifications/reference/{reference}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: GetVerificationStatusByReferenceRequest): - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.verification_origin, - reference=self.request_data.reference - ) - - def handle_response(self, response: HTTPResponse) -> GetVerificationStatusByReferenceResponse: - super().handle_response(response) - return GetVerificationStatusByReferenceResponse( - id=response.body.get("id"), - method=response.body.get("method"), - status=response.body.get("status"), - price=response.body.get("price"), - identity=response.body.get("identity"), - country_id=response.body.get("country_id"), - verification_timestamp=response.body.get("verification_timestamp"), - reference=response.body.get("reference"), - reason=response.body.get("reason"), - call_complete=response.body.get("call_complete") - ) diff --git a/sinch/domains/verification/endpoints/report_verification_using_id.py b/sinch/domains/verification/endpoints/report_verification_using_id.py deleted file mode 100644 index 5b441673..00000000 --- a/sinch/domains/verification/endpoints/report_verification_using_id.py +++ /dev/null @@ -1,38 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.verification.endpoints.verification_endpoint import VerificationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.verification.models.requests import ReportVerificationByIdRequest -from sinch.domains.verification.models.responses import ReportVerificationByIdResponse - - -class ReportVerificationByIdEndpoint(VerificationEndpoint): - ENDPOINT_URL = "{origin}/verification/v1/verifications/id/{id}" - HTTP_METHOD = HTTPMethods.PUT.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: ReportVerificationByIdRequest): - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.verification_origin, - id=self.request_data.id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> ReportVerificationByIdResponse: - super().handle_response(response) - return ReportVerificationByIdResponse( - id=response.body.get("id"), - method=response.body.get("method"), - status=response.body.get("status"), - price=response.body.get("price"), - identity=response.body.get("identity"), - country_id=response.body.get("country_id"), - verification_timestamp=response.body.get("verification_timestamp"), - reference=response.body.get("reference"), - reason=response.body.get("reason"), - call_complete=response.body.get("call_complete") - ) diff --git a/sinch/domains/verification/endpoints/report_verification_using_identity.py b/sinch/domains/verification/endpoints/report_verification_using_identity.py deleted file mode 100644 index 56d30507..00000000 --- a/sinch/domains/verification/endpoints/report_verification_using_identity.py +++ /dev/null @@ -1,38 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.verification.endpoints.verification_endpoint import VerificationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.verification.models.requests import ReportVerificationByIdentityRequest -from sinch.domains.verification.models.responses import ReportVerificationByIdentityResponse - - -class ReportVerificationByIdentityEndpoint(VerificationEndpoint): - ENDPOINT_URL = "{origin}/verification/v1/verifications/number/{endpoint}" - HTTP_METHOD = HTTPMethods.PUT.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: ReportVerificationByIdentityRequest): - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.verification_origin, - endpoint=self.request_data.endpoint - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> ReportVerificationByIdentityResponse: - super().handle_response(response) - return ReportVerificationByIdentityResponse( - id=response.body.get("id"), - method=response.body.get("method"), - status=response.body.get("status"), - price=response.body.get("price"), - identity=response.body.get("identity"), - country_id=response.body.get("country_id"), - verification_timestamp=response.body.get("verification_timestamp"), - reference=response.body.get("reference"), - reason=response.body.get("reason"), - call_complete=response.body.get("call_complete") - ) diff --git a/sinch/domains/verification/endpoints/start_verification.py b/sinch/domains/verification/endpoints/start_verification.py deleted file mode 100644 index 7a10bf7a..00000000 --- a/sinch/domains/verification/endpoints/start_verification.py +++ /dev/null @@ -1,75 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.verification.endpoints.verification_endpoint import VerificationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.verification.enums import VerificationMethod -from sinch.domains.verification.models.requests import StartVerificationRequest -from sinch.domains.verification.models.responses import ( - FlashCallResponse, - SMSResponse, - DataResponse, - StartVerificationResponse, - StartSMSVerificationResponse, - StartDataVerificationResponse, - StartPhoneCallVerificationResponse, - StartFlashCallVerificationResponse -) - - -class StartVerificationEndpoint(VerificationEndpoint): - ENDPOINT_URL = "{origin}/verification/v1/verifications" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: StartVerificationRequest): - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.verification_origin, - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> StartVerificationResponse: - super().handle_response(response) - if self.request_data.method == VerificationMethod.SMS.value: - sms_response = response.body.get("sms") - return StartSMSVerificationResponse( - id=response.body.get("id"), - method=response.body.get("method"), - _links=response.body.get("_links"), - sms=SMSResponse( - interception_timeout=response.body["sms"].get("interceptionTimeout"), - template=response.body["sms"].get("template") - ) if sms_response else None - ) - elif self.request_data.method == VerificationMethod.FLASH_CALL.value: - flash_call_response = response.body.get("flashCall") - return StartFlashCallVerificationResponse( - id=response.body.get("id"), - method=response.body.get("method"), - _links=response.body.get("_links"), - flash_call=FlashCallResponse( - cli_filter=response.body["flashCall"].get("cliFilter"), - interception_timeout=response.body["flashCall"].get("interceptionTimeout"), - report_timeout=response.body["flashCall"].get("reportTimeout"), - deny_call_after=response.body["flashCall"].get("denyCallAfter") - ) if flash_call_response else None - ) - elif self.request_data.method == VerificationMethod.CALLOUT.value: - return StartPhoneCallVerificationResponse( - id=response.body.get("id"), - method=response.body.get("method"), - _links=response.body.get("_links") - ) - elif self.request_data.method == VerificationMethod.SEAMLESS.value: - seamless_response = response.body.get("seamless") - return StartDataVerificationResponse( - id=response.body.get("id"), - method=response.body.get("method"), - _links=response.body.get("_links"), - seamless=DataResponse( - target_uri=response.body["seamless"].get("targetUri") - ) if seamless_response else None - ) diff --git a/sinch/domains/verification/endpoints/verification_endpoint.py b/sinch/domains/verification/endpoints/verification_endpoint.py deleted file mode 100644 index e7898b62..00000000 --- a/sinch/domains/verification/endpoints/verification_endpoint.py +++ /dev/null @@ -1,13 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.core.endpoint import HTTPEndpoint -from sinch.domains.verification.exceptions import VerificationException - - -class VerificationEndpoint(HTTPEndpoint): - def handle_response(self, response: HTTPResponse): - if response.status_code >= 400: - raise VerificationException( - message=response.body["message"], - response=response, - is_from_server=True - ) diff --git a/sinch/domains/verification/enums.py b/sinch/domains/verification/enums.py deleted file mode 100644 index 26e0212c..00000000 --- a/sinch/domains/verification/enums.py +++ /dev/null @@ -1,17 +0,0 @@ -from enum import Enum - - -class VerificationMethod(Enum): - SMS = "sms" - FLASH_CALL = "flashCall" - CALLOUT = "callout" - SEAMLESS = "seamless" - - -class VerificationStatus(Enum): - PENDING = "PENDING" - SUCCESSFUL = "SUCCESSFUL" - FAIL = "FAIL" - DENIED = "DENIED" - ABORTED = "ABORTED" - ERROR = "ERROR" diff --git a/sinch/domains/verification/models/__init__.py b/sinch/domains/verification/models/__init__.py deleted file mode 100644 index a8786da8..00000000 --- a/sinch/domains/verification/models/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from typing import TypedDict, Literal - - -class VerificationIdentity(TypedDict): - type: Literal["number"] - endpoint: str diff --git a/sinch/domains/verification/models/requests.py b/sinch/domains/verification/models/requests.py deleted file mode 100644 index 6539e768..00000000 --- a/sinch/domains/verification/models/requests.py +++ /dev/null @@ -1,212 +0,0 @@ -import json -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel -from sinch.domains.verification.enums import VerificationMethod -from sinch.domains.verification.models import VerificationIdentity - - -class ReportPhoneCallVerificationDataTransformationMixin: - def as_dict(self): - request_data = super().as_dict() - payload = {"method": request_data["method"], "callout": {}} - - if request_data.get("code"): - payload["callout"]["code"] = request_data["code"] - - return payload - - -class ReportFlashCallVerificationDataTransformationMixin: - def as_dict(self): - request_data = super().as_dict() - payload = {"method": request_data["method"], "flashCall": {}} - - if request_data.get("cli"): - payload["flashCall"]["cli"] = request_data["cli"] - - return payload - - -class ReportSMSVerificationDataTransformationMixin: - def as_dict(self): - request_data = super().as_dict() - payload = {"method": request_data["method"], "sms": {}} - - if request_data.get("code"): - payload["sms"]["code"] = request_data["code"] - - if request_data.get("cli"): - payload["sms"]["cli"] = request_data["cli"] - - return payload - - -@dataclass -class StartVerificationRequest(SinchRequestBaseModel): - identity: VerificationIdentity - reference: str - custom: str - - -@dataclass -class StartSMSVerificationRequest(StartVerificationRequest): - expiry: str - code_type: str - template: str - method: str = VerificationMethod.SMS.value - - def as_dict(self): - payload = super().as_dict() - payload["smsOptions"] = {} - - if payload.get("code_type"): - payload["smsOptions"].update({ - "codeType": payload.pop("code_type") - }) - elif payload.get("expiry"): - payload["smsOptions"].update({ - "expiry": payload.pop("expiry") - }) - elif payload.get("template"): - payload["smsOptions"].update({ - "template": payload.pop("template") - }) - return payload - - -@dataclass -class StartFlashCallVerificationRequest(StartVerificationRequest): - dial_timeout: int - method: str = VerificationMethod.FLASH_CALL.value - - def as_dict(self): - payload = super().as_dict() - if payload.get("dial_timeout"): - payload["flashCallOptions"] = { - "dialTimeout": payload.pop("dial_timeout") - } - return payload - - -@dataclass -class StartPhoneCallVerificationRequest(StartVerificationRequest): - method: str = VerificationMethod.CALLOUT.value - - -@dataclass -class StartCalloutVerificationRequest(StartVerificationRequest): - speech_locale: str - method: str = VerificationMethod.CALLOUT.value - - def as_dict(self): - payload = super().as_dict() - if payload.get("speech_locale"): - payload["calloutOptions"] = { - "speech": { - "locale": payload.pop("speech_locale") - } - } - return payload - - -@dataclass -class StartDataVerificationRequest(StartVerificationRequest): - method: str = VerificationMethod.SEAMLESS.value - - -@dataclass -class ReportVerificationByIdentityRequest(SinchRequestBaseModel): - endpoint: str - - -@dataclass -class ReportVerificationByIdentityAndSMSRequest( - ReportSMSVerificationDataTransformationMixin, - ReportVerificationByIdentityRequest -): - code: str - cli: str - method: str = VerificationMethod.SMS.value - - -@dataclass -class ReportVerificationByIdentityAndFlashCallRequest( - ReportFlashCallVerificationDataTransformationMixin, - ReportVerificationByIdentityRequest -): - cli: str - method: str = VerificationMethod.FLASH_CALL.value - - -@dataclass -class ReportVerificationByIdentityAndPhoneCallRequest( - ReportPhoneCallVerificationDataTransformationMixin, - ReportVerificationByIdentityRequest -): - code: str - method: str = VerificationMethod.CALLOUT.value - - -@dataclass -class ReportVerificationByIdRequest(SinchRequestBaseModel): - id: str - - -@dataclass -class ReportVerificationByIdAndSMSRequest( - ReportSMSVerificationDataTransformationMixin, - ReportVerificationByIdRequest -): - code: str - cli: str - method: str = VerificationMethod.SMS.value - - -@dataclass -class ReportVerificationByIdAndFlashCallRequest( - ReportFlashCallVerificationDataTransformationMixin, - ReportVerificationByIdRequest -): - cli: str - method: str = VerificationMethod.FLASH_CALL.value - - -@dataclass -class ReportVerificationByIdAndPhoneCallRequest( - ReportPhoneCallVerificationDataTransformationMixin, - ReportVerificationByIdRequest -): - code: str - method: str = VerificationMethod.CALLOUT.value - - -@dataclass -class GetVerificationStatusByReferenceRequest(SinchRequestBaseModel): - reference: str - - -@dataclass -class GetVerificationStatusByIdentityRequest(SinchRequestBaseModel): - endpoint: str - method: str - - -@dataclass -class GetVerificationStatusByIdRequest(SinchRequestBaseModel): - id: str - - -@dataclass -class ReportVerificationByIdentityRequestLegacy(ReportVerificationByIdentityRequest): - verification_report_request: dict - - def as_json(self): - return json.dumps(self.verification_report_request) - - -@dataclass -class ReportVerificationByIdRequestLegacy(ReportVerificationByIdRequest): - verification_report_request: dict - - def as_json(self): - return json.dumps(self.verification_report_request) diff --git a/sinch/domains/verification/models/responses.py b/sinch/domains/verification/models/responses.py deleted file mode 100644 index 5a55aabc..00000000 --- a/sinch/domains/verification/models/responses.py +++ /dev/null @@ -1,108 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.verification.enums import VerificationMethod, VerificationStatus - - -@dataclass -class FlashCallResponse: - cli_filter: str - interception_timeout: int - report_timeout: int - deny_call_after: int - - -@dataclass -class SMSResponse: - template: str - interception_timeout: str - - -@dataclass -class DataResponse: - target_uri: str - - -@dataclass -class StartVerificationResponse(SinchBaseModel): - id: str - method: VerificationMethod - _links: list - - -@dataclass -class StartFlashCallInitiateVerificationResponse(StartVerificationResponse): - flash_call: FlashCallResponse - - -@dataclass -class StartDataInitiateVerificationResponse(StartVerificationResponse): - seamless: DataResponse - - -@dataclass -class StartCalloutInitiateVerificationResponse(StartVerificationResponse): - pass - - -@dataclass -class StartSMSVerificationResponse(StartVerificationResponse): - sms: SMSResponse - - -@dataclass -class StartFlashCallVerificationResponse(StartVerificationResponse): - flash_call: FlashCallResponse - - -@dataclass -class StartDataVerificationResponse(StartVerificationResponse): - seamless: DataResponse - - -@dataclass -class StartPhoneCallVerificationResponse(StartVerificationResponse): - pass - - -@dataclass -class VerificationResponse(SinchBaseModel): - id: str - method: VerificationMethod - status: VerificationStatus - price: dict - identity: dict - country_id: str - verification_timestamp: str - reference: str - reason: str - call_complete: bool - - -@dataclass -class GetVerificationStatusByIdResponse(VerificationResponse): - pass - - -@dataclass -class ReportVerificationResponse(VerificationResponse): - pass - - -@dataclass -class ReportVerificationByIdentityResponse(ReportVerificationResponse): - pass - - -@dataclass -class ReportVerificationByIdResponse(ReportVerificationResponse): - pass - - -@dataclass -class GetVerificationStatusByReferenceResponse(VerificationResponse): - pass - - -@dataclass -class GetVerificationStatusByIdentityResponse(VerificationResponse): - pass diff --git a/sinch/domains/voice/__init__.py b/sinch/domains/voice/__init__.py deleted file mode 100644 index 8f997afa..00000000 --- a/sinch/domains/voice/__init__.py +++ /dev/null @@ -1,426 +0,0 @@ -from typing import List, Literal, Union -from sinch.domains.voice.endpoints.callouts.callout import CalloutEndpoint -from sinch.domains.voice.endpoints.calls.get_call import GetCallEndpoint -from sinch.domains.voice.endpoints.calls.update_call import UpdateCallEndpoint -from sinch.domains.voice.endpoints.calls.manage_call import ManageCallEndpoint - -from sinch.domains.voice.endpoints.applications.get_numbers import GetVoiceNumbersEndpoint -from sinch.domains.voice.endpoints.applications.query_number import QueryVoiceNumberEndpoint -from sinch.domains.voice.endpoints.applications.get_callback_urls import GetVoiceCallbacksEndpoint -from sinch.domains.voice.endpoints.applications.unassign_number import UnAssignVoiceNumberEndpoint -from sinch.domains.voice.endpoints.applications.assign_numbers import AssignVoiceNumbersEndpoint -from sinch.domains.voice.endpoints.applications.update_callbacks import UpdateVoiceCallbacksEndpoint - -from sinch.domains.voice.endpoints.conferences.kick_participant import KickParticipantConferenceEndpoint -from sinch.domains.voice.endpoints.conferences.kick_all_participants import KickAllConferenceEndpoint -from sinch.domains.voice.endpoints.conferences.manage_participant import ManageParticipantConferenceEndpoint -from sinch.domains.voice.endpoints.conferences.get_conference import GetConferenceEndpoint - -from sinch.domains.voice.enums import CalloutMethod -from sinch.domains.voice.models.callouts.responses import VoiceCalloutResponse -from sinch.domains.voice.models.callouts.requests import ( - ConferenceVoiceCalloutRequest, - TextToSpeechVoiceCalloutRequest, - CustomVoiceCalloutRequest, - Destination, - ConferenceDTMFOptions -) -from sinch.domains.voice.models.calls.requests import ( - GetVoiceCallRequest, - UpdateVoiceCallRequest, - ManageVoiceCallRequest -) -from sinch.domains.voice.models.calls.responses import ( - GetVoiceCallResponse, - UpdateVoiceCallResponse, - ManageVoiceCallResponse -) -from sinch.domains.voice.models.conferences.requests import ( - GetVoiceConferenceRequest, - KickAllVoiceConferenceRequest, - KickParticipantVoiceConferenceRequest, - ManageParticipantVoiceConferenceRequest -) -from sinch.domains.voice.models.conferences.responses import ( - GetVoiceConferenceResponse, - KickAllVoiceConferenceResponse, - ManageParticipantVoiceConferenceResponse, - KickParticipantVoiceConferenceResponse -) -from sinch.domains.voice.models.applications.requests import ( - AssignNumbersVoiceApplicationRequest, - UnassignNumbersVoiceApplicationRequest, - QueryNumberVoiceApplicationRequest, - UpdateCallbackUrlsVoiceApplicationRequest, - GetCallbackUrlsVoiceApplicationRequest -) -from sinch.domains.voice.models.applications.responses import ( - GetNumbersVoiceApplicationResponse, - AssignNumbersVoiceApplicationResponse, - UnassignNumbersVoiceApplicationResponse, - GetCallbackUrlsVoiceApplicationResponse, - QueryNumberVoiceApplicationResponse -) -from sinch.domains.voice.models.svaml.actions.actions import Action -from sinch.domains.voice.models.svaml.instructions.instructions import Instruction - - -class Callouts: - def __init__(self, sinch): - self._sinch = sinch - - def text_to_speech( - self, - destination: Destination, - cli: str = None, - dtmf: str = None, - domain: Literal["pstn", "mxp"] = None, - custom: str = None, - locale: str = None, - text: str = None, - prompts: str = None, - enable_ace: bool = None, - enable_dice: bool = None, - enable_pie: bool = None - ) -> VoiceCalloutResponse: - return self._sinch.configuration.transport.request( - CalloutEndpoint( - callout_method=CalloutMethod.TEXT_TO_SPEECH.value, - request_data=TextToSpeechVoiceCalloutRequest( - destination=destination, - cli=cli, - dtmf=dtmf, - domain=domain, - custom=custom, - locale=locale, - text=text, - prompts=prompts, - enableAce=enable_ace, - enableDice=enable_dice, - enablePie=enable_pie - ) - ) - ) - - def conference( - self, - destination: Destination, - conference_id: str, - cli: str = None, - conference_dtmf_options: ConferenceDTMFOptions = None, - dtmf: str = None, - conference: str = None, - max_duration: int = None, - enable_ace: bool = None, - enable_dice: bool = None, - enable_pie: bool = None, - locale: str = None, - greeting: str = None, - moh_class: str = None, - custom: str = None, - domain: Literal["pstn", "mxp"] = None, - ) -> VoiceCalloutResponse: - return self._sinch.configuration.transport.request( - CalloutEndpoint( - callout_method=CalloutMethod.CONFERENCE.value, - request_data=ConferenceVoiceCalloutRequest( - destination=destination, - conferenceId=conference_id, - cli=cli, - conferenceDtmfOptions=conference_dtmf_options, - dtmf=dtmf, - conference=conference, - maxDuration=max_duration, - enableAce=enable_ace, - enableDice=enable_dice, - enablePie=enable_pie, - locale=locale, - greeting=greeting, - mohClass=moh_class, - custom=custom, - domain=domain - ) - ) - ) - - def custom( - self, - cli: str = None, - destination: Destination = None, - dtmf: str = None, - custom: str = None, - max_duration: int = None, - ice: str = None, - ace: str = None, - pie: str = None - ) -> VoiceCalloutResponse: - return self._sinch.configuration.transport.request( - CalloutEndpoint( - callout_method=CalloutMethod.CUSTOM.value, - request_data=CustomVoiceCalloutRequest( - cli=cli, - destination=destination, - dtmf=dtmf, - custom=custom, - maxDuration=max_duration, - ice=ice, - ace=ace, - pie=pie - ) - ) - ) - - -class Calls: - def __init__(self, sinch): - self._sinch = sinch - - def get(self, call_id) -> GetVoiceCallResponse: - return self._sinch.configuration.transport.request( - GetCallEndpoint( - request_data=GetVoiceCallRequest( - call_id=call_id - ) - ) - ) - - def update( - self, - call_id: str, - instructions: Union[list, List[Instruction]], - action: Union[dict, Action] - ) -> UpdateVoiceCallResponse: - return self._sinch.configuration.transport.request( - UpdateCallEndpoint( - request_data=UpdateVoiceCallRequest( - call_id=call_id, - instructions=instructions, - action=action - ) - ) - ) - - def manage_with_call_leg( - self, - call_id: str, - call_leg: str, - instructions: Union[list, List[Instruction]], - action: Union[dict, Action] - ) -> ManageVoiceCallResponse: - return self._sinch.configuration.transport.request( - ManageCallEndpoint( - request_data=ManageVoiceCallRequest( - call_id=call_id, - call_leg=call_leg, - instructions=instructions, - action=action - ) - ) - ) - - -class Conferences: - def __init__(self, sinch): - self._sinch = sinch - - def call( - self, - destination: Destination, - conference_id: str, - cli: str = None, - conference_dtmf_options: ConferenceDTMFOptions = None, - dtmf: str = None, - conference: str = None, - max_duration: int = None, - enable_ace: bool = None, - enable_dice: bool = None, - enable_pie: bool = None, - locale: str = None, - greeting: str = None, - moh_class: str = None, - custom: str = None, - domain: Literal["pstn", "mxp"] = None, - ) -> VoiceCalloutResponse: - return self._sinch.voice.callouts.conference( - destination=destination, - conference_id=conference_id, - cli=cli, - conference_dtmf_options=conference_dtmf_options, - dtmf=dtmf, - conference=conference, - max_duration=max_duration, - enable_ace=enable_ace, - enable_dice=enable_dice, - enable_pie=enable_pie, - locale=locale, - greeting=greeting, - moh_class=moh_class, - custom=custom, - domain=domain - ) - - def get(self, conference_id: str) -> GetVoiceConferenceResponse: - return self._sinch.configuration.transport.request( - GetConferenceEndpoint( - request_data=GetVoiceConferenceRequest( - conference_id=conference_id - ) - ) - ) - - def kick_all(self, conference_id: str) -> KickAllVoiceConferenceResponse: - return self._sinch.configuration.transport.request( - KickAllConferenceEndpoint( - request_data=KickAllVoiceConferenceRequest( - conference_id=conference_id - ) - ) - ) - - def kick_participant( - self, - call_id: str, - conference_id: str, - ) -> KickParticipantVoiceConferenceResponse: - return self._sinch.configuration.transport.request( - KickParticipantConferenceEndpoint( - request_data=KickParticipantVoiceConferenceRequest( - call_id=call_id, - conference_id=conference_id - ) - ) - ) - - def manage_participant( - self, - call_id: str, - conference_id: str, - command: str, - moh: str = None - ) -> ManageParticipantVoiceConferenceResponse: - return self._sinch.configuration.transport.request( - ManageParticipantConferenceEndpoint( - request_data=ManageParticipantVoiceConferenceRequest( - call_id=call_id, - conference_id=conference_id, - command=command, - moh=moh - ) - ) - ) - - -class Applications: - def __init__(self, sinch): - self._sinch = sinch - - def get_numbers(self) -> GetNumbersVoiceApplicationResponse: - return self._sinch.configuration.transport.request( - GetVoiceNumbersEndpoint() - ) - - def assign_numbers( - self, - numbers: List[str], - application_key: str = None, - capability: str = None - ) -> AssignNumbersVoiceApplicationResponse: - return self._sinch.configuration.transport.request( - AssignVoiceNumbersEndpoint( - request_data=AssignNumbersVoiceApplicationRequest( - numbers=numbers, - application_key=application_key, - capability=capability - ) - ) - ) - - def unassign_number( - self, - number: str, - application_key: str = None, - capability: str = None - - ) -> UnassignNumbersVoiceApplicationResponse: - return self._sinch.configuration.transport.request( - UnAssignVoiceNumberEndpoint( - request_data=UnassignNumbersVoiceApplicationRequest( - number=number, - application_key=application_key, - capability=capability - ) - ) - ) - - def get_callback_urls( - self, - application_key: str - ) -> GetCallbackUrlsVoiceApplicationResponse: - return self._sinch.configuration.transport.request( - GetVoiceCallbacksEndpoint( - request_data=GetCallbackUrlsVoiceApplicationRequest( - application_key=application_key - ) - ) - ) - - def update_callback_urls( - self, - application_key: str, - primary: str = None, - fallback: str = None - ): - return self._sinch.configuration.transport.request( - UpdateVoiceCallbacksEndpoint( - request_data=UpdateCallbackUrlsVoiceApplicationRequest( - application_key=application_key, - primary=primary, - fallback=fallback - ) - ) - ) - - def query_number(self, number) -> QueryNumberVoiceApplicationResponse: - return self._sinch.configuration.transport.request( - QueryVoiceNumberEndpoint( - request_data=QueryNumberVoiceApplicationRequest( - number=number - ) - ) - ) - - -class VoiceBase: - """ - Documentation for the Voice API: https://developers.sinch.com/docs/voice/ - """ - def __init__(self, sinch): - self._sinch = sinch - - -class Voice(VoiceBase): - """ - Synchronous version of the Voice Domain - """ - __doc__ += VoiceBase.__doc__ - - def __init__(self, sinch): - super().__init__(sinch) - self.callouts = Callouts(self._sinch) - self.calls = Calls(self._sinch) - self.conferences = Conferences(self._sinch) - self.applications = Applications(self._sinch) - - -class VoiceAsync(VoiceBase): - """ - Asynchronous version of the Voice Domain - """ - __doc__ += VoiceBase.__doc__ - - def __init__(self, sinch): - super().__init__(sinch) - self.callouts = Callouts(self._sinch) - self.calls = Calls(self._sinch) - self.conferences = Conferences(self._sinch) - self.applications = Applications(self._sinch) diff --git a/sinch/domains/voice/endpoints/__init__.py b/sinch/domains/voice/endpoints/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/voice/endpoints/applications/__init__.py b/sinch/domains/voice/endpoints/applications/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/voice/endpoints/applications/assign_numbers.py b/sinch/domains/voice/endpoints/applications/assign_numbers.py deleted file mode 100644 index d07bcac4..00000000 --- a/sinch/domains/voice/endpoints/applications/assign_numbers.py +++ /dev/null @@ -1,26 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.applications.requests import AssignNumbersVoiceApplicationRequest -from sinch.domains.voice.models.applications.responses import AssignNumbersVoiceApplicationResponse - - -class AssignVoiceNumbersEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/v1/configuration/numbers" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: AssignNumbersVoiceApplicationRequest): - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_applications_origin - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> AssignNumbersVoiceApplicationResponse: - super().handle_response(response) - return AssignNumbersVoiceApplicationResponse() diff --git a/sinch/domains/voice/endpoints/applications/get_callback_urls.py b/sinch/domains/voice/endpoints/applications/get_callback_urls.py deleted file mode 100644 index 65c15ea7..00000000 --- a/sinch/domains/voice/endpoints/applications/get_callback_urls.py +++ /dev/null @@ -1,27 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.applications.responses import GetCallbackUrlsVoiceApplicationResponse -from sinch.domains.voice.models.applications.requests import GetCallbackUrlsVoiceApplicationRequest - - -class GetVoiceCallbacksEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/v1/configuration/callbacks/applications/{application_key}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: GetCallbackUrlsVoiceApplicationRequest): - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_applications_origin, - application_key=self.request_data.application_key - ) - - def handle_response(self, response: HTTPResponse) -> GetCallbackUrlsVoiceApplicationResponse: - super().handle_response(response) - return GetCallbackUrlsVoiceApplicationResponse( - primary=response.body["url"].get("primary"), - fallback=response.body["url"].get("fallback") - ) diff --git a/sinch/domains/voice/endpoints/applications/get_numbers.py b/sinch/domains/voice/endpoints/applications/get_numbers.py deleted file mode 100644 index ceed55a2..00000000 --- a/sinch/domains/voice/endpoints/applications/get_numbers.py +++ /dev/null @@ -1,31 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.domains.voice.models import ApplicationNumber -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.applications.responses import GetNumbersVoiceApplicationResponse - - -class GetVoiceNumbersEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/v1/configuration/numbers" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self): - pass - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_applications_origin - ) - - def handle_response(self, response: HTTPResponse) -> GetNumbersVoiceApplicationResponse: - super().handle_response(response) - return GetNumbersVoiceApplicationResponse( - numbers=[ - ApplicationNumber( - number=number.get("number"), - capability=number.get("capability"), - applicationkey=number.get("applicationkey") - ) for number in response.body["numbers"] - ] - ) diff --git a/sinch/domains/voice/endpoints/applications/query_number.py b/sinch/domains/voice/endpoints/applications/query_number.py deleted file mode 100644 index f5cef057..00000000 --- a/sinch/domains/voice/endpoints/applications/query_number.py +++ /dev/null @@ -1,30 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.applications.requests import QueryNumberVoiceApplicationRequest -from sinch.domains.voice.models.applications.responses import QueryNumberVoiceApplicationResponse - - -class QueryVoiceNumberEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/v1/calling/query/number/{number}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: QueryNumberVoiceApplicationRequest): - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_applications_origin, - number=self.request_data.number - ) - - def handle_response(self, response: HTTPResponse) -> QueryNumberVoiceApplicationResponse: - super().handle_response(response) - return QueryNumberVoiceApplicationResponse( - country_id=response.body["number"]["countryId"], - number_type=response.body["number"]["numberType"], - normalized_number=response.body["number"]["normalizedNumber"], - restricted=response.body["number"]["restricted"], - rate=response.body["number"]["rate"] - ) diff --git a/sinch/domains/voice/endpoints/applications/unassign_number.py b/sinch/domains/voice/endpoints/applications/unassign_number.py deleted file mode 100644 index a2b6bfd5..00000000 --- a/sinch/domains/voice/endpoints/applications/unassign_number.py +++ /dev/null @@ -1,38 +0,0 @@ -import json -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.applications.requests import UnassignNumbersVoiceApplicationRequest -from sinch.domains.voice.models.applications.responses import UnassignNumbersVoiceApplicationResponse - - -class UnAssignVoiceNumberEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/v1/configuration/numbers" - HTTP_METHOD = HTTPMethods.DELETE.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: UnassignNumbersVoiceApplicationRequest): - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_applications_origin - ) - - def request_body(self): - request_data = {} - - if self.request_data.number: - request_data["number"] = self.request_data.number - - if self.request_data.application_key: - request_data["applicationKey"] = self.request_data.application_key - - if self.request_data.capability: - request_data["capability"] = self.request_data.capability - - return json.dumps(request_data) - - def handle_response(self, response: HTTPResponse) -> UnassignNumbersVoiceApplicationResponse: - super().handle_response(response) - return UnassignNumbersVoiceApplicationResponse() diff --git a/sinch/domains/voice/endpoints/applications/update_callbacks.py b/sinch/domains/voice/endpoints/applications/update_callbacks.py deleted file mode 100644 index f95630e1..00000000 --- a/sinch/domains/voice/endpoints/applications/update_callbacks.py +++ /dev/null @@ -1,38 +0,0 @@ -import json -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.applications.requests import UpdateCallbackUrlsVoiceApplicationRequest -from sinch.domains.voice.models.applications.responses import UpdateCallbackUrlsVoiceApplicationResponse - - -class UpdateVoiceCallbacksEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/v1/configuration/callbacks/applications/{application_key}" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: UpdateCallbackUrlsVoiceApplicationRequest): - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_applications_origin, - application_key=self.request_data.application_key - ) - - def request_body(self): - request_data = { - "url": {} - } - - if self.request_data.primary: - request_data["url"]["primary"] = self.request_data.primary - - if self.request_data.primary: - request_data["url"]["fallback"] = self.request_data.fallback - - return json.dumps(request_data) - - def handle_response(self, response: HTTPResponse) -> UpdateCallbackUrlsVoiceApplicationResponse: - super().handle_response(response) - return UpdateCallbackUrlsVoiceApplicationResponse() diff --git a/sinch/domains/voice/endpoints/callouts/__init__.py b/sinch/domains/voice/endpoints/callouts/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/voice/endpoints/callouts/callout.py b/sinch/domains/voice/endpoints/callouts/callout.py deleted file mode 100644 index ef889a05..00000000 --- a/sinch/domains/voice/endpoints/callouts/callout.py +++ /dev/null @@ -1,55 +0,0 @@ -import json -from sinch.domains.voice.enums import CalloutMethod -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.callouts.responses import VoiceCalloutResponse - - -class CalloutEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/calling/v1/callouts" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data, callout_method): - self.request_data = request_data - self.callout_method = callout_method - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_origin - ) - - def request_body(self): - request_data = {} - if self.callout_method == CalloutMethod.TEXT_TO_SPEECH.value: - request_data["method"] = CalloutMethod.TEXT_TO_SPEECH.value - request_data[CalloutMethod.TEXT_TO_SPEECH.value] = self.request_data.as_dict() - - elif self.callout_method == CalloutMethod.CUSTOM.value: - request_data["method"] = CalloutMethod.CUSTOM.value - request_data[CalloutMethod.CUSTOM.value] = self.request_data.as_dict() - - elif self.callout_method == CalloutMethod.CONFERENCE.value: - request_data["method"] = CalloutMethod.CONFERENCE.value - if self.request_data.conferenceDtmfOptions: - dtmf_options = {} - - if self.request_data.conferenceDtmfOptions["mode"]: - dtmf_options["mode"] = self.request_data.conferenceDtmfOptions["mode"] - - if self.request_data.conferenceDtmfOptions["timeout_mills"]: - dtmf_options["timeoutMills"] = self.request_data.conferenceDtmfOptions["timeout_mills"] - - if self.request_data.conferenceDtmfOptions["max_digits"]: - dtmf_options["maxDigits"] = self.request_data.conferenceDtmfOptions["max_digits"] - - self.request_data.conferenceDtmfOptions = dtmf_options - - request_data[CalloutMethod.CONFERENCE.value] = self.request_data.as_dict() - - return json.dumps(request_data) - - def handle_response(self, response: HTTPResponse): - super().handle_response(response) - return VoiceCalloutResponse(call_id=response.body["callId"]) diff --git a/sinch/domains/voice/endpoints/calls/__init__.py b/sinch/domains/voice/endpoints/calls/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/voice/endpoints/calls/get_call.py b/sinch/domains/voice/endpoints/calls/get_call.py deleted file mode 100644 index 5e5b4504..00000000 --- a/sinch/domains/voice/endpoints/calls/get_call.py +++ /dev/null @@ -1,53 +0,0 @@ -from sinch.core.deserializers import timestamp_to_datetime_in_utc_deserializer -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.calls.responses import GetVoiceCallResponse -from sinch.domains.voice.models.calls.requests import GetVoiceCallRequest -from sinch.domains.voice.models import Price, Destination - - -class GetCallEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/calling/v1/calls/id/{call_id}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: GetVoiceCallRequest): - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_origin, - call_id=self.request_data.call_id - ) - - def handle_response(self, response: HTTPResponse) -> GetVoiceCallResponse: - super().handle_response(response) - call_origin = response.body.get("from") - call_destination = response.body.get("to") - return GetVoiceCallResponse( - from_=Destination( - type=call_origin["type"], - endpoint=call_origin.get["endpoint"], - ) if call_origin else None, - to=Destination( - type=call_destination.get("type"), - endpoint=call_destination.get("endpoint") - ) if call_destination else None, - domain=response.body.get("domain"), - call_id=response.body.get("callId"), - duration=response.body.get("duration"), - status=response.body.get("status"), - result=response.body.get("result"), - reason=response.body.get("reason"), - timestamp=timestamp_to_datetime_in_utc_deserializer(response.body["timestamp"]), - custom=response.body.get("custom"), - user_rate=Price( - currency_id=response.body["userRate"]["currencyId"], - amount=response.body["userRate"]["amount"] - ), - debit=Price( - currency_id=response.body["userRate"]["currencyId"], - amount=response.body["userRate"]["amount"] - ) - ) diff --git a/sinch/domains/voice/endpoints/calls/manage_call.py b/sinch/domains/voice/endpoints/calls/manage_call.py deleted file mode 100644 index b734f305..00000000 --- a/sinch/domains/voice/endpoints/calls/manage_call.py +++ /dev/null @@ -1,32 +0,0 @@ -from copy import deepcopy -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.calls.responses import ManageVoiceCallResponse -from sinch.domains.voice.models.calls.requests import ManageVoiceCallRequest - - -class ManageCallEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/calling/v1/calls/id/{call_id}/leg/{call_leg}" - HTTP_METHOD = HTTPMethods.PATCH.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: ManageVoiceCallRequest): - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_origin, - call_id=self.request_data.call_id, - call_leg=self.request_data.call_leg - ) - - def request_body(self): - request_data = deepcopy(self.request_data) - request_data.call_leg = None - request_data.call_id = None - return request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> ManageVoiceCallResponse: - super().handle_response(response) - return ManageVoiceCallResponse() diff --git a/sinch/domains/voice/endpoints/calls/update_call.py b/sinch/domains/voice/endpoints/calls/update_call.py deleted file mode 100644 index 7dd982ef..00000000 --- a/sinch/domains/voice/endpoints/calls/update_call.py +++ /dev/null @@ -1,30 +0,0 @@ -from copy import deepcopy -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.calls.responses import UpdateVoiceCallResponse -from sinch.domains.voice.models.calls.requests import UpdateVoiceCallRequest - - -class UpdateCallEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/calling/v1/calls/id/{call_id}" - HTTP_METHOD = HTTPMethods.PATCH.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: UpdateVoiceCallRequest): - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_origin, - call_id=self.request_data.call_id - ) - - def request_body(self): - request_data = deepcopy(self.request_data) - request_data.call_id = None - return request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> UpdateVoiceCallResponse: - super().handle_response(response) - return UpdateVoiceCallResponse() diff --git a/sinch/domains/voice/endpoints/conferences/__init__.py b/sinch/domains/voice/endpoints/conferences/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/voice/endpoints/conferences/get_conference.py b/sinch/domains/voice/endpoints/conferences/get_conference.py deleted file mode 100644 index 8f2ec107..00000000 --- a/sinch/domains/voice/endpoints/conferences/get_conference.py +++ /dev/null @@ -1,29 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.conferences.responses import GetVoiceConferenceResponse -from sinch.domains.voice.models.conferences.requests import GetVoiceConferenceRequest -from sinch.domains.voice.models import ConferenceParticipant - - -class GetConferenceEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/calling/v1/conferences/id/{conference_id}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: GetVoiceConferenceRequest): - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_origin, - conference_id=self.request_data.conference_id - ) - - def handle_response(self, response: HTTPResponse) -> GetVoiceConferenceResponse: - super().handle_response(response) - return GetVoiceConferenceResponse( - participants=[ - ConferenceParticipant(**participant) for participant in response.body["participants"] - ] - ) diff --git a/sinch/domains/voice/endpoints/conferences/kick_all_participants.py b/sinch/domains/voice/endpoints/conferences/kick_all_participants.py deleted file mode 100644 index 7f3e5dc5..00000000 --- a/sinch/domains/voice/endpoints/conferences/kick_all_participants.py +++ /dev/null @@ -1,24 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.conferences.responses import KickAllVoiceConferenceResponse -from sinch.domains.voice.models.conferences.requests import KickAllVoiceConferenceRequest - - -class KickAllConferenceEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/calling/v1/conferences/id/{conference_id}" - HTTP_METHOD = HTTPMethods.DELETE.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: KickAllVoiceConferenceRequest): - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_origin, - conference_id=self.request_data.conference_id - ) - - def handle_response(self, response: HTTPResponse) -> KickAllVoiceConferenceResponse: - super().handle_response(response) - return KickAllVoiceConferenceResponse() diff --git a/sinch/domains/voice/endpoints/conferences/kick_participant.py b/sinch/domains/voice/endpoints/conferences/kick_participant.py deleted file mode 100644 index 2dd0b4ee..00000000 --- a/sinch/domains/voice/endpoints/conferences/kick_participant.py +++ /dev/null @@ -1,25 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.conferences.responses import KickParticipantVoiceConferenceResponse -from sinch.domains.voice.models.conferences.requests import KickParticipantVoiceConferenceRequest - - -class KickParticipantConferenceEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/calling/v1/conferences/id/{conference_id}/{call_id}" - HTTP_METHOD = HTTPMethods.DELETE.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: KickParticipantVoiceConferenceRequest): - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_origin, - conference_id=self.request_data.conference_id, - call_id=self.request_data.call_id - ) - - def handle_response(self, response: HTTPResponse) -> KickParticipantVoiceConferenceResponse: - super().handle_response(response) - return KickParticipantVoiceConferenceResponse() diff --git a/sinch/domains/voice/endpoints/conferences/manage_participant.py b/sinch/domains/voice/endpoints/conferences/manage_participant.py deleted file mode 100644 index 593f9e3b..00000000 --- a/sinch/domains/voice/endpoints/conferences/manage_participant.py +++ /dev/null @@ -1,32 +0,0 @@ -from copy import deepcopy -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.conferences.responses import ManageParticipantVoiceConferenceResponse -from sinch.domains.voice.models.conferences.requests import ManageParticipantVoiceConferenceRequest - - -class ManageParticipantConferenceEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/calling/v1/conferences/id/{conference_id}/{call_id}" - HTTP_METHOD = HTTPMethods.PATCH.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: ManageParticipantVoiceConferenceRequest): - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_origin, - conference_id=self.request_data.conference_id, - call_id=self.request_data.call_id - ) - - def request_body(self): - request_data = deepcopy(self.request_data) - request_data.conference_id = None - request_data.call_id = None - return request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> ManageParticipantVoiceConferenceResponse: - super().handle_response(response) - return ManageParticipantVoiceConferenceResponse() diff --git a/sinch/domains/voice/endpoints/voice_endpoint.py b/sinch/domains/voice/endpoints/voice_endpoint.py deleted file mode 100644 index 05950720..00000000 --- a/sinch/domains/voice/endpoints/voice_endpoint.py +++ /dev/null @@ -1,13 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.core.endpoint import HTTPEndpoint -from sinch.domains.voice.exceptions import VoiceException - - -class VoiceEndpoint(HTTPEndpoint): - def handle_response(self, response: HTTPResponse): - if response.status_code >= 400: - raise VoiceException( - message=response.body["message"], - response=response, - is_from_server=True - ) diff --git a/sinch/domains/voice/enums.py b/sinch/domains/voice/enums.py deleted file mode 100644 index 40de184d..00000000 --- a/sinch/domains/voice/enums.py +++ /dev/null @@ -1,82 +0,0 @@ -from enum import Enum - - -class CalloutMethod(Enum): - TEXT_TO_SPEECH = "ttsCallout" - CUSTOM = "customCallout" - CONFERENCE = "conferenceCallout" - - -class Region(Enum): - EUROPE = "euc1" - NORTH_AMERICA = "use1" - SOUTH_AMERICA = "sae1" - SOUTH_EAST_ASIA_1 = "apse1" - SOUTH_EAST_ASIA_2 = "apse2" - - -class ConferenceCommand(Enum): - MUTE = "mute" - UNMUTE = "unmute" - ONHOLD = "onhold" - RESUME = "resume" - - -class MusicOnHold(Enum): - RING = "ring" - MUSIC_1 = "music1" - MUSIC_2 = "music2" - MUSIC_3 = "music3" - - -class ConferenceDTMFOptionsMode(Enum): - IGNORE = "ignore" - FORWARD = "forward" - DETECT = "detect" - - -class Indications(Enum): - AUSTRIA = "at" - AUSTRALIA = "au" - BULGARIA = "bg" - BRAZIL = "br" - BELGIUM = "be" - SWITZERLAND = "ch" - CHILE = "cl" - CHINA = "cn" - CZECH_REPUBLIC = "cz" - GERMANY = "de" - DENMARK = "dk" - ESTONIA = "ee" - SPAIN = "es" - FINLAND = "fi" - FRANCE = "fr" - GREECE = "gr" - HUNGARY = "hu" - ISRAEL = "il" - INDIA = "in" - ITALY = "it" - LITHUANIA = "lt" - JAPAN = "jp" - MEXICO = "mx" - MALAYSIA = "my" - NETHERLANDS = "nl" - NORWAY = "no" - NEW_ZEALAND = "nz" - PHILIPPINES = "ph" - POLAND = "pl" - PORTUGAL = "pt" - RUSSIA = "ru" - SWEDEN = "se" - SINGAPORE = "sg" - THAILAND = "th" - UNITED_KINGDOM = "uk" - UNITED_STATES = "us" - TAIWAN = "tw" - VENEZUELA = "ve" - SOUTH_AFRICA = "za" - - -class Capability(Enum): - VOCE = "voice" - SMS = "sms" diff --git a/sinch/domains/voice/models/__init__.py b/sinch/domains/voice/models/__init__.py deleted file mode 100644 index dc15940d..00000000 --- a/sinch/domains/voice/models/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -from dataclasses import dataclass -from typing import TypedDict, Literal - - -@dataclass -class Price: - currency_id: str - amount: float - - -@dataclass -class ConferenceParticipant: - cli: str - id: str - duration: int - muted: bool - onhold: bool - - -@dataclass -class ApplicationNumber: - number: str - capability: str - applicationkey: str - - -class Destination(TypedDict): - type: Literal["number", "username"] - endpoint: str - - -class ConferenceDTMFOptions(TypedDict): - mode: Literal["ignore", "forward", "detect"] - max_digits: int - timeout_mills: int diff --git a/sinch/domains/voice/models/applications/__init__.py b/sinch/domains/voice/models/applications/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/voice/models/applications/requests.py b/sinch/domains/voice/models/applications/requests.py deleted file mode 100644 index 3d0883f6..00000000 --- a/sinch/domains/voice/models/applications/requests.py +++ /dev/null @@ -1,34 +0,0 @@ -from typing import List -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class AssignNumbersVoiceApplicationRequest(SinchRequestBaseModel): - numbers: List[str] - application_key: str - capability: str - - -@dataclass -class UnassignNumbersVoiceApplicationRequest(SinchRequestBaseModel): - number: str - application_key: str - capability: str - - -@dataclass -class QueryNumberVoiceApplicationRequest(SinchRequestBaseModel): - number: str - - -@dataclass -class UpdateCallbackUrlsVoiceApplicationRequest(SinchRequestBaseModel): - application_key: str - primary: str - fallback: str - - -@dataclass -class GetCallbackUrlsVoiceApplicationRequest(SinchRequestBaseModel): - application_key: str diff --git a/sinch/domains/voice/models/applications/responses.py b/sinch/domains/voice/models/applications/responses.py deleted file mode 100644 index a1637d88..00000000 --- a/sinch/domains/voice/models/applications/responses.py +++ /dev/null @@ -1,40 +0,0 @@ -from typing import List - -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.voice.models import ApplicationNumber, Price - - -@dataclass -class GetNumbersVoiceApplicationResponse(SinchBaseModel): - numbers: List[ApplicationNumber] - - -@dataclass -class AssignNumbersVoiceApplicationResponse(SinchBaseModel): - pass - - -@dataclass -class UnassignNumbersVoiceApplicationResponse(SinchBaseModel): - pass - - -@dataclass -class UpdateCallbackUrlsVoiceApplicationResponse(SinchBaseModel): - pass - - -@dataclass -class GetCallbackUrlsVoiceApplicationResponse(SinchBaseModel): - primary: str - fallback: str - - -@dataclass -class QueryNumberVoiceApplicationResponse(SinchBaseModel): - country_id: str - number_type: str - normalized_number: str - restricted: bool - rate: Price diff --git a/sinch/domains/voice/models/callouts/__init__.py b/sinch/domains/voice/models/callouts/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/voice/models/callouts/requests.py b/sinch/domains/voice/models/callouts/requests.py deleted file mode 100644 index 753ce2f5..00000000 --- a/sinch/domains/voice/models/callouts/requests.py +++ /dev/null @@ -1,50 +0,0 @@ -from dataclasses import dataclass -from typing import Literal -from sinch.core.models.base_model import SinchRequestBaseModel -from sinch.domains.voice.models import Destination, ConferenceDTMFOptions - - -@dataclass -class TextToSpeechVoiceCalloutRequest(SinchRequestBaseModel): - destination: Destination - cli: str - dtmf: str - domain: Literal["pstn", "mxp"] - custom: str - locale: str - text: str - prompts: str - enableAce: bool - enableDice: bool - enablePie: bool - - -@dataclass -class ConferenceVoiceCalloutRequest(SinchRequestBaseModel): - destination: Destination - conferenceId: str - cli: str - conferenceDtmfOptions: ConferenceDTMFOptions - dtmf: str - conference: str - maxDuration: int - enableAce: bool - enableDice: bool - enablePie: bool - locale: str - greeting: str - mohClass: str - custom: str - domain: Literal["pstn", "mxp"] - - -@dataclass -class CustomVoiceCalloutRequest(SinchRequestBaseModel): - cli: str - destination: Destination - dtmf: str - custom: str - maxDuration: int - ice: str - ace: str - pie: str diff --git a/sinch/domains/voice/models/callouts/responses.py b/sinch/domains/voice/models/callouts/responses.py deleted file mode 100644 index 12691078..00000000 --- a/sinch/domains/voice/models/callouts/responses.py +++ /dev/null @@ -1,7 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class VoiceCalloutResponse(SinchBaseModel): - call_id: str diff --git a/sinch/domains/voice/models/calls/__init__.py b/sinch/domains/voice/models/calls/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/voice/models/calls/requests.py b/sinch/domains/voice/models/calls/requests.py deleted file mode 100644 index a49e29f7..00000000 --- a/sinch/domains/voice/models/calls/requests.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import Union, List -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel -from sinch.domains.voice.models.svaml.actions.actions import Action -from sinch.domains.voice.models.svaml.instructions.instructions import Instruction - - -@dataclass -class GetVoiceCallRequest(SinchRequestBaseModel): - call_id: str - - -@dataclass -class UpdateVoiceCallRequest(SinchRequestBaseModel): - call_id: str - instructions: Union[list, List[Instruction]] - action: Action - - -@dataclass -class ManageVoiceCallRequest(SinchRequestBaseModel): - call_id: str - call_leg: str - instructions: Union[list, List[Instruction]] - action: Action diff --git a/sinch/domains/voice/models/calls/responses.py b/sinch/domains/voice/models/calls/responses.py deleted file mode 100644 index f0e87efa..00000000 --- a/sinch/domains/voice/models/calls/responses.py +++ /dev/null @@ -1,29 +0,0 @@ -from datetime import datetime -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.voice.models import Price, Destination - - -@dataclass -class GetVoiceCallResponse(SinchBaseModel): - from_: Destination - to: Destination - domain: str - call_id: str - duration: int - status: str - result: str - reason: str - timestamp: datetime - custom: str - user_rate: Price - debit: Price - - -@dataclass -class UpdateVoiceCallResponse(SinchBaseModel): - pass - - -class ManageVoiceCallResponse(SinchBaseModel): - pass diff --git a/sinch/domains/voice/models/conferences/__init__.py b/sinch/domains/voice/models/conferences/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/voice/models/conferences/requests.py b/sinch/domains/voice/models/conferences/requests.py deleted file mode 100644 index aca3f0b8..00000000 --- a/sinch/domains/voice/models/conferences/requests.py +++ /dev/null @@ -1,26 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class GetVoiceConferenceRequest(SinchRequestBaseModel): - conference_id: str - - -@dataclass -class KickAllVoiceConferenceRequest(SinchRequestBaseModel): - conference_id: str - - -@dataclass -class ManageParticipantVoiceConferenceRequest(SinchRequestBaseModel): - conference_id: str - call_id: str - command: str - moh: str - - -@dataclass -class KickParticipantVoiceConferenceRequest(SinchRequestBaseModel): - conference_id: str - call_id: str diff --git a/sinch/domains/voice/models/conferences/responses.py b/sinch/domains/voice/models/conferences/responses.py deleted file mode 100644 index ad0c0423..00000000 --- a/sinch/domains/voice/models/conferences/responses.py +++ /dev/null @@ -1,24 +0,0 @@ -from dataclasses import dataclass -from typing import List -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.voice.models import ConferenceParticipant - - -@dataclass -class GetVoiceConferenceResponse(SinchBaseModel): - participants: List[ConferenceParticipant] - - -@dataclass -class KickAllVoiceConferenceResponse(SinchBaseModel): - pass - - -@dataclass -class ManageParticipantVoiceConferenceResponse(SinchBaseModel): - pass - - -@dataclass -class KickParticipantVoiceConferenceResponse(SinchBaseModel): - pass diff --git a/sinch/domains/voice/models/svaml/__init__.py b/sinch/domains/voice/models/svaml/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/voice/models/svaml/actions/__init__.py b/sinch/domains/voice/models/svaml/actions/__init__.py deleted file mode 100644 index 94f6d89c..00000000 --- a/sinch/domains/voice/models/svaml/actions/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from .actions import ( - AnsweringMachineDetection, CallHeader, HangupAction, - ContinueAction, ConnectPstnAction, ConnectMxpAction, - Option, MenuOption, ConnectSipAction, ConnectConfAction, - RunMenuAction, ParkAction -) - -__all__ = [ - "AnsweringMachineDetection", "CallHeader", "HangupAction", - "ContinueAction", "ConnectPstnAction", "ConnectMxpAction", - "Option", "MenuOption", "ConnectSipAction", "ConnectConfAction", - "RunMenuAction", "ParkAction" -] diff --git a/sinch/domains/voice/models/svaml/actions/actions.py b/sinch/domains/voice/models/svaml/actions/actions.py deleted file mode 100644 index 125d6b87..00000000 --- a/sinch/domains/voice/models/svaml/actions/actions.py +++ /dev/null @@ -1,190 +0,0 @@ -from dataclasses import dataclass -from typing import Optional, List, TypedDict -from sinch.core.models.base_model import SinchRequestBaseModel -from sinch.domains.voice.models import Destination, ConferenceDTMFOptions - - -class Action(SinchRequestBaseModel): - name: str - - -class AnsweringMachineDetection(TypedDict): - enabled: bool - - -class CallHeader(TypedDict): - key: str - value: str - - -@dataclass -class HangupAction(Action): - name: str = "hangup" - - -@dataclass -class ContinueAction(Action): - name: str = "continue" - - -@dataclass -class ConnectPstnAction(Action): - name: str = "connectPstn" - number: Optional[str] = None - locale: Optional[str] = None - max_duration: Optional[int] = None - dial_timeout: Optional[int] = None - cli: Optional[str] = None - suppress_callbacks: Optional[bool] = None - dtmf: Optional[str] = None - indications: Optional[str] = None - amd: Optional[AnsweringMachineDetection] = None - - def as_dict(self): - payload = super().as_dict() - if payload.get("max_duration"): - payload["maxDuration"] = payload.pop("max_duration") - - if payload.get("dial_timeout"): - payload["dialTimeout"] = payload.pop("dial_timeout") - - if payload.get("suppress_callbacks"): - payload["suppressCallbacks"] = payload.pop("suppress_callbacks") - - return payload - - -@dataclass -class ConnectMxpAction(Action): - name: str = "connectMxp" - destination: Optional[Destination] = None - call_headers: Optional[List[CallHeader]] = None - - def as_dict(self): - payload = super().as_dict() - if payload.get("call_headers"): - payload["callHeaders"] = payload.pop("call_headers") - - return payload - - -@dataclass -class Option(SinchRequestBaseModel): - dtmf: str - action: str - - -@dataclass -class MenuOption(SinchRequestBaseModel): - id: str - main_prompt: Optional[str] = None - repeat_prompt: Optional[str] = None - repeats: Optional[int] = None - max_digits: Optional[int] = None - timeout_mills: Optional[int] = None - max_timeout_mills: Optional[int] = None - options: Optional[List[Option]] = None - - def as_dict(self): - payload = super().as_dict() - if payload.get("main_prompt"): - payload["mainPrompt"] = payload.pop("main_prompt") - - if payload.get("repeat_prompt"): - payload["repeatPrompt"] = payload.pop("repeat_prompt") - - if payload.get("max_digits"): - payload["maxDigits"] = payload.pop("max_digits") - - if payload.get("timeout_mills"): - payload["timeoutMills"] = payload.pop("timeout_mills") - - if payload.get("max_timeout_mills"): - payload["maxTimeoutMills"] = payload.pop("max_timeout_mills") - - return payload - - -@dataclass -class ConnectSipAction(Action): - destination: Optional[Destination] - name: str = "connectSip" - max_duration: Optional[int] = None - cli: Optional[str] = None - transport: Optional[str] = None - suppress_callbacks: Optional[bool] = None - call_headers: Optional[List[CallHeader]] = None - moh: Optional[str] = None - - def as_dict(self): - payload = super().as_dict() - if payload.get("max_duration"): - payload["maxDuration"] = payload.pop("max_duration") - - if payload.get("suppress_callbacks"): - payload["suppressCallbacks"] = payload.pop("suppress_callbacks") - - if payload.get("call_headers"): - payload["callHeaders"] = payload.pop("call_headers") - - return payload - - -@dataclass -class ConnectConfAction(Action): - conference_id: str - name: str = "connectConf" - conference_dtmf_options: Optional[ConferenceDTMFOptions] = None - moh: Optional[str] = None - - def as_dict(self): - payload = super().as_dict() - if payload.get("conference_id"): - payload["conferenceId"] = payload.pop("conference_id") - - if payload.get("conference_dtmf_options"): - payload["conferenceDtmfOptions"] = payload.pop("conference_dtmf_options") - - return payload - - -@dataclass -class RunMenuAction(Action): - name: str = "runMenu" - barge: Optional[bool] = None - locale: Optional[str] = None - main_menu: Optional[str] = None - enable_voice: Optional[bool] = None - menus: Optional[List[MenuOption]] = None - - def as_dict(self): - payload = super().as_dict() - if payload.get("main_menu"): - payload["mainMenu"] = payload.pop("main_menu") - - if payload.get("enable_voice"): - payload["enableVoice"] = payload.pop("enable_voice") - - return payload - - -@dataclass -class ParkAction(Action): - name: str = "park" - locale: Optional[str] = None - intro_prompt: Optional[str] = None - hold_prompt: Optional[str] = None - max_duration: Optional[int] = None - - def as_dict(self): - payload = super().as_dict() - if payload.get("intro_prompt"): - payload["introPrompt"] = payload.pop("intro_prompt") - - if payload.get("hold_prompt"): - payload["holdPrompt"] = payload.pop("hold_prompt") - - if payload.get("max_duration"): - payload["maxDuration"] = payload.pop("max_duration") - - return payload diff --git a/sinch/domains/voice/models/svaml/instructions/__init__.py b/sinch/domains/voice/models/svaml/instructions/__init__.py deleted file mode 100644 index b4cc2553..00000000 --- a/sinch/domains/voice/models/svaml/instructions/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from .instructions import ( - TranscriptionOptions, RecordingOptions, PlayFileInstruction, - SayInstruction, SendDtmfInstruction, SetCookieInstruction, - AnswerInstruction, StartRecordingInstruction, StopRecordingInstruction -) - -__all__ = [ - "TranscriptionOptions", "RecordingOptions", "PlayFileInstruction", - "SayInstruction", "SendDtmfInstruction", "SetCookieInstruction", - "AnswerInstruction", "StartRecordingInstruction", "StopRecordingInstruction" -] diff --git a/sinch/domains/voice/models/svaml/instructions/instructions.py b/sinch/domains/voice/models/svaml/instructions/instructions.py deleted file mode 100644 index 8b254a24..00000000 --- a/sinch/domains/voice/models/svaml/instructions/instructions.py +++ /dev/null @@ -1,79 +0,0 @@ -from dataclasses import dataclass -from typing import Optional, List -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class Instruction(SinchRequestBaseModel): - pass - - -@dataclass -class TranscriptionOptions(SinchRequestBaseModel): - enabled: str = None - locale: str = None - - -@dataclass -class RecordingOptions(SinchRequestBaseModel): - destination_url: str = None - credentials: str = None - format: str = None - notification_events: str = None - transcription_options: TranscriptionOptions = None - - def as_dict(self): - payload = super().as_dict() - if payload.get("destination_url"): - payload["destinationUrl"] = payload.pop("destination_url") - - if payload.get("notification_events"): - payload["notificationEvents"] = payload.pop("notification_events") - - if payload.get("transcription_options"): - payload["transcriptionOptions"] = payload.pop("transcription_options") - - return payload - - -@dataclass -class PlayFileInstruction(Instruction): - ids: List[List[str]] - locale: str - name: str = "playFiles" - - -@dataclass -class SayInstruction(Instruction): - name: str = "say" - text: Optional[str] = None - locale: Optional[str] = None - - -@dataclass -class SendDtmfInstruction(Instruction): - name: str = "sendDtmf" - value: Optional[str] = None - - -@dataclass -class SetCookieInstruction(Instruction): - name: str = "setCookie" - key: Optional[str] = None - value: Optional[str] = None - - -@dataclass -class AnswerInstruction(Instruction): - name: str = "answer" - - -@dataclass -class StartRecordingInstruction(Instruction): - name: str = "startRecording" - options: Optional[RecordingOptions] = None - - -@dataclass -class StopRecordingInstruction(Instruction): - name: str = "stopRecording" diff --git a/tests/conftest.py b/tests/conftest.py index 2f3a4676..3cae4d3c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,21 +1,30 @@ # This file that contains fixtures that are shared across all tests in the tests directory. import os from dataclasses import dataclass +from unittest.mock import Mock, MagicMock +from sinch.domains.sms.models.v1.internal import ( + ListDeliveryReportsRequest, + ListDeliveryReportsResponse, +) import pytest -from sinch import SinchClient, SinchClientAsync -from sinch.core.models.base_model import SinchBaseModel, SinchRequestBaseModel +from sinch import SinchClient +from sinch.core.models.base_model import SinchRequestBaseModel from sinch.core.models.http_response import HTTPResponse -from sinch.domains.authentication.models.authentication import OAuthToken +from sinch.domains.authentication.models.v1.authentication import OAuthToken +from sinch.domains.numbers.models.v1.response import ActiveNumber -@dataclass -class IntBasedPaginationResponse(SinchBaseModel): - count: int - page: int - page_size: int - pig_dogs: list +def parse_iso_datetime(iso_string): + """ + Parse ISO datetime string that may end with 'Z' (UTC indicator). + Compatible with Python 3.9+ by replacing 'Z' with '+00:00'. + """ + from datetime import datetime + if iso_string.endswith('Z'): + iso_string = iso_string[:-1] + '+00:00' + return datetime.fromisoformat(iso_string) @dataclass @@ -24,12 +33,6 @@ class IntBasedPaginationRequest(SinchRequestBaseModel): page_size: int = 0 -@dataclass -class TokenBasedPaginationResponse(SinchBaseModel): - pig_dogs: list - next_page_token: str = None - - @dataclass class TokenBasedPaginationRequest(SinchRequestBaseModel): page_size: int @@ -42,10 +45,7 @@ def configure_origin( conversation_origin, templates_origin, auth_origin, - sms_origin, - verification_origin, - voice_origin, - disable_ssl + sms_origin ): if auth_origin: sinch_client.configuration.auth_origin = auth_origin @@ -63,16 +63,6 @@ def configure_origin( sinch_client.configuration.sms_origin = sms_origin sinch_client.configuration.sms_origin_with_service_plan_id = sms_origin - if verification_origin: - sinch_client.configuration.verification_origin = verification_origin - - if voice_origin: - sinch_client.configuration.voice_origin = voice_origin - sinch_client.configuration.voice_applications_origin = voice_origin - - if disable_ssl: - sinch_client.configuration.disable_https = True - return sinch_client @@ -111,16 +101,6 @@ def sms_origin(): return os.getenv("SMS_ORIGIN") -@pytest.fixture -def verification_origin(): - return os.getenv("VERIFICATION_ORIGIN") - - -@pytest.fixture -def voice_origin(): - return os.getenv("VOICE_ORIGIN") - - @pytest.fixture def templates_origin(): return os.getenv("TEMPLATES_ORIGIN") @@ -131,21 +111,10 @@ def disable_ssl(): return os.getenv("DISABLE_SSL") -@pytest.fixture -def application_key(): - return os.getenv("APPLICATION_KEY") - - -@pytest.fixture -def application_secret(): - return os.getenv("APPLICATION_SECRET") - - @pytest.fixture def service_plan_id(): return os.getenv("SERVICE_PLAN_ID") - @pytest.fixture def http_response(): return HTTPResponse( @@ -189,56 +158,20 @@ def token_based_pagination_request_data(): @pytest.fixture -def first_token_based_pagination_response(): - return TokenBasedPaginationResponse( - pig_dogs=["Walaszek", "Połać"], - next_page_token="za30%wsze" - ) - - -@pytest.fixture -def second_token_based_pagination_response(): - return TokenBasedPaginationResponse( - pig_dogs=["Bartosz", "Piotr"], - next_page_token="" - ) - - -@pytest.fixture -def int_based_pagination_request_data(): - return IntBasedPaginationRequest( +def sms_pagination_request_data(): + return ListDeliveryReportsRequest( page=0, page_size=2 ) -@pytest.fixture -def first_int_based_pagination_response(): - return IntBasedPaginationResponse( - count=4, - page=0, - page_size=2, - pig_dogs=["Bartosz", "Piotr"] - ) - - -@pytest.fixture -def second_int_based_pagination_response(): - return IntBasedPaginationResponse( - count=4, - page=1, - page_size=2, - pig_dogs=["Walaszek", "Połać"] - ) - - @pytest.fixture def third_int_based_pagination_response(): - return IntBasedPaginationResponse( + return ListDeliveryReportsResponse( count=4, page=2, - page_size=0, - pig_dogs=[] + page_size=2, + delivery_reports=[] ) @@ -246,67 +179,176 @@ def third_int_based_pagination_response(): def sinch_client_sync( key_id, key_secret, - application_key, - application_secret, numbers_origin, conversation_origin, templates_origin, auth_origin, sms_origin, - verification_origin, - voice_origin, - disable_ssl, project_id ): return configure_origin( SinchClient( key_id=key_id, key_secret=key_secret, - project_id=project_id, - application_key=application_key, - application_secret=application_secret + project_id=project_id ), numbers_origin, conversation_origin, templates_origin, auth_origin, - sms_origin, - verification_origin, - voice_origin, - disable_ssl + sms_origin ) @pytest.fixture -def sinch_client_async( - key_id, - key_secret, - application_key, - application_secret, - numbers_origin, - conversation_origin, - templates_origin, - auth_origin, - sms_origin, - verification_origin, - voice_origin, - disable_ssl, - project_id -): - return configure_origin( - SinchClientAsync( - key_id=key_id, - key_secret=key_secret, - project_id=project_id, - application_key=application_key, - application_secret=application_secret - ), - numbers_origin, - conversation_origin, - templates_origin, - auth_origin, - sms_origin, - verification_origin, - voice_origin, - disable_ssl - ) \ No newline at end of file +def mock_sinch_client_numbers(): + class MockConfiguration: + numbers_origin = "https://mock-numbers-api.sinch.com" + project_id = "test_project_id" + transport = MagicMock() + transport.request = MagicMock() + + class MockSinchClient: + configuration = MockConfiguration() + + return MockSinchClient() + + +@pytest.fixture +def mock_sinch_client_number_lookup(): + class MockConfiguration: + number_lookup_origin = "https://lookup.api.sinch.com" + project_id = "test_project_id" + transport = MagicMock() + transport.request = MagicMock() + + class MockSinchClient: + configuration = MockConfiguration() + + return MockSinchClient() + + +def _create_mock_sinch_client(**config_kwargs): + """ + Helper function to create a mock Sinch client with the given configuration. + """ + from sinch.core.clients.sinch_client_configuration import Configuration + from sinch.core.ports.http_transport import HTTPTransport + from sinch.core.token_manager import TokenManager + + mock_transport = MagicMock(spec=HTTPTransport) + mock_transport.request = MagicMock() + + mock_token_manager = MagicMock(spec=TokenManager) + + default_config = { + "transport": mock_transport, + "token_manager": mock_token_manager, + "project_id": "test_project_id", + "key_id": "test_key_id", + "key_secret": "test_key_secret", + } + default_config.update(config_kwargs) + + config = Configuration(**default_config) + config._authentication_method = "project_auth" + + class MockSinchClient: + configuration = config + + return MockSinchClient() + + +@pytest.fixture +def mock_sinch_client_sms(): + return _create_mock_sinch_client( + service_plan_id="test_service_plan_id", + sms_region="eu" + ) + + +@pytest.fixture +def mock_sinch_client_conversation(): + return _create_mock_sinch_client( + conversation_region="us" + ) + + +@pytest.fixture +def mock_pagination_active_number_responses(): + return [ + Mock(content=[ActiveNumber(phone_number="+12345678901"), + ActiveNumber(phone_number="+12345678902")], + next_page_token="token_1"), + Mock(content=[ActiveNumber(phone_number="+12345678903"), + ActiveNumber(phone_number="+12345678904")], + next_page_token="token_2"), + Mock(content=[ActiveNumber(phone_number="+12345678905")], + next_page_token=None) + ] + + +@pytest.fixture +def mock_pagination_expected_phone_numbers_response(): + return [ + "+12345678901", "+12345678902", "+12345678903", "+12345678904", "+12345678905" + ] + + +@pytest.fixture +def mock_sms_pagination_responses(): + from datetime import datetime + from sinch.domains.sms.models.v1.response import RecipientDeliveryReport + + return [ + Mock(content=[ + RecipientDeliveryReport( + at=parse_iso_datetime("2025-10-19T16:45:31.935Z"), + batch_id="01K7YNS82JMYGAKAATHFP0QTB5", + code=400, + recipient="12346836075", + status="DELIVERED", + type="recipient_delivery_report_sms" + ), + RecipientDeliveryReport( + at=parse_iso_datetime("2025-10-19T16:40:26.855Z"), + batch_id="01K7YNFY30DS2KKVQZVBFANHMR", + code=400, + recipient="12346836075", + status="DELIVERED", + type="recipient_delivery_report_sms" + ) + ], + count=4, page=0, page_size=2), + Mock(content=[ + RecipientDeliveryReport( + at=parse_iso_datetime("2025-10-19T16:35:15.123Z"), + batch_id="01K7YNGZ45XW8KKPQRSTUVWXYZ", + code=401, + recipient="34683607595", + status="DISPATCHED", + type="recipient_delivery_report_sms" + ), + RecipientDeliveryReport( + at=parse_iso_datetime("2025-10-19T16:30:10.456Z"), + batch_id="01K7YNHM67YZ3LMNOPQRSTUVWX", + code=402, + recipient="34683607596", + status="FAILED", + type="recipient_delivery_report_sms" + ) + ], + count=4, page=1, page_size=2), + Mock(content=[], + count=4, page=2, page_size=2) + ] + + +@pytest.fixture +def mock_int_pagination_expected_delivery_reports(): + return [ + "01K7YNS82JMYGAKAATHFP0QTB5", + "01K7YNFY30DS2KKVQZVBFANHMR", + "01K7YNGZ45XW8KKPQRSTUVWXYZ", + "01K7YNHM67YZ3LMNOPQRSTUVWX" + ] diff --git a/tests/e2e/conversation/features/environment.py b/tests/e2e/conversation/features/environment.py new file mode 100644 index 00000000..db663960 --- /dev/null +++ b/tests/e2e/conversation/features/environment.py @@ -0,0 +1,6 @@ +from tests.e2e.shared_config import create_test_client + + +def before_all(context): + """Initializes the Sinch client""" + context.sinch = create_test_client() diff --git a/tests/e2e/conversation/features/steps/conversation.steps.py b/tests/e2e/conversation/features/steps/conversation.steps.py new file mode 100644 index 00000000..c83b95a5 --- /dev/null +++ b/tests/e2e/conversation/features/steps/conversation.steps.py @@ -0,0 +1,210 @@ +from datetime import datetime, timezone +from behave import given, when, then +from sinch.domains.conversation.api.v1.messages_apis import Messages + + +@given('the Conversation service "Messages" is available') +def step_service_is_available(context): + assert hasattr(context, 'sinch') and context.sinch, 'Sinch client was not initialized' + assert isinstance(context.sinch.conversation.messages, Messages), 'Messages service is not available' + context.messages = context.sinch.conversation.messages + + +@when('I send a request to delete a message') +def step_delete_message(context): + context.delete_message_response = context.messages.delete( + message_id='01W4FFL35P4NC4K35MESSAGE001' + ) + + +@then('the delete message response contains no data') +def step_validate_delete_message_response(context): + assert context.delete_message_response is None, 'Delete message response should be None' + + +@when('I send a request to retrieve a message') +def step_retrieve_message(context): + context.message = context.messages.get( + message_id='01W4FFL35P4NC4K35MESSAGE001' + ) + + +@then('the response contains the message details') +def step_validate_message_details(context): + message = context.message + assert message is not None, 'Message should not be None' + assert message.id == '01W4FFL35P4NC4K35MESSAGE001', f'Expected message.id to be "01W4FFL35P4NC4K35MESSAGE001", got "{message.id}"' + assert message.direction == 'TO_CONTACT', f'Expected message.direction to be "TO_CONTACT", got "{message.direction}"' + assert message.conversation_id == '01W4FFL35P4NC4K35CONVERSATI', f'Expected message.conversation_id to be "01W4FFL35P4NC4K35CONVERSATI", got "{message.conversation_id}"' + assert message.contact_id == '01W4FFL35P4NC4K35CONTACT001', f'Expected message.contact_id to be "01W4FFL35P4NC4K35CONTACT001", got "{message.contact_id}"' + assert message.metadata == '', f'Expected message.metadata to be "", got "{message.metadata}"' + + expected_accept_time = datetime(2024, 6, 6, 12, 42, 42, tzinfo=timezone.utc) + assert message.accept_time == expected_accept_time, f'Expected message.accept_time to be {expected_accept_time}, got {message.accept_time}' + + assert message.processing_mode == 'CONVERSATION', f'Expected message.processing_mode to be "CONVERSATION", got "{message.processing_mode}"' + assert message.injected is False, f'Expected message.injected to be False, got {message.injected}' + + assert message.channel_identity is not None, 'Message channel_identity should not be None' + assert message.channel_identity.channel == 'SMS', f'Expected channel_identity.channel to be "SMS", got "{message.channel_identity.channel}"' + assert message.channel_identity.identity == '12015555555', f'Expected channel_identity.identity to be "12015555555", got "{message.channel_identity.identity}"' + assert message.channel_identity.app_id == '', f'Expected channel_identity.app_id to be "", got "{message.channel_identity.app_id}"' + + +@when('I send a request to update a message') +def step_update_message(context): + context.update_message_response = context.messages.update( + message_id='01W4FFL35P4NC4K35MESSAGE001', + metadata='Updated metadata' + ) + + +@then('the response contains the message details with updated metadata') +def step_validate_update_message_response(context): + message = context.update_message_response + assert message is not None, 'Update message response should not be None' + assert message.id == '01W4FFL35P4NC4K35MESSAGE001', f'Expected message.id to be "01W4FFL35P4NC4K35MESSAGE001", got "{message.id}"' + assert message.metadata == 'Updated metadata', f'Expected message.metadata to be "Updated metadata", got "{message.metadata}"' + + +@when('I send a request to send a message to a contact') +def step_send_message(context): + context.message_response = context.messages.send_text_message( + app_id='01W4FFL35P4NC4K35CONVAPP001', + text='Hello', + contact_id='01W4FFL35P4NC4K35CONTACT001' + ) + + +@then('the response contains the id of the message') +def step_validate_send_message_response(context): + assert context.message_response is not None, 'Message response should not be None' + assert hasattr(context.message_response, 'message_id'), 'Message response should have message_id attribute' + assert context.message_response.message_id == '01W4FFL35P4NC4K35MESSAGE001', f'Expected message_id to be "01W4FFL35P4NC4K35MESSAGE001", got "{context.message_response.message_id}"' + + +@when('I send a request to list the existing messages') +def step_list_messages(context): + context.list_response = context.messages.list(page_size=2) + + +@then('the response contains "{count}" messages') +def step_validate_message_count(context, count): + expected_messages_count = int(count) + assert len(context.list_response.content()) == expected_messages_count, ( + f'Expected {expected_messages_count} messages, got {len(context.list_response.content())}' + ) + + +@when('I send a request to list all the messages') +def step_list_all_messages(context): + """List all messages using iterator""" + response = context.messages.list(page_size=2) + messages_list = [] + + for message in response.iterator(): + messages_list.append(message) + + context.messages_list = messages_list + + +@then('the messages list contains "{count}" messages') +def step_validate_total_message_count(context, count): + expected_messages_count = int(count) + assert len(context.messages_list) == expected_messages_count, ( + f'Expected {expected_messages_count} messages, got {len(context.messages_list)}' + ) + + +@when('I iterate manually over the messages pages') +def step_iterate_messages_pages(context): + """Manually iterate over messages pages""" + context.list_response = context.messages.list( + page_size=2, + ) + + context.messages_list = [] + context.pages_iteration = 0 + reached_end_of_pages = False + + while not reached_end_of_pages: + context.messages_list.extend(context.list_response.content()) + context.pages_iteration += 1 + if context.list_response.has_next_page: + context.list_response = context.list_response.next_page() + else: + reached_end_of_pages = True + + +@then('the result contains the data from "{count}" pages') +def step_validate_page_count(context, count): + expected_pages_count = int(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): + context.list_response = context.messages.list_last_messages_by_channel_identity( + channel_identities=['12015555555', '12017777777', '7504610123456789'], + messages_source='CONVERSATION_SOURCE', + page_size=2, + ) + + +@then('the response contains "{count}" last messages sent to specified channel identities') +def step_validate_last_messages_count(context, count): + expected_count = int(count) + assert len(context.list_response.content()) == expected_count, ( + f'Expected {expected_count} last messages, got {len(context.list_response.content())}' + ) + + +@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): + """List all last messages by channel identity using iterator""" + response = context.messages.list_last_messages_by_channel_identity( + channel_identities=['12015555555', '12017777777', '7504610123456789'], + messages_source='CONVERSATION_SOURCE', + page_size=2, + ) + messages_list = [] + for message in response.iterator(): + messages_list.append(message) + context.messages_list = messages_list + + +@then('the response list contains "{count}" last messages sent to specified channel identities') +def step_validate_response_list_count(context, count): + expected_count = int(count) + assert len(context.messages_list) == expected_count, ( + f'Expected {expected_count} last messages, got {len(context.messages_list)}' + ) + + +@when('I iterate manually over the last messages sent to specified channel identities pages') +def step_iterate_last_messages_pages(context): + context.list_response = context.messages.list_last_messages_by_channel_identity( + channel_identities=['12015555555', '12017777777', '7504610123456789'], + messages_source='CONVERSATION_SOURCE', + page_size=2, + ) + context.messages_list = [] + context.pages_iteration = 0 + reached_end_of_pages = False + while not reached_end_of_pages: + context.messages_list.extend(context.list_response.content()) + context.pages_iteration += 1 + if context.list_response.has_next_page: + context.list_response = context.list_response.next_page() + else: + reached_end_of_pages = True + + +@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): + expected_pages_count = int(count) + assert context.pages_iteration == expected_pages_count, ( + f'Expected {expected_pages_count} pages, got {context.pages_iteration}' + ) 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..b4f95085 --- /dev/null +++ b/tests/e2e/conversation/features/steps/webhooks-events.steps.py @@ -0,0 +1,382 @@ +import requests +from behave import given, when, then +from sinch.domains.conversation.sinch_events.v1 import ConversationSinchEvent +from sinch.domains.conversation.models.v1.sinch_events import ( + MessageDeliveryReceiptEvent, + MessageInboundEvent, + MessageSubmitEvent, +) +from tests.e2e.helpers import has_key_or_attr, store_webhook_response + + +APP_SECRET = "CactusKnight_SurfsWaves" + + +def process_event(context, response): + store_webhook_response(context, response) + context.event = context.conversation_sinch_events.parse_event(context.raw_event) + + +def _fetch_and_process(context, path_suffix): + base_url = context.sinch.configuration.conversation_origin + url = f"{base_url}/webhooks/conversation/{path_suffix}" + response = requests.get(url) + process_event(context, response) + + +@given("the Conversation Webhooks handler is available") +def step_conversation_webhooks_available(context): + context.sinch.configuration.auth_origin = "http://localhost:3014" + context.sinch.configuration.conversation_origin = "http://localhost:3014" + context.conversation_sinch_events = ConversationSinchEvent(APP_SECRET) + + +# --- CAPABILITY --- +@when('I send a request to trigger a "CAPABILITY" event') +def step_trigger_capability(context): + pass + + +# TODO: Refactor to parameterized step to avoid duplication. +@then('the header of the Conversation event "CAPABILITY" contains a valid signature') +def step_signature_valid_capability(context): + pass + + +@then('the Conversation event describes a "CAPABILITY" event type') +def step_describes_capability_event_type(context): + pass + + +# --- CONTACT_CREATE --- +@when('I send a request to trigger a "CONTACT_CREATE" event') +def step_trigger_contact_create(context): + pass + + +@then('the header of the Conversation event "CONTACT_CREATE" contains a valid signature') +def step_signature_valid_contact_create(context): + pass + + +@then('the Conversation event describes a "CONTACT_CREATE" event type') +def step_describes_contact_create_event_type(context): + pass + + +# --- CONTACT_DELETE --- +@when('I send a request to trigger a "CONTACT_DELETE" event') +def step_trigger_contact_delete(context): + pass + + +@then('the header of the Conversation event "CONTACT_DELETE" contains a valid signature') +def step_signature_valid_contact_delete(context): + pass + + +@then('the Conversation event describes a "CONTACT_DELETE" event type') +def step_describes_contact_delete_event_type(context): + pass + + +# --- CONTACT_MERGE --- +@when('I send a request to trigger a "CONTACT_MERGE" event') +def step_trigger_contact_merge(context): + pass + + +@then('the header of the Conversation event "CONTACT_MERGE" contains a valid signature') +def step_signature_valid_contact_merge(context): + pass + + +@then('the Conversation event describes a "CONTACT_MERGE" event type') +def step_describes_contact_merge_event_type(context): + pass + + +# --- CONTACT_UPDATE --- +@when('I send a request to trigger a "CONTACT_UPDATE" event') +def step_trigger_contact_update(context): + pass + + +@then('the header of the Conversation event "CONTACT_UPDATE" contains a valid signature') +def step_signature_valid_contact_update(context): + pass + + +@then('the Conversation event describes a "CONTACT_UPDATE" event type') +def step_describes_contact_update_event_type(context): + pass + + +# --- CONVERSATION_DELETE --- +@when('I send a request to trigger a "CONVERSATION_DELETE" event') +def step_trigger_conversation_delete(context): + pass + + +@then('the header of the Conversation event "CONVERSATION_DELETE" contains a valid signature') +def step_signature_valid_conversation_delete(context): + pass + + +@then('the Conversation event describes a "CONVERSATION_DELETE" event type') +def step_describes_conversation_delete_event_type(context): + pass + + +# --- CONVERSATION_START --- +@when('I send a request to trigger a "CONVERSATION_START" event') +def step_trigger_conversation_start(context): + pass + + +@then('the header of the Conversation event "CONVERSATION_START" contains a valid signature') +def step_signature_valid_conversation_start(context): + pass + + +@then('the Conversation event describes a "CONVERSATION_START" event type') +def step_describes_conversation_start_event_type(context): + pass + + +# --- CONVERSATION_STOP --- +@when('I send a request to trigger a "CONVERSATION_STOP" event') +def step_trigger_conversation_stop(context): + pass + + +@then('the header of the Conversation event "CONVERSATION_STOP" contains a valid signature') +def step_signature_valid_conversation_stop(context): + pass + + +@then('the Conversation event describes a "CONVERSATION_STOP" event type') +def step_describes_conversation_stop_event_type(context): + pass + + +# --- EVENT_DELIVERY (FAILED) --- +@when('I send a request to trigger a "EVENT_DELIVERY" event with a "FAILED" status') +def step_trigger_event_delivery_failed(context): + _fetch_and_process(context, "event-delivery-report/failed") + + +@then('the header of the Conversation event "EVENT_DELIVERY" with a "FAILED" status contains a valid signature') +def step_signature_valid_event_delivery_failed(context): + assert context.conversation_sinch_events.validate_authentication_header( + context.webhook_headers, context.raw_event + ), "Signature validation failed for event EVENT_DELIVERY with status FAILED" + + +@then('the Conversation event describes a "EVENT_DELIVERY" event type') +def step_describes_event_delivery_event_type(context): + pass + + +@then("the Conversation event describes a FAILED event delivery status and its reason") +def step_check_failed_event_delivery_reason(context): + pass + + +# --- EVENT_DELIVERY (DELIVERED) --- +@when('I send a request to trigger a "EVENT_DELIVERY" event with a "DELIVERED" status') +def step_trigger_event_delivery_delivered(context): + _fetch_and_process(context, "event-delivery-report/succeeded") + + +@then('the header of the Conversation event "EVENT_DELIVERY" with a "DELIVERED" status contains a valid signature') +def step_signature_valid_event_delivery_delivered(context): + assert context.conversation_sinch_events.validate_authentication_header( + context.webhook_headers, context.raw_event + ), "Signature validation failed for event EVENT_DELIVERY with status DELIVERED" + + +# --- EVENT_INBOUND --- +@when('I send a request to trigger a "EVENT_INBOUND" event') +def step_trigger_event_inbound(context): + pass + + +@then('the header of the Conversation event "EVENT_INBOUND" contains a valid signature') +def step_signature_valid_event_inbound(context): + pass + + +@then('the Conversation event describes a "EVENT_INBOUND" event type') +def step_describes_event_inbound_event_type(context): + pass + + +# --- MESSAGE_DELIVERY (FAILED) --- +@when('I send a request to trigger a "MESSAGE_DELIVERY" event with a "FAILED" status') +def step_trigger_message_delivery_failed(context): + _fetch_and_process(context, "message-delivery-report/failed") + + +@then('the header of the Conversation event "MESSAGE_DELIVERY" with a "FAILED" status contains a valid signature') +def step_signature_valid_message_delivery_failed(context): + assert context.conversation_sinch_events.validate_authentication_header( + context.webhook_headers, context.raw_event + ), "Signature validation failed for event MESSAGE_DELIVERY with status FAILED" + + +@then('the Conversation event describes a "MESSAGE_DELIVERY" event type') +def step_describes_message_delivery_event_type(context): + event = context.event + assert isinstance(event, MessageDeliveryReceiptEvent), ( + f"Expected MessageDeliveryReceiptEvent, got {type(event)}" + ) + assert event.message_delivery_report is not None, "message_delivery_report must be present" + + +@then("the Conversation event describes a FAILED message delivery status and its reason") +def step_check_failed_message_delivery_reason(context): + message_delivery_report = context.event.message_delivery_report + assert message_delivery_report is not None, "message_delivery_report is missing" + assert message_delivery_report.status == "FAILED", ( + f"Expected status 'FAILED', got {message_delivery_report.status!r}" + ) + assert message_delivery_report.reason is not None, "reason is missing for FAILED delivery" + assert message_delivery_report.reason.code == "RECIPIENT_NOT_REACHABLE", ( + f"Expected reason code 'RECIPIENT_NOT_REACHABLE', got {message_delivery_report.reason.code!r}" + ) + + +# --- MESSAGE_DELIVERY (QUEUED_ON_CHANNEL) --- +@when('I send a request to trigger a "MESSAGE_DELIVERY" event with a "QUEUED_ON_CHANNEL" status') +def step_trigger_message_delivery_queued(context): + _fetch_and_process(context, "message-delivery-report/succeeded") + + +@then('the header of the Conversation event "MESSAGE_DELIVERY" with a "QUEUED_ON_CHANNEL" status contains a valid signature') +def step_signature_valid_message_delivery_queued(context): + assert context.conversation_sinch_events.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_sinch_events.validate_authentication_header( + context.webhook_headers, context.raw_event + ), "Signature validation failed for event MESSAGE_INBOUND" + + +@then('the Conversation event describes a "MESSAGE_INBOUND" event type') +def step_describes_message_inbound_event_type(context): + event = context.event + assert isinstance(event, MessageInboundEvent), ( + f"Expected MessageInboundEvent, got {type(event)}" + ) + assert event.message is not None, "message must be present" + + +# --- MESSAGE_INBOUND_SMART_CONVERSATION_REDACTION --- +@when('I send a request to trigger a "MESSAGE_INBOUND_SMART_CONVERSATION_REDACTION" event') +def step_trigger_message_inbound_smart_conversation_redaction(context): + pass + + +@then('the header of the Conversation event "MESSAGE_INBOUND_SMART_CONVERSATION_REDACTION" contains a valid signature') +def step_signature_valid_message_inbound_smart_conversation_redaction(context): + pass + + +@then('the Conversation event describes a "MESSAGE_INBOUND_SMART_CONVERSATION_REDACTION" event type') +def step_describes_message_inbound_smart_conversation_redaction_event_type(context): + pass + + +# --- MESSAGE_SUBMIT (media) --- +@when('I send a request to trigger a "MESSAGE_SUBMIT" event for a "media" message') +def step_trigger_message_submit_media(context): + _fetch_and_process(context, "message-submit/media") + + +@then('the header of the Conversation event "MESSAGE_SUBMIT" for a "media" message contains a valid signature') +def step_signature_valid_message_submit_media(context): + assert context.conversation_sinch_events.validate_authentication_header( + context.webhook_headers, context.raw_event + ), "Signature validation failed for event MESSAGE_SUBMIT for media message" + + +@then('the Conversation event describes a "MESSAGE_SUBMIT" event type for a "media" message') +def step_check_message_submit_media(context): + message_submit_event = context.event + assert isinstance(message_submit_event, MessageSubmitEvent), ( + f"Expected MessageSubmitEvent, got {type(message_submit_event)}" + ) + assert message_submit_event.message_submit_notification is not None + submitted = message_submit_event.message_submit_notification.submitted_message + assert has_key_or_attr(submitted, "media_message"), ( + "Expected submitted_message.media_message to be present" + ) + + +# --- MESSAGE_SUBMIT (text) --- +@when('I send a request to trigger a "MESSAGE_SUBMIT" event for a "text" message') +def step_trigger_message_submit_text(context): + _fetch_and_process(context, "message-submit/text") + + +@then('the header of the Conversation event "MESSAGE_SUBMIT" for a "text" message contains a valid signature') +def step_signature_valid_message_submit_text(context): + assert context.conversation_sinch_events.validate_authentication_header( + context.webhook_headers, context.raw_event + ), "Signature validation failed for event MESSAGE_SUBMIT for text message" + + +@then('the Conversation event describes a "MESSAGE_SUBMIT" event type for a "text" message') +def step_check_message_submit_text(context): + message_submit_event = context.event + assert isinstance(message_submit_event, MessageSubmitEvent), ( + f"Expected MessageSubmitEvent, got {type(message_submit_event)}" + ) + assert message_submit_event.message_submit_notification is not None + submitted = message_submit_event.message_submit_notification.submitted_message + assert has_key_or_attr(submitted, "text_message"), ( + "Expected submitted_message.text_message to be present" + ) + + +# --- SMART_CONVERSATIONS (media) --- +@when('I send a request to trigger a "SMART_CONVERSATIONS" event for a "media" message') +def step_trigger_smart_conversations_media(context): + pass + + +@then('the header of the Conversation event "SMART_CONVERSATIONS" for a "media" message contains a valid signature') +def step_signature_valid_smart_conversations_media(context): + pass + + +@then('the Conversation event describes a "SMART_CONVERSATIONS" event type for a "media" message') +def step_check_smart_conversations_media(context): + pass + + +# --- SMART_CONVERSATIONS (text) --- +@when('I send a request to trigger a "SMART_CONVERSATIONS" event for a "text" message') +def step_trigger_smart_conversations_text(context): + pass + + +@then('the header of the Conversation event "SMART_CONVERSATIONS" for a "text" message contains a valid signature') +def step_signature_valid_smart_conversations_text(context): + pass + + +@then('the Conversation event describes a "SMART_CONVERSATIONS" event type for a "text" message') +def step_check_smart_conversations_text(context): + pass diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py new file mode 100644 index 00000000..0f20e188 --- /dev/null +++ b/tests/e2e/helpers.py @@ -0,0 +1,16 @@ +""" +Common utility helpers for E2E tests, shared across domains. +""" + + +def store_webhook_response(context, response): + context.webhook_headers = dict(response.headers) + context.raw_event = response.text + + +def has_key_or_attr(obj, key): + if obj is None: + return False + if isinstance(obj, dict): + return key in obj and obj[key] is not None + return getattr(obj, key, None) is not None diff --git a/tests/e2e/number-lookup/features/environment.py b/tests/e2e/number-lookup/features/environment.py new file mode 100644 index 00000000..db663960 --- /dev/null +++ b/tests/e2e/number-lookup/features/environment.py @@ -0,0 +1,6 @@ +from tests.e2e.shared_config import create_test_client + + +def before_all(context): + """Initializes the Sinch client""" + context.sinch = create_test_client() diff --git a/tests/e2e/number-lookup/features/lookups.feature b/tests/e2e/number-lookup/features/lookups.feature new file mode 100644 index 00000000..b43f9ccb --- /dev/null +++ b/tests/e2e/number-lookup/features/lookups.feature @@ -0,0 +1,13 @@ +Feature: [Number Lookup] + E2E test for Number Lookup API + + Background: + Given the Number Lookup service is available + + Scenario: [Lookup] lookup for a phone number with no additional features + When I send a request to lookup for a phone number with no additional features + Then the response contains the details of the phone number lookup with line details only + + Scenario: [Lookup] lookup for a phone number with all the features + When I send a request to lookup for a phone number with all the features + Then the response contains the details of the phone number lookup with all the features diff --git a/tests/e2e/number-lookup/features/steps/lookups.steps.py b/tests/e2e/number-lookup/features/steps/lookups.steps.py new file mode 100644 index 00000000..e8d6f6e8 --- /dev/null +++ b/tests/e2e/number-lookup/features/steps/lookups.steps.py @@ -0,0 +1,81 @@ +from datetime import datetime, timezone +from behave import given, when, then +from sinch.domains.number_lookup.api.v1.number_lookup_apis import NumberLookup +from sinch.domains.number_lookup.models.v1.response import LookupNumberResponse + + +@given('the Number Lookup service is available') +def step_service_is_available(context): + assert hasattr(context, 'sinch') and context.sinch, 'Sinch client was not initialized' + assert isinstance(context.sinch.number_lookup, NumberLookup), 'Number Lookup service is not available' + context.number_lookup = context.sinch.number_lookup + + +@when('I send a request to lookup for a phone number with no additional features') +def step_lookup_number_no_features(context): + context.response = context.number_lookup.lookup( + number='+12016666666' + ) + + +@then('the response contains the details of the phone number lookup with line details only') +def step_validate_lookup_line_only(context): + data: LookupNumberResponse = context.response + assert data.number == '+12016666666' + assert data.country_code == 'US' + assert data.trace_id == '84c1fd4063c38d9f3900d06e56542d48' + assert data.line.carrier == 'T-Mobile USA' + assert data.line.type == 'Mobile' + assert data.line.mobile_country_code == '310' + assert data.line.mobile_network_code == '260' + assert data.line.ported is None + assert data.line.porting_date is None + assert data.line.error is None + assert data.sim_swap is None + assert data.voip_detection is None + assert data.rnd is None + + +@when('I send a request to lookup for a phone number with all the features') +def step_lookup_number_all_features(context): + context.response = context.number_lookup.lookup( + number='+12015555555', + features=['LineType', 'RND', 'SimSwap', 'VoIPDetection'], + rnd_feature_options={'contactDate': '2025-09-09'} + ) + + +@then('the response contains the details of the phone number lookup with all the features') +def step_validate_lookup_all_features(context): + data: LookupNumberResponse = context.response + assert data.number == '+12015555555' + assert data.country_code == 'US' + assert data.trace_id == '5c817a6b7351d80a6b1d8007e5c145b8' + + assert data.line is not None + assert data.line.carrier == 'AT&T' + assert data.line.type == 'Mobile' + assert data.line.mobile_country_code == '310' + assert data.line.mobile_network_code == '070' + assert data.line.ported is True + assert data.line.porting_date == datetime(2010, 8, 7, 23, 45, 49, tzinfo=timezone.utc) + assert data.line.error is None + + assert data.sim_swap.swapped is None + assert data.sim_swap.swap_period is None + assert data.sim_swap.error.status == 100 + assert data.sim_swap.error.title == 'Feature Disabled' + assert data.sim_swap.error.detail == 'SimSwap feature is currently disabled.' + + assert data.voip_detection is not None + assert data.voip_detection.probability is None + assert data.voip_detection.error.status == 100 + assert data.voip_detection.error.title == 'Feature Disabled' + assert data.voip_detection.error.detail == 'VoIPDetection feature is currently disabled.' + + assert data.rnd is not None + assert data.rnd.disconnected is None + assert data.rnd.error is not None + assert data.rnd.error.status == 100 + assert data.rnd.error.title == 'Feature Disabled' + assert data.rnd.error.detail == 'RND feature is currently disabled.' diff --git a/tests/e2e/numbers/features/environment.py b/tests/e2e/numbers/features/environment.py new file mode 100644 index 00000000..db663960 --- /dev/null +++ b/tests/e2e/numbers/features/environment.py @@ -0,0 +1,6 @@ +from tests.e2e.shared_config import create_test_client + + +def before_all(context): + """Initializes the Sinch client""" + context.sinch = create_test_client() diff --git a/tests/e2e/numbers/features/steps/available-regions.steps.py b/tests/e2e/numbers/features/steps/available-regions.steps.py new file mode 100644 index 00000000..19b1ac13 --- /dev/null +++ b/tests/e2e/numbers/features/steps/available-regions.steps.py @@ -0,0 +1,60 @@ +from behave import given, when, then +from sinch.domains.numbers.virtual_numbers import VirtualNumbers + + +def count_region_type(regions, number_types): + """Count the number of regions that have a specific number type.""" + return sum(number_types in region.types for region in regions if region.types) + + +@given('the Numbers service "Regions" is available') +def step_regions_service_is_available(context): + """Ensures the Sinch client is initialized""" + assert hasattr(context, 'sinch') and context.sinch, 'Sinch client was not initialized' + assert isinstance(context.sinch.numbers, VirtualNumbers), 'Numbers service is not available' + context.numbers = context.sinch.numbers + + +@when('I send a request to list all the regions') +def step_list_all_regions(context): + response = context.numbers.regions.list() + context.response = response.content() + + +@when('I send a request to list the TOLL_FREE regions') +def step_list_toll_free_regions(context): + response = context.numbers.regions.list( + types=['TOLL_FREE'] + ) + context.response = response.content() + + +@when('I send a request to list the TOLL_FREE or MOBILE regions') +def step_list_toll_free_or_mobile_regions(context): + response = context.numbers.regions.list( + types=['TOLL_FREE', 'MOBILE'] + ) + context.response = response.content() + + +@then('the response contains "{count}" regions') +def step_check_regions_count(context, count): + assert len(context.response) == int(count), f'Expected {count}, got {len(context.response)}' + + +@then('the response contains "{count}" TOLL_FREE regions') +def step_check_toll_free_regions_count(context, count): + toll_free_count = count_region_type(context.response, 'TOLL_FREE') + assert toll_free_count == int(count), f'Expected {count}, got {toll_free_count}' + + +@then('the response contains "{count}" MOBILE regions') +def step_check_mobile_regions_count(context, count): + mobile_count = count_region_type(context.response, 'MOBILE') + assert mobile_count == int(count), f'Expected {count}, got {mobile_count}' + + +@then('the response contains "{count}" LOCAL regions') +def step_check_local_regions_count(context, count): + local_count = count_region_type(context.response, 'LOCAL') + assert local_count == int(count), f'Expected {count}, got {local_count}' diff --git a/tests/e2e/numbers/features/steps/callback-configuration.steps.py b/tests/e2e/numbers/features/steps/callback-configuration.steps.py new file mode 100644 index 00000000..b7e7624f --- /dev/null +++ b/tests/e2e/numbers/features/steps/callback-configuration.steps.py @@ -0,0 +1,47 @@ +from behave import given, when, then + +from sinch.domains.numbers.api.v1.exceptions import NumberNotFoundException +from sinch.domains.numbers.models.v1.errors import NotFoundError +from sinch.domains.numbers.virtual_numbers import VirtualNumbers + + +@given('the Numbers service "Callback Configuration" is available') +def step_callback_config_service_is_available(context): + """Ensures the Sinch client is initialized""" + assert hasattr(context, 'sinch') and context.sinch, 'Sinch client was not initialized' + assert isinstance(context.sinch.numbers, VirtualNumbers), 'Numbers service is not available' + context.numbers = context.sinch.numbers + + +@when('I send a request to retrieve the callback configuration') +def step_retrieve_callback_configuration(context): + context.response = context.numbers.event_destinations.get() + + +@then('the response contains the project\'s callback configuration') +def step_check_callback_configuration(context): + assert context.response.project_id == '12c0ffee-dada-beef-cafe-baadc0de5678' + assert context.response.hmac_secret == '0default-pass-word-*max-36characters' + + +@when('I send a request to update the callback configuration with the secret "{hmac_secret}"') +def step_update_callback_configuration(context, hmac_secret): + try: + context.response = context.numbers.event_destinations.update(hmac_secret=hmac_secret) + context.error = None + except NumberNotFoundException as e: + context.error = e + + +@then('the response contains the updated project\'s callback configuration') +def step_check_updated_callback_configuration(context): + assert context.response.project_id == '12c0ffee-dada-beef-cafe-baadc0de5678' + assert context.response.hmac_secret == 'strongPa$$PhraseWith36CharactersMax' + + +@then('the response contains an error') +def step_check_error_response(context): + data: NotFoundError = context.error.args[0] + assert data is not None + assert data.code == 404 + assert data.status == 'NOT_FOUND' diff --git a/tests/e2e/numbers/features/steps/numbers.steps.py b/tests/e2e/numbers/features/steps/numbers.steps.py new file mode 100644 index 00000000..25d7b192 --- /dev/null +++ b/tests/e2e/numbers/features/steps/numbers.steps.py @@ -0,0 +1,310 @@ +from datetime import timezone, datetime +from behave import given, when, then +from decimal import Decimal +from sinch.domains.numbers.api.v1.exceptions import NumberNotFoundException +from sinch.domains.numbers.models.v1.errors import NotFoundError +from sinch.domains.numbers.models.v1.response import ActiveNumber +from sinch.domains.numbers.virtual_numbers import VirtualNumbers + + +@given('the Numbers service is available') +def step_service_is_available(context): + """Ensures the Sinch client is initialized""" + assert hasattr(context, 'sinch') and context.sinch, 'Sinch client was not initialized' + assert isinstance(context.sinch.numbers, VirtualNumbers), 'Numbers service is not available' + context.numbers = context.sinch.numbers + + +@when('I send a request to search for available phone numbers') +def step_search_available_numbers(context): + response = context.numbers.search_for_available_numbers( + region_code='US', + number_type='LOCAL' + ) + context.response = response.content() + + +@then('the response contains "{count}" available phone numbers') +def step_check_available_numbers_count(context, count): + assert len(context.response) == int(count), \ + f'Expected {count}, got {len(context.response)}' + + +@then('a phone number contains all the expected properties') +def step_check_number_properties(context): + number = context.response[0] + assert number.phone_number == '+12013504948' + assert number.region_code == 'US' + assert number.type == 'LOCAL' + assert number.capability == ['SMS', 'VOICE'] + assert number.setup_price.currency_code == 'EUR' + assert number.setup_price.amount == Decimal('0.80') + assert number.monthly_price.currency_code == 'EUR' + assert number.monthly_price.amount == Decimal('0.80') + assert number.payment_interval_months == 1 + assert number.supporting_documentation_required is True + + +@when('I send a request to check the availability of the phone number "{phone_number}"') +def step_check_number_availability(context, phone_number): + try: + context.response = context.numbers.check_availability(phone_number) + except NumberNotFoundException as e: + context.error = e + + +@then('the response displays the phone number "{phone_number}" details') +def step_validate_number_details(context, phone_number): + data = context.response + assert data.phone_number == phone_number, f'Expected {phone_number}, got {data.phone_number}' + + +@then('the response contains an error about the number "{phone_number}" not being available') +def step_check_unavailable_number(context, phone_number): + data: NotFoundError = context.error.args[0] + assert data.code == 404 + assert data.status == 'NOT_FOUND' + assert data.details[0].resource_name == phone_number + + +@when('I send a request to rent a number with some criteria') +def step_rent_any_number(context): + context.response = context.numbers.rent_any( + region_code='US', + number_type='LOCAL', + capabilities=['SMS', 'VOICE'], + sms_configuration={ + 'service_plan_id': 'SpaceMonkeySquadron', + }, + voice_configuration={ + 'type': 'RTC', + 'app_id': 'sunshine-rain-drop-very-beautifulday' + }, + number_pattern={ + 'pattern': '7654321', + 'search_pattern': 'END' + }, + ) + + +@then('the response contains a rented phone number') +def step_validate_rented_number(context): + data: ActiveNumber = context.response + assert data.phone_number == '+12017654321' + assert data.project_id == '123c0ffee-dada-beef-cafe-baadc0de5678' + assert data.display_name == '' + assert data.region_code == 'US' + assert data.type == 'LOCAL' + assert data.capability == ['SMS', 'VOICE'] + assert data.money.currency_code == 'EUR' + assert data.money.amount == Decimal('0.80') + assert data.payment_interval_months == 1 + assert data.next_charge_date == datetime.fromisoformat( + '2024-06-06T14:42:42.022227+00:00' + ).astimezone(tz=timezone.utc) + assert data.expire_at is None + assert data.event_destination_target == '' + assert data.sms_configuration.service_plan_id == '' + assert data.sms_configuration.campaign_id == '' + assert data.sms_configuration.scheduled_provisioning.service_plan_id == 'SpaceMonkeySquadron' + assert data.sms_configuration.scheduled_provisioning.campaign_id == '' + assert data.sms_configuration.scheduled_provisioning.status == 'WAITING' + assert data.sms_configuration.scheduled_provisioning.last_updated_time == datetime.fromisoformat( + '2024-06-06T14:42:42.596223+00:00' + ).astimezone(tz=timezone.utc) + assert data.sms_configuration.scheduled_provisioning.error_codes == [] + assert data.voice_configuration.type == 'RTC' + assert data.voice_configuration.app_id == '' + assert data.voice_configuration.trunk_id == '' + assert data.voice_configuration.service_id == '' + assert data.voice_configuration.scheduled_voice_provisioning.type == 'RTC' + assert data.voice_configuration.scheduled_voice_provisioning.app_id == 'sunshine-rain-drop-very-beautifulday' + assert data.voice_configuration.scheduled_voice_provisioning.trunk_id == '' + assert data.voice_configuration.scheduled_voice_provisioning.service_id == '' + assert data.voice_configuration.scheduled_voice_provisioning.status == 'WAITING' + assert data.voice_configuration.scheduled_voice_provisioning.last_updated_time == datetime.fromisoformat( + '2024-06-06T14:42:42.604092+00:00' + ).astimezone(tz=timezone.utc) + + +@when('I send a request to rent the phone number "{phone_number}"') +def step_rent_specific_number(context, phone_number): + context.response = context.numbers.rent( + phone_number=phone_number, + sms_configuration={ + 'service_plan_id': 'SpaceMonkeySquadron', + }, + voice_configuration={ + 'app_id': 'sunshine-rain-drop-very-beautifulday' + } + ) + + +@then('the response contains this rented phone number "{phone_number}"') +def step_validate_rented_specific_number(context, phone_number): + data: ActiveNumber = context.response + assert data.phone_number == phone_number, f'Expected {phone_number}, got {data.phone_number}' + + +@when('I send a request to rent the unavailable phone number "{phone_number}"') +def step_rent_unavailable_number(context, phone_number): + try: + context.response = context.numbers.rent( + phone_number=phone_number, + sms_configuration={ + 'service_plan_id': 'SpaceMonkeySquadron', + }, + voice_configuration={ + 'app_id': 'sunshine-rain-drop-very-beautifulday' + } + ) + except NumberNotFoundException as e: + context.error = e + + +@when("I send a request to list the phone numbers") +def step_when_list_phone_numbers(context): + response = context.numbers.list( + region_code='US', + number_type='LOCAL' + ) + # Get the first page + context.response = response.content() + + +@then('the response contains "{count}" phone numbers') +def step_then_response_contains_x_phone_numbers(context, count): + assert len(context.response) == int(count), \ + f'Expected {count}, got {len(context.response)}' + + +@when("I send a request to list all the phone numbers") +def step_when_list_all_phone_numbers(context): + response = context.numbers.list( + region_code='US', + number_type='LOCAL' + ) + active_numbers_list = [] + + for number in response.iterator(): + active_numbers_list.append(number) + + context.active_numbers_list = active_numbers_list + + +@then('the phone numbers list contains "{count}" phone numbers') +def step_then_phone_numbers_list_contains_x_phone_numbers(context, count): + assert len(context.active_numbers_list) == int(count), f'Expected {count}, got {len(context.active_numbers_list)}' + phone_number1 = context.active_numbers_list[0] + assert phone_number1.voice_configuration.type == 'FAX' + assert phone_number1.voice_configuration.service_id == '01W4FFL35P4NC4K35FAXSERVICE' + phone_number2 = context.active_numbers_list[1] + assert phone_number2.voice_configuration.type == 'EST' + assert phone_number2.voice_configuration.trunk_id == '01W4FFL35P4NC4K35SIPTRUNK00' + phone_number3 = context.active_numbers_list[2] + assert phone_number3.voice_configuration.type == 'RTC' + assert phone_number3.voice_configuration.app_id == 'sunshine-rain-drop-very-beautifulday' + + +@when('I send a request to update the phone number "{phone_number}"') +def step_when_update_phone_number(context, phone_number): + context.response = context.numbers.update( + phone_number=phone_number, + display_name='Updated description during E2E tests', + sms_configuration={ + 'service_plan_id': 'SingingMooseSociety' + }, + voice_configuration={ + 'type': 'FAX', + 'service_id': '01W4FFL35P4NC4K35FAXSERVICE' + }, + event_destination_target='https://my-callback-server.com/numbers' + ) + + +@then('the response contains a phone number with updated parameters') +def step_then_response_contains_updated_number(context): + data: ActiveNumber = context.response + assert data.display_name == 'Updated description during E2E tests' + assert data.sms_configuration.service_plan_id == 'SpaceMonkeySquadron' + assert data.sms_configuration.campaign_id == '' + assert data.sms_configuration.scheduled_provisioning.service_plan_id == 'SingingMooseSociety' + assert data.sms_configuration.scheduled_provisioning.campaign_id == '' + assert data.sms_configuration.scheduled_provisioning.status == 'WAITING' + assert data.sms_configuration.scheduled_provisioning.last_updated_time == datetime.fromisoformat( + '2024-06-06T20:02:20.432220+00:00' + ).astimezone(tz=timezone.utc) + assert data.sms_configuration.scheduled_provisioning.error_codes == [] + assert data.voice_configuration.type == 'RTC' + assert data.voice_configuration.app_id == 'sunshine-rain-drop-very-beautifulday' + assert data.voice_configuration.trunk_id == '' + assert data.voice_configuration.service_id == '' + assert data.voice_configuration.scheduled_voice_provisioning.status == 'WAITING' + assert data.voice_configuration.scheduled_voice_provisioning.type == 'FAX' + assert data.voice_configuration.scheduled_voice_provisioning.app_id == '' + assert data.voice_configuration.scheduled_voice_provisioning.trunk_id == '' + assert data.voice_configuration.scheduled_voice_provisioning.service_id == '01W4FFL35P4NC4K35FAXSERVICE' + assert data.voice_configuration.scheduled_voice_provisioning.last_updated_time == datetime.fromisoformat( + '2024-06-06T20:02:20.437509+00:00' + ).astimezone(tz=timezone.utc) + assert data.event_destination_target == 'https://my-callback-server.com/numbers' + + +@when('I send a request to retrieve the phone number "{phone_number}"') +def step_when_retrieve_phone_number(context, phone_number): + try: + context.response = context.numbers.get( + phone_number=phone_number, + ) + except NumberNotFoundException as e: + context.error = e + + +@then('the response contains details about the phone number "{phone_number}"') +def step_then_response_contains_phone_details(context, phone_number): + data: ActiveNumber = context.response + assert data.phone_number == phone_number + assert data.next_charge_date == datetime.fromisoformat( + '2024-06-06T14:42:42.677575+00:00' + ).astimezone(tz=timezone.utc) + assert data.expire_at is None + assert data.sms_configuration.service_plan_id == 'SpaceMonkeySquadron' + + +@then('the response contains details about the phone number "{phone_number}" with an SMS provisioning error') +def step_then_response_contains_sms_provisioning_error(context, phone_number): + data: ActiveNumber = context.response + assert data.phone_number == phone_number + assert data.next_charge_date == datetime.fromisoformat('2024-07-06T14:42:42.677575+00:00').astimezone( + tz=timezone.utc) + assert data.expire_at is None + assert data.sms_configuration.service_plan_id == '' + assert data.sms_configuration.scheduled_provisioning.status == 'FAILED' + assert data.sms_configuration.scheduled_provisioning.error_codes == ['SMS_PROVISIONING_FAILED'] + + +@then('the response contains an error about the number "{phone_number}" not being a rented number') +def step_then_response_contains_error_not_rented(context, phone_number): + data: NotFoundError = context.error.args[0] + assert data.code == 404 + assert data.status == 'NOT_FOUND' + assert data.details[0].resource_name == phone_number + + +@when('I send a request to release the phone number "{phone_number}"') +def step_when_release_phone_number(context, phone_number): + context.response = context.numbers.release( + phone_number=phone_number + ) + + +@then('the response contains details about the phone number "{phone_number}" to be released') +def step_then_response_contains_released_number(context, phone_number): + data: ActiveNumber = context.response + assert data.phone_number == phone_number + assert data.next_charge_date == datetime.fromisoformat( + '2024-06-06T14:42:42.677575+00:00' + ).astimezone(tz=timezone.utc) + assert data.expire_at == datetime.fromisoformat( + '2024-06-06T14:42:42.677575+00:00' + ).astimezone(tz=timezone.utc) diff --git a/tests/e2e/numbers/features/steps/webhooks.steps.py b/tests/e2e/numbers/features/steps/webhooks.steps.py new file mode 100644 index 00000000..cb93cbbb --- /dev/null +++ b/tests/e2e/numbers/features/steps/webhooks.steps.py @@ -0,0 +1,38 @@ +import json +import requests +from behave import given, when, then +from tests.e2e.helpers import store_webhook_response + +SINCH_NUMBERS_CALLBACK_SECRET = 'strongPa$$PhraseWith36CharactersMax' + + +@given('the Numbers Webhooks handler is available') +def step_webhook_handler_is_available(context): + context.numbers_webhook = context.sinch.numbers.sinch_events(SINCH_NUMBERS_CALLBACK_SECRET) + + +@when('I send a request to trigger the "{status}" for "{event_type}" event') +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}') + 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.webhook_headers, context.raw_event + ), 'Signature validation failed' + + +@then('the event describes a "{status}" for "{event_type}" event') +def step_check_event_details(context, status, event_type): + assert context.event.event_type == event_type + if status == 'success': + assert context.event.status == 'SUCCEEDED' + assert context.event.failure_code is None + else: + assert context.event.status == 'FAILED' + assert context.event.failure_code == 'PROVISIONING_TO_VOICE_PLATFORM_FAILED' diff --git a/tests/e2e/shared_config.py b/tests/e2e/shared_config.py new file mode 100644 index 00000000..805e22ae --- /dev/null +++ b/tests/e2e/shared_config.py @@ -0,0 +1,17 @@ +from sinch import SinchClient + + +def create_test_client(): + """Creates a Sinch client with test configuration for all domains""" + client_params = { + 'project_id': 'tinyfrog-jump-high-over-lilypadbasin', + 'key_id': 'keyId', + 'key_secret': 'keySecret', + } + client = SinchClient(**client_params) + client.configuration.auth_origin = 'http://localhost:3011' + client.configuration.numbers_origin = 'http://localhost:3013' + client.configuration.sms_origin = 'http://localhost:3017' + client.configuration.number_lookup_origin = 'http://localhost:3022' + client.configuration.conversation_origin = 'http://localhost:3014' + return client diff --git a/tests/e2e/sms/features/environment.py b/tests/e2e/sms/features/environment.py new file mode 100644 index 00000000..db663960 --- /dev/null +++ b/tests/e2e/sms/features/environment.py @@ -0,0 +1,6 @@ +from tests.e2e.shared_config import create_test_client + + +def before_all(context): + """Initializes the Sinch client""" + context.sinch = create_test_client() diff --git a/tests/e2e/sms/features/steps/batches.steps.py b/tests/e2e/sms/features/steps/batches.steps.py new file mode 100644 index 00000000..bf59f4a0 --- /dev/null +++ b/tests/e2e/sms/features/steps/batches.steps.py @@ -0,0 +1,364 @@ +from datetime import datetime, timezone +from behave import given, when, then +from sinch.domains.sms.models.v1.types import BatchResponse +from sinch.domains.sms.models.v1.response.dry_run_response import DryRunResponse +from sinch.domains.sms.models.v1.shared.text_response import TextResponse + + +def _setup_sinch_client(context, use_service_plan_auth=False): + """Helper function to setup Sinch client""" + from sinch import SinchClient + + if use_service_plan_auth: + sinch = SinchClient( + service_plan_id='CappyPremiumPlan', + sms_api_token='HappyCappyToken', + ) + sinch.configuration.sms_origin_with_service_plan_id = 'http://localhost:3017' + else: + sinch = SinchClient( + project_id='tinyfrog-jump-high-over-lilypadbasin', + key_id='keyId', + key_secret='keySecret', + ) + + sinch.configuration.auth_origin = 'http://localhost:3011' + sinch.configuration.sms_origin = 'http://localhost:3017' + + context.sinch = sinch + context.sms = sinch.sms + + +@given('the SMS service "Batches" is available') +def step_sms_service_batches_available(context): + """Ensures the Sinch client is initialized""" + _setup_sinch_client(context, use_service_plan_auth=False) + + +@given('the SMS service "Batches" is available and is configured for servicePlanId authentication') +def step_sms_service_batches_available_with_service_plan(context): + """Ensures the Sinch client is initialized with service_plan_id authentication""" + _setup_sinch_client(context, use_service_plan_auth=True) + + +@when('I send a request to send a text message') +def step_send_text_message(context): + """Send a text message""" + context.response = context.sms.batches.send_sms( + body='SMS body message', + to=['+12017777777'], + from_='+12015555555', + send_at=datetime(2024, 6, 6, 9, 25, 0, tzinfo=timezone.utc), + delivery_report='full', + feedback_enabled=True, + ) + + +@then('the response contains the text SMS details') +def step_validate_text_sms_details(context): + """Validate text SMS response""" + data: BatchResponse = context.response + assert data.id == '01W4FFL35P4NC4K35SMSBATCH1' + assert data.to == ['12017777777'] + assert data.from_ == '12015555555' + assert data.canceled is False + assert data.body == 'SMS body message' + assert data.type == 'mt_text' + assert data.created_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + assert data.modified_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + assert data.delivery_report == 'full' + assert data.send_at == datetime(2024, 6, 6, 9, 25, 0, tzinfo=timezone.utc) + assert data.expire_at == datetime(2024, 6, 9, 9, 25, 0, tzinfo=timezone.utc) + assert data.feedback_enabled is True + assert isinstance(data, TextResponse) + assert data.flash_message is False + + +@when('I send a request to send a text message with multiple parameters') +def step_send_text_message_with_parameters(context): + """Send a text message with multiple parameters""" + context.response = context.sms.batches.send_sms( + body='Hello ${name}! Get 20% off with this discount code ${code}', + to=['+12017777777', '+12018888888'], + from_='+12015555555', + parameters={ + 'name': { + '+12017777777': 'John', + '+12018888888': 'Paul', + 'default': 'there', + }, + 'code': { + '+12017777777': 'HALLOWEEN20 🎃', + }, + }, + delivery_report='full', + ) + + +@then('the response contains the text SMS details with multiple parameters') +def step_validate_text_sms_with_parameters(context): + """Validate text SMS response with parameters""" + data: BatchResponse = context.response + assert data.id == '01W4FFL35P4NC4K35SMSBATCH2' + assert data.to == ['12017777777', '12018888888'] + assert data.from_ == '12015555555' + assert data.canceled is False + + expected_parameters = { + 'name': { + 'default': 'there', + '+12017777777': 'John', + '+12018888888': 'Paul', + }, + 'code': { + '+12017777777': 'HALLOWEEN20 🎃', + }, + } + assert data.parameters == expected_parameters + assert data.body == 'Hello ${name}! Get 20% off with this discount code ${code}' + assert data.type == 'mt_text' + assert data.created_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + assert data.modified_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + assert data.delivery_report == 'full' + assert data.expire_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + assert isinstance(data, TextResponse) + assert data.flash_message is False + + +@when('I send a request to perform a dry run of a batch') +def step_perform_dry_run(context): + """Perform a dry run of a batch""" + context.dry_run_response = context.sms.batches.dry_run_sms( + from_='+12015555555', + to=[ + '+12017777777', + '+12018888888', + '+12019999999', + ], + parameters={ + 'name': { + '+12017777777': 'John', + 'default': 'there', + }, + }, + body='Hello ${name}!', + delivery_report='none', + ) + + +@then('the response contains the calculated bodies and number of parts for all messages in the batch') +def step_validate_dry_run_response(context): + """Validate dry run response""" + data: DryRunResponse = context.dry_run_response + assert data.number_of_messages == 3 + assert data.number_of_recipients == 3 + assert data.per_recipient is not None + assert len(data.per_recipient) == 3 + + john_message = next( + (msg for msg in data.per_recipient if msg.recipient == '12017777777'), + None + ) + assert john_message is not None + assert john_message.body == 'Hello John!' + assert john_message.number_of_parts == 1 + assert john_message.encoding == 'text' + + default_message = next( + (msg for msg in data.per_recipient if msg.recipient == '12018888888'), + None + ) + assert default_message is not None + assert default_message.body == 'Hello there!' + assert default_message.number_of_parts == 1 + assert default_message.encoding == 'text' + + +@when('I send a request to list the SMS batches') +def step_list_sms_batches(context): + """List SMS batches""" + context.response = context.sms.batches.list( + page_size=2, + ) + + +@then('the response contains "{count}" SMS batches') +def step_validate_batches_count(context, count): + """Validate the count of SMS batches in response""" + expected_count = int(count) + assert len(context.response.content()) == expected_count, \ + f'Expected {expected_count}, got {len(context.response.content())}' + + +@when('I send a request to list all the SMS batches') +def step_list_all_sms_batches(context): + """List all SMS batches using iterator""" + response = context.sms.batches.list(page_size=2) + batches_list = [] + + for batch in response.iterator(): + batches_list.append(batch) + + context.batches_list = batches_list + + +@when('I iterate manually over the SMS batches pages') +def step_iterate_manually_batches(context): + """Manually iterate over SMS batches pages""" + context.list_response = context.sms.batches.list( + page_size=2, + ) + + context.batches_list = [] + context.pages_iteration = 0 + reached_end_of_pages = False + + while not reached_end_of_pages: + context.batches_list.extend(context.list_response.content()) + context.pages_iteration += 1 + if context.list_response.has_next_page: + context.list_response = context.list_response.next_page() + else: + reached_end_of_pages = True + + +@then('the SMS batches list contains "{count}" SMS batches') +def step_validate_batches_list_count(context, count): + """Validate the count of SMS batches in the full list""" + expected_count = int(count) + assert len(context.batches_list) == expected_count, \ + f'Expected {expected_count}, got {len(context.batches_list)}' + + +@then('the SMS batches iteration result contains the data from "{count}" pages') +def step_validate_batches_pages_count(context, count): + """Validate the count of pages in the iteration result""" + expected_pages_count = int(count) + assert context.pages_iteration == expected_pages_count, \ + f'Expected {expected_pages_count} pages, got {context.pages_iteration}' + + +@when('I send a request to retrieve an SMS batch') +def step_retrieve_sms_batch(context): + """Retrieve an SMS batch""" + context.batch = context.sms.batches.get( + batch_id='01W4FFL35P4NC4K35SMSBATCH1', + ) + + +@then('the response contains the SMS batch details') +def step_validate_batch_details(context): + """Validate SMS batch response""" + batch: BatchResponse = context.batch + assert batch.id == '01W4FFL35P4NC4K35SMSBATCH1' + assert batch.to == ['12017777777'] + assert batch.from_ == '12015555555' + assert batch.canceled is False + assert batch.body == 'SMS body message' + assert batch.type == 'mt_text' + assert batch.created_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + assert batch.modified_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + assert batch.delivery_report == 'full' + assert batch.send_at == datetime(2024, 6, 6, 9, 25, 0, tzinfo=timezone.utc) + assert batch.expire_at == datetime(2024, 6, 9, 9, 25, 0, tzinfo=timezone.utc) + assert batch.feedback_enabled is True + assert isinstance(batch, TextResponse) + assert batch.flash_message is False + + +@when('I send a request to update an SMS batch') +def step_update_sms_batch(context): + """Update an SMS batch""" + context.batch = context.sms.batches.update_sms( + batch_id='01W4FFL35P4NC4K35SMSBATCH1', + from_='+12016666666', + to_add=[ + '01W4FFL35P4NC4K35SMSGROUP1', + ], + delivery_report='summary', + ) + + +@then('the response contains the SMS batch details with updated data') +def step_validate_updated_batch_details(context): + """Validate updated SMS batch response""" + batch: BatchResponse = context.batch + assert batch.id == '01W4FFL35P4NC4K35SMSBATCH1' + assert batch.to == ['12017777777', '01W4FFL35P4NC4K35SMSGROUP1'] + assert batch.from_ == '12016666666' + assert batch.canceled is False + assert batch.body == 'SMS body message' + assert batch.type == 'mt_text' + assert batch.created_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + assert batch.modified_at == datetime(2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc) + assert batch.delivery_report == 'summary' + assert batch.send_at == datetime(2024, 6, 6, 9, 25, 0, tzinfo=timezone.utc) + assert batch.expire_at == datetime(2024, 6, 9, 9, 25, 0, tzinfo=timezone.utc) + assert batch.feedback_enabled is True + assert isinstance(batch, TextResponse) + assert batch.flash_message is False + + +@when('I send a request to replace an SMS batch') +def step_replace_sms_batch(context): + """Replace an SMS batch""" + context.batch = context.sms.batches.replace_sms( + batch_id='01W4FFL35P4NC4K35SMSBATCH1', + from_='+12016666666', + to=['+12018888888'], + body='This is the replacement batch', + send_at=datetime(2024, 6, 6, 9, 35, 0, tzinfo=timezone.utc), + ) + + +@then('the response contains the new SMS batch details with the provided data for replacement') +def step_validate_replaced_batch_details(context): + """Validate replaced SMS batch response""" + batch: BatchResponse = context.batch + assert batch.id == '01W4FFL35P4NC4K35SMSBATCH1' + assert batch.to == ['12018888888'] + assert batch.from_ == '12016666666' + assert batch.canceled is False + assert batch.body == 'This is the replacement batch' + assert batch.type == 'mt_text' + assert batch.created_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + assert batch.modified_at == datetime(2024, 6, 6, 9, 23, 32, 504000, tzinfo=timezone.utc) + assert batch.delivery_report == 'none' + assert batch.send_at == datetime(2024, 6, 6, 9, 35, 0, tzinfo=timezone.utc) + assert batch.expire_at == datetime(2024, 6, 9, 9, 35, 0, tzinfo=timezone.utc) + assert batch.feedback_enabled is None + assert isinstance(batch, TextResponse) + assert batch.flash_message is False + + +@when('I send a request to cancel an SMS batch') +def step_cancel_sms_batch(context): + """Cancel an SMS batch""" + context.batch = context.sms.batches.cancel( + batch_id='01W4FFL35P4NC4K35SMSBATCH1', + ) + + +@then('the response contains the SMS batch details with a cancelled status') +def step_validate_cancelled_batch(context): + """Validate cancelled SMS batch response""" + batch: BatchResponse = context.batch + assert batch.id == '01W4FFL35P4NC4K35SMSBATCH1' + assert batch.canceled is True + + +@when('I send a request to send delivery feedbacks') +def step_send_delivery_feedback(context): + """Send delivery feedback""" + context.delivery_feedback_response = context.sms.batches.send_delivery_feedback( + batch_id='01W4FFL35P4NC4K35SMSBATCH1', + recipients=[ + '+12017777777', + ], + ) + + +@then('the delivery feedback response contains no data') +def step_validate_delivery_feedback_response(context): + """Validate delivery feedback response""" + assert context.delivery_feedback_response is None diff --git a/tests/e2e/sms/features/steps/delivery_reports.steps.py b/tests/e2e/sms/features/steps/delivery_reports.steps.py new file mode 100644 index 00000000..5234723e --- /dev/null +++ b/tests/e2e/sms/features/steps/delivery_reports.steps.py @@ -0,0 +1,171 @@ +from datetime import datetime, timezone +from behave import given, when, then +from sinch.domains.sms.models.v1.response import BatchDeliveryReport, RecipientDeliveryReport +from sinch.domains.sms.sms import SMS + + +@given('the SMS service "{service_name}" is available') +def step_sms_service_available(context, service_name): + """Ensures the Sinch client is initialized""" + assert hasattr(context, 'sinch') and context.sinch, 'Sinch client was not initialized' + assert isinstance(context.sinch.sms, SMS), 'SMS service is not available' + context.sms = context.sinch.sms + + +@given('the SMS service "{service_name}" is available and is configured for servicePlanId authentication') +def step_sms_service_available_with_service_plan(context, service_name): + """Ensures the Sinch client is initialized with service_plan_id authentication""" + from sinch import SinchClient + + # Create a new client with service_plan_id authentication + context.sinch = SinchClient( + service_plan_id='CappyPremiumPlan', + sms_api_token='HappyCappyToken', + ) + context.sinch.configuration.auth_origin = 'http://localhost:3011' + context.sinch.configuration.sms_origin = 'http://localhost:3017' + context.sinch.configuration.sms_origin_with_service_plan_id = 'http://localhost:3017' + assert isinstance(context.sinch.sms, SMS), 'SMS service is not available' + context.sms = context.sinch.sms + + +@when('I send a request to retrieve a summary SMS delivery report') +def step_retrieve_summary_delivery_report(context): + """Retrieve a summary SMS delivery report""" + context.response = context.sms.delivery_reports.get( + batch_id='01W4FFL35P4NC4K35SMSBATCH1', + status=['DELIVERED', 'FAILED'], + code=[15, 0] + ) + + +@then('the response contains a summary SMS delivery report') +def step_validate_summary_delivery_report(context): + """Validate summary SMS delivery report response""" + data: BatchDeliveryReport = context.response + assert data.batch_id == '01W4FFL35P4NC4K35SMSBATCH1' + assert data.client_reference == 'reference_e2e' + assert data.statuses is not None + assert len(data.statuses) >= 2 + + status = data.statuses[0] + assert status.code == 15 + assert status.count == 1 + assert status.recipients is None + assert status.status == 'Failed' + + status = data.statuses[1] + assert status.code == 0 + assert status.count == 1 + assert status.recipients is None + assert status.status == 'Delivered' + + assert data.total_message_count == 2 + assert data.type == 'delivery_report_sms' + + +@when('I send a request to retrieve a full SMS delivery report') +def step_retrieve_full_delivery_report(context): + """Retrieve a full SMS delivery report""" + context.response = context.sms.delivery_reports.get( + batch_id='01W4FFL35P4NC4K35SMSBATCH1', + report_type='full' + ) + + +@then('the response contains a full SMS delivery report') +def step_validate_full_delivery_report(context): + """Validate full SMS delivery report response""" + data: BatchDeliveryReport = context.response + assert data.batch_id == '01W4FFL35P4NC4K35SMSBATCH1' + assert data.statuses is not None + status = data.statuses[0] + assert status.recipients is not None + assert status.code == 0 + assert status.count == 1 + assert status.recipients[0] == '12017777777' + assert status.status == 'Delivered' + + +@when('I send a request to retrieve a recipient\'s delivery report') +def step_retrieve_recipient_delivery_report(context): + """Retrieve a recipient's delivery report""" + context.response = context.sms.delivery_reports.get_for_number( + batch_id='01W4FFL35P4NC4K35SMSBATCH1', + recipient='12017777777' + ) + + +@then('the response contains the recipient\'s delivery report details') +def step_validate_recipient_delivery_report(context): + """Validate recipient delivery report response""" + data: RecipientDeliveryReport = context.response + assert data.batch_id == '01W4FFL35P4NC4K35SMSBATCH1' + assert data.recipient == '12017777777' + assert data.client_reference == 'reference_e2e' + assert data.status == 'Delivered' + assert data.type == 'recipient_delivery_report_sms' + assert data.code == 0 + assert data.at == datetime(2024, 6, 6, 13, 6, 27, 833000, tzinfo=timezone.utc) + assert data.operator_status_at == datetime(2024, 6, 6, 13, 6, 0, tzinfo=timezone.utc) + + +@when('I send a request to list the SMS delivery reports') +def step_list_delivery_reports(context): + """List a page of SMS delivery reports""" + context.response = context.sms.delivery_reports.list() + + +@then('the response contains "{count}" SMS delivery reports') +def step_validate_delivery_reports_count(context, count): + """Validate the count of SMS delivery reports in response""" + expected_count = int(count) + assert len(context.response.content()) == expected_count, \ + f'Expected {expected_count}, got {len(context.response.content())}' + + +@when('I send a request to list all the SMS delivery reports') +def step_list_all_delivery_reports(context): + """List all SMS delivery reports using iterator""" + response = context.sms.delivery_reports.list(page_size=2) + delivery_reports_list = [] + + for delivery_report in response.iterator(): + delivery_reports_list.append(delivery_report) + + context.delivery_reports_list = delivery_reports_list + + +@then('the SMS delivery reports list contains "{count}" SMS delivery reports') +def step_validate_delivery_reports_list_count(context, count): + """Validate the count of SMS delivery reports in the full list""" + expected_count = int(count) + assert len(context.delivery_reports_list) == expected_count, \ + f'Expected {expected_count}, got {len(context.delivery_reports_list)}' + + +@when('I iterate manually over the SMS delivery reports pages') +def step_iterate_manually_delivery_reports(context): + """Manually iterate over SMS delivery reports pages""" + context.list_response = context.sms.delivery_reports.list(page_size=2) + + # Iterate through all pages + context.delivery_reports_list = [] + context.pages_iteration = 0 + reached_last_page = False + + while not reached_last_page: + context.delivery_reports_list.extend(context.list_response.content()) + context.pages_iteration += 1 + if context.list_response.has_next_page: + context.list_response = context.list_response.next_page() + else: + reached_last_page = True + + +@then('the SMS delivery reports iteration result contains the data from "{count}" pages') +def step_validate_delivery_reports_pages_count(context, count): + """Validate the count of pages in the iteration result""" + expected_pages_count = int(count) + assert context.pages_iteration == expected_pages_count, \ + f'Expected {expected_pages_count} pages, got {context.pages_iteration}' diff --git a/tests/e2e/sms/features/steps/webhooks.steps.py b/tests/e2e/sms/features/steps/webhooks.steps.py new file mode 100644 index 00000000..99907fc4 --- /dev/null +++ b/tests/e2e/sms/features/steps/webhooks.steps.py @@ -0,0 +1,122 @@ +import requests +from datetime import datetime, timezone +from behave import given, when, then +from sinch.domains.sms.sinch_events.v1.sms_sinch_event import SmsSinchEvent +from sinch.domains.sms.sinch_events.v1.events import ( + MOTextSinchEvent, +) +from sinch.domains.sms.models.v1.response import ( + BatchDeliveryReport, + RecipientDeliveryReport, +) +from tests.e2e.helpers import store_webhook_response + +SINCH_SMS_SINCH_EVENT_SECRET = 'KayakingTheSwell' + + +@given('the SMS Webhooks handler is available') +def step_webhook_handler_is_available(context): + context.sms_sinch_event = SmsSinchEvent(SINCH_SMS_SINCH_EVENT_SECRET) + + +@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') + store_webhook_response(context, response) + context.event = context.sms_sinch_event.parse_event(context.raw_event) + + +@then('the header of the event "{event_type}" contains a valid signature') +@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_sinch_event.validate_authentication_header( + context.webhook_headers, context.raw_event + ), 'Signature validation failed' + + +@then('the SMS event describes an "incoming SMS" event') +def step_check_incoming_sms_event(context): + incoming_sms_event: MOTextSinchEvent = context.event + assert incoming_sms_event.id == '01W4FFL35P4NC4K35SMSBATCH8' + assert incoming_sms_event.from_ == '12015555555' + assert incoming_sms_event.to == '12017777777' + assert incoming_sms_event.body == 'Hello John! 👋' + assert incoming_sms_event.type == 'mo_text' + assert incoming_sms_event.operator_id == '311071' + expected_received_at = datetime(2024, 6, 6, 7, 52, 37, 386000, tzinfo=timezone.utc) + assert incoming_sms_event.received_at == expected_received_at + + +@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') + store_webhook_response(context, response) + context.event = context.sms_sinch_event.parse_event(context.raw_event) + + +@then('the SMS event describes an "SMS delivery report" event') +def step_check_delivery_report_event(context): + delivery_report_event: BatchDeliveryReport = context.event + assert delivery_report_event.batch_id == '01W4FFL35P4NC4K35SMSBATCH8' + assert delivery_report_event.client_reference == 'client-ref' + assert delivery_report_event.statuses is not None + assert len(delivery_report_event.statuses) > 0 + + status = delivery_report_event.statuses[0] + assert status.code == 0 + assert status.count == 2 + assert status.status == 'Delivered' + assert status.recipients is not None + assert len(status.recipients) == 2 + assert status.recipients[0] == '12017777777' + assert status.recipients[1] == '33612345678' + assert delivery_report_event.type == 'delivery_report_sms' + + +@when('I send a request to trigger an "SMS recipient delivery report" event with the status "Delivered"') +def step_send_recipient_delivery_report_event_delivered(context): + response = requests.get( + 'http://localhost:3017/webhooks/sms/recipient-delivery-report-sms-delivered' + ) + store_webhook_response(context, response) + context.event = context.sms_sinch_event.parse_event(context.raw_event) + + +@when('I send a request to trigger an "SMS recipient delivery report" event with the status "Aborted"') +def step_send_recipient_delivery_report_event_aborted(context): + response = requests.get( + 'http://localhost:3017/webhooks/sms/recipient-delivery-report-sms-aborted' + ) + store_webhook_response(context, response) + context.event = context.sms_sinch_event.parse_event(context.raw_event) + + +@then('the SMS event describes an SMS recipient delivery report event with the status "Delivered"') +def step_check_recipient_delivery_report_delivered(context): + recipient_dr_event: RecipientDeliveryReport = context.event + assert recipient_dr_event.batch_id == '01W4FFL35P4NC4K35SMSBATCH9' + assert recipient_dr_event.recipient == '12017777777' + assert recipient_dr_event.code == 0 + assert recipient_dr_event.status == 'Delivered' + assert recipient_dr_event.type == 'recipient_delivery_report_sms' + assert recipient_dr_event.client_reference == 'client-ref' + + expected_at = datetime(2024, 6, 6, 8, 17, 19, 210000, tzinfo=timezone.utc) + assert recipient_dr_event.at == expected_at + + expected_operator_status_at = datetime(2024, 6, 6, 8, 17, 0, tzinfo=timezone.utc) + assert recipient_dr_event.operator_status_at == expected_operator_status_at + + +@then('the SMS event describes an SMS recipient delivery report event with the status "Aborted"') +def step_check_recipient_delivery_report_aborted(context): + recipient_dr_event: RecipientDeliveryReport = context.event + assert recipient_dr_event.batch_id == '01W4FFL35P4NC4K35SMSBATCH9' + assert recipient_dr_event.recipient == '12010000000' + assert recipient_dr_event.code == 412 + assert recipient_dr_event.status == 'Aborted' + assert recipient_dr_event.type == 'recipient_delivery_report_sms' + assert recipient_dr_event.client_reference == 'client-ref' + + expected_at = datetime(2024, 6, 6, 8, 17, 15, 603000, tzinfo=timezone.utc) + assert recipient_dr_event.at == expected_at diff --git a/tests/unit/domains/authentication/test_authentication_validation.py b/tests/unit/domains/authentication/test_authentication_validation.py new file mode 100644 index 00000000..79cfddc5 --- /dev/null +++ b/tests/unit/domains/authentication/test_authentication_validation.py @@ -0,0 +1,49 @@ +import pytest +from sinch.domains.authentication.sinch_events.v1.authentication_validation import ( + validate_signature_header, +) + + +@pytest.fixture +def string_to_sign(): + return ( + '{"eventId":"event_id","timestamp":"2025-04-08T10:38:04.854087603",' + '"projectId":"project-id","resourceId":"+1234567890",' + '"resourceType":"ACTIVE_NUMBER","eventType":"PROVISIONING_TO_VOICE_PLATFORM",' + '"status":"SUCCEEDED","failureCode":null,"internalFailureCode":null}' + ) + + +@pytest.fixture +def secret(): + return "my-callback-secret" + + +def test_valid_signature_header_expects_successful_validation(secret, string_to_sign): + headers = { + "X-Sinch-Signature": "d2107528d5d52897a97dc6e24e09a208036ccd83" + } + validated = validate_signature_header(secret, headers, string_to_sign) + assert validated is True + + +def test_missing_signature_expects_no_validation(secret, string_to_sign): + headers = {} + validated = validate_signature_header(secret, headers, string_to_sign) + assert validated is False + + +def test_invalid_signature_expects_no_validation(secret, string_to_sign): + headers = { + "X-Sinch-Signature": "invalid-signature" + } + validated = validate_signature_header(secret, headers, string_to_sign) + assert validated is False + + +def test_None_secret_expects_no_validation(string_to_sign): + headers = { + "X-Sinch-Signature": "d2107528d5d52897a97dc6e24e09a208036ccd83" + } + validated = validate_signature_header(None, headers, string_to_sign) + assert validated is False diff --git a/tests/unit/domains/authentication/test_sinch_event_utils.py b/tests/unit/domains/authentication/test_sinch_event_utils.py new file mode 100644 index 00000000..555b6567 --- /dev/null +++ b/tests/unit/domains/authentication/test_sinch_event_utils.py @@ -0,0 +1,56 @@ +import pytest +from datetime import datetime, timezone +from sinch.domains.authentication.sinch_events.v1.sinch_event_utils import ( + parse_json, + normalize_iso_timestamp, +) + + +class TestParseJson: + def test_parse_json_expects_valid_json_string(self): + """Test parse_json with a valid JSON string.""" + json_string = '{"key": "value", "number": 123}' + result = parse_json(json_string) + assert result == {"key": "value", "number": 123} + + def test_parse_json_expects_invalid_json_raises_value_error(self): + """Test parse_json with invalid JSON raises ValueError.""" + invalid_json = '{"key": "value"' + with pytest.raises(ValueError, match="Failed to decode JSON"): + parse_json(invalid_json) + + +class TestNormalizeIsoTimestamp: + def test_normalize_iso_timestamp_expects_zulu_suffix(self): + """Test normalize_iso_timestamp with Zulu timezone suffix (Z).""" + timestamp_str = "2025-03-15T14:30:45.123Z" + result = normalize_iso_timestamp(timestamp_str) + expected = datetime(2025, 3, 15, 14, 30, 45, 123000, tzinfo=timezone.utc) + assert result == expected + + def test_normalize_iso_timestamp_expects_without_timezone_suffix(self): + """Test normalize_iso_timestamp without timezone suffix (assumes UTC).""" + timestamp_str = "2025-07-22T09:15:33.456" + result = normalize_iso_timestamp(timestamp_str) + expected = datetime(2025, 7, 22, 9, 15, 33, 456000, tzinfo=timezone.utc) + assert result == expected + + def test_normalize_iso_timestamp_expects_trims_microseconds(self): + """Test normalize_iso_timestamp trims microseconds to 6 digits.""" + timestamp_str = "2025-11-08T16:42:17.789123456+00:00" + result = normalize_iso_timestamp(timestamp_str) + expected = datetime(2025, 11, 8, 16, 42, 17, 789123, tzinfo=timezone.utc) + assert result == expected + + def test_normalize_iso_timestamp_expects_without_microseconds(self): + """Test normalize_iso_timestamp without microseconds.""" + timestamp_str = "2025-01-31T23:59:00Z" + result = normalize_iso_timestamp(timestamp_str) + expected = datetime(2025, 1, 31, 23, 59, 0, 0, tzinfo=timezone.utc) + assert result == expected + + def test_normalize_iso_timestamp_expects_invalid_format_raises_value_error(self): + """Test normalize_iso_timestamp with invalid format raises ValueError.""" + invalid_timestamp = "not-a-timestamp" + with pytest.raises(ValueError, match="Invalid timestamp format"): + normalize_iso_timestamp(invalid_timestamp) diff --git a/sinch/domains/sms/endpoints/batches/__init__.py b/tests/unit/domains/conversation/v1/endpoints/messages/__init__.py similarity index 100% rename from sinch/domains/sms/endpoints/batches/__init__.py rename to tests/unit/domains/conversation/v1/endpoints/messages/__init__.py diff --git a/tests/unit/domains/conversation/v1/endpoints/messages/test_delete_message_endpoint.py b/tests/unit/domains/conversation/v1/endpoints/messages/test_delete_message_endpoint.py new file mode 100644 index 00000000..6ed477b9 --- /dev/null +++ b/tests/unit/domains/conversation/v1/endpoints/messages/test_delete_message_endpoint.py @@ -0,0 +1,83 @@ +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.conversation.api.v1.internal import DeleteMessageEndpoint +from sinch.domains.conversation.models.v1.messages.internal.request import MessageIdRequest +from sinch.domains.conversation.api.v1.exceptions import ConversationException + + +@pytest.fixture +def request_data(): + return MessageIdRequest(message_id="01FC66621XXXXX119Z8PMV1QPQ") + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=204, + body=None, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def mock_error_response(): + return HTTPResponse( + status_code=404, + body={ + "error": { + "message": "Message not found", + "status": "NotFound" + } + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return DeleteMessageEndpoint("test_project_id", request_data) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_conversation): + """ + Test that the URL is built correctly. + """ + assert ( + endpoint.build_url(mock_sinch_client_conversation) + == "https://us.conversation.api.sinch.com/v1/projects/test_project_id/messages/01FC66621XXXXX119Z8PMV1QPQ" + ) + + +def test_messages_source_query_param_expects_parsed_params(): + """ + Test that the messages_source query parameter is parsed correctly. + """ + request_data = MessageIdRequest( + message_id="01FC66621XXXXX119Z8PMV1QPQ", + messages_source="CONVERSATION_SOURCE" + ) + endpoint = DeleteMessageEndpoint("test_project_id", request_data) + + query_params = endpoint.build_query_params() + assert query_params["messages_source"] == "CONVERSATION_SOURCE" + + +def test_handle_response_expects_success(endpoint, mock_response): + """ + Test that a successful delete response (204 No Content) is handled correctly. + """ + result = endpoint.handle_response(mock_response) + assert result is None + + +def test_handle_response_expects_conversation_exception_on_error( + endpoint, mock_error_response +): + """ + Test that ConversationException is raised when server returns an error. + """ + with pytest.raises(ConversationException) as exc_info: + endpoint.handle_response(mock_error_response) + + assert exc_info.value.is_from_server is True + assert exc_info.value.http_response.status_code == 404 diff --git a/tests/unit/domains/conversation/v1/endpoints/messages/test_get_message_endpoint.py b/tests/unit/domains/conversation/v1/endpoints/messages/test_get_message_endpoint.py new file mode 100644 index 00000000..177dab0a --- /dev/null +++ b/tests/unit/domains/conversation/v1/endpoints/messages/test_get_message_endpoint.py @@ -0,0 +1,93 @@ +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.conversation.api.v1.internal import GetMessageEndpoint +from sinch.domains.conversation.models.v1.messages.internal.request import MessageIdRequest +from sinch.domains.conversation.models.v1.messages.response.message_response import ( + AppMessageResponse, + ContactMessageResponse, +) +from tests.unit.domains.conversation.v1.models.response.test_conversation_message_response_model import ( + contact_message_response_data, + app_message_response_data, +) + + +@pytest.fixture +def request_data(): + return MessageIdRequest(message_id="CAPY123456789ABCDEFGHIJKLMNOP") + + +@pytest.fixture +def mock_contact_message_response(contact_message_response_data): + """Mock response for ContactMessageResponse (Union type test).""" + return HTTPResponse( + status_code=200, + body=contact_message_response_data, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def mock_app_message_response(app_message_response_data): + """Mock response for AppMessageResponse (Union type test).""" + return HTTPResponse( + status_code=200, + body=app_message_response_data, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return GetMessageEndpoint("test_project_id", request_data) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_conversation): + """" + Test that the URL is built correctly. + """ + assert ( + endpoint.build_url(mock_sinch_client_conversation) + == "https://us.conversation.api.sinch.com/v1/projects/test_project_id/messages/CAPY123456789ABCDEFGHIJKLMNOP" + ) + + +def test_messages_source_query_param_expects_parsed_params(): + """ + Test that the messages_source query parameter is parsed correctly. + """ + request_data = MessageIdRequest( + message_id="CAPY123456789ABCDEFGHIJKLMNOP", + messages_source="CONVERSATION_SOURCE" + ) + endpoint = GetMessageEndpoint("test_project_id", request_data) + + query_params = endpoint.build_query_params() + assert query_params["messages_source"] == "CONVERSATION_SOURCE" + + +def test_handle_response_expects_contact_message_response(endpoint, mock_contact_message_response): + """ + Test that contact message response is handled correctly and mapped to the appropriate fields. + """ + parsed_response = endpoint.handle_response(mock_contact_message_response) + + # ConversationMessageResponse is a Union of AppMessageResponse and ContactMessageResponse + # In this test case, we expect a ContactMessageResponse + assert isinstance(parsed_response, ContactMessageResponse) + assert not isinstance(parsed_response, AppMessageResponse) + + +def test_handle_response_expects_app_message_response(mock_app_message_response): + """ + Test that the app message response is handled correctly and mapped to the appropriate fields. + """ + request_data = MessageIdRequest(message_id="APP123456789ABCDEFGHIJKLMNOP") + endpoint = GetMessageEndpoint("test_project_id", request_data) + + parsed_response = endpoint.handle_response(mock_app_message_response) + + # ConversationMessageResponse is a Union of AppMessageResponse and ContactMessageResponse + # In this test case, we expect an AppMessageResponse + assert isinstance(parsed_response, AppMessageResponse) + assert not isinstance(parsed_response, ContactMessageResponse) diff --git a/tests/unit/domains/conversation/v1/endpoints/messages/test_list_last_messages_by_channel_identity_endpoint.py b/tests/unit/domains/conversation/v1/endpoints/messages/test_list_last_messages_by_channel_identity_endpoint.py new file mode 100644 index 00000000..1347834b --- /dev/null +++ b/tests/unit/domains/conversation/v1/endpoints/messages/test_list_last_messages_by_channel_identity_endpoint.py @@ -0,0 +1,112 @@ +import json +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.conversation.api.v1.internal import ( + ListLastMessagesByChannelIdentityEndpoint, +) +from sinch.domains.conversation.models.v1.messages.internal import ( + ListMessagesResponse, +) +from sinch.domains.conversation.models.v1.messages.internal.request import ( + ListLastMessagesByChannelIdentityRequest, +) +from tests.unit.domains.conversation.v1.models.response.test_conversation_message_response_model import ( + contact_message_response_data, +) + + +@pytest.fixture +def request_data(): + return ListLastMessagesByChannelIdentityRequest( + channel_identities=["+15551234567"], + messages_source="DISPATCH_SOURCE", + page_size=2, + ) + + +@pytest.fixture +def endpoint(request_data): + return ListLastMessagesByChannelIdentityEndpoint( + "test_project_id", request_data + ) + + +@pytest.fixture +def mock_list_last_messages_response(contact_message_response_data): + return HTTPResponse( + status_code=200, + body={ + "messages": [contact_message_response_data], + "next_page_token": "token_next_page_abc", + }, + headers={"Content-Type": "application/json"}, + ) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_conversation): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_conversation) + == "https://us.conversation.api.sinch.com/v1/projects/test_project_id/messages:fetch-last-message" + ) + + +def test_request_body_expects_parsed_params(): + """Test that all body fields are serialized when set.""" + request_data = ListLastMessagesByChannelIdentityRequest( + channel_identities=["+46701234567", "+46709876543"], + contact_ids=["CONTACT123"], + app_id="APP789", + messages_source="DISPATCH_SOURCE", + page_size=20, + page_token="token_xyz", + view="WITH_METADATA", + channel="WHATSAPP", + direction="TO_APP", + ) + endpoint = ListLastMessagesByChannelIdentityEndpoint( + "test_project_id", request_data + ) + body = json.loads(endpoint.request_body()) + + assert body["channel_identities"] == ["+46701234567", "+46709876543"] + assert body["contact_ids"] == ["CONTACT123"] + assert body["app_id"] == "APP789" + assert body["messages_source"] == "DISPATCH_SOURCE" + assert body["page_size"] == 20 + assert body["page_token"] == "token_xyz" + assert body["view"] == "WITH_METADATA" + assert body["channel"] == "WHATSAPP" + assert body["direction"] == "TO_APP" + + +def test_handle_response_expects_list_messages_response( + endpoint, mock_list_last_messages_response +): + """Test that a successful response is parsed to ListMessagesResponse.""" + result = endpoint.handle_response(mock_list_last_messages_response) + + assert isinstance(result, ListMessagesResponse) + assert result.next_page_token == "token_next_page_abc" + assert result.messages is not None + assert len(result.messages) == 1 + assert result.messages[0].id == "CAPY123456789ABCDEFGHIJKLMNOP" + + +def test_handle_response_expects_empty_messages_list(): + """Test that response with empty messages list is handled correctly.""" + request_data = ListLastMessagesByChannelIdentityRequest(page_size=10) + endpoint = ListLastMessagesByChannelIdentityEndpoint( + "test_project_id", request_data + ) + mock_response = HTTPResponse( + status_code=200, + body={"messages": [], "next_page_token": None}, + headers={"Content-Type": "application/json"}, + ) + + result = endpoint.handle_response(mock_response) + + assert isinstance(result, ListMessagesResponse) + assert result.messages == [] + assert result.next_page_token is None diff --git a/tests/unit/domains/conversation/v1/endpoints/messages/test_list_messages_endpoint.py b/tests/unit/domains/conversation/v1/endpoints/messages/test_list_messages_endpoint.py new file mode 100644 index 00000000..e6eb805e --- /dev/null +++ b/tests/unit/domains/conversation/v1/endpoints/messages/test_list_messages_endpoint.py @@ -0,0 +1,115 @@ +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.conversation.api.v1.internal import ListMessagesEndpoint +from sinch.domains.conversation.models.v1.messages.internal import ( + ListMessagesResponse, +) +from sinch.domains.conversation.models.v1.messages.internal.request import ( + ListMessagesRequest, +) +from tests.unit.domains.conversation.v1.models.response.test_conversation_message_response_model import ( + contact_message_response_data, +) + + +@pytest.fixture +def request_data(): + return ListMessagesRequest(page_size=10) + + +@pytest.fixture +def endpoint(request_data): + return ListMessagesEndpoint("test_project_id", request_data) + + +@pytest.fixture +def mock_list_messages_response(contact_message_response_data): + return HTTPResponse( + status_code=200, + body={ + "messages": [contact_message_response_data], + "next_page_token": "token_next_page_abc", + }, + headers={"Content-Type": "application/json"}, + ) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_conversation): + """Test that the URL is built correctly (no path params beyond project_id).""" + assert ( + endpoint.build_url(mock_sinch_client_conversation) + == "https://us.conversation.api.sinch.com/v1/projects/test_project_id/messages" + ) + + +def test_build_query_params_expects_excludes_unset_fields(): + """Test that query params only include non-None fields.""" + request_data = ListMessagesRequest(page_size=10) + endpoint = ListMessagesEndpoint("test_project_id", request_data) + + query_params = endpoint.build_query_params() + + assert query_params["page_size"] == 10 + assert "conversation_id" not in query_params + + +def test_build_query_params_expects_parsed_params(): + """Test that all query param fields are serialized when set.""" + request_data = ListMessagesRequest( + conversation_id="CONV123", + contact_id="CONTACT456", + app_id="APP789", + channel_identity="+46701234567", + page_size=20, + page_token="token_xyz", + view="WITH_METADATA", + messages_source="DISPATCH_SOURCE", + only_recipient_originated=True, + channel="WHATSAPP", + direction="TO_APP", + ) + endpoint = ListMessagesEndpoint("test_project_id", request_data) + + query_params = endpoint.build_query_params() + + assert query_params["conversation_id"] == "CONV123" + assert query_params["contact_id"] == "CONTACT456" + assert query_params["app_id"] == "APP789" + assert query_params["channel_identity"] == "+46701234567" + assert query_params["page_size"] == 20 + assert query_params["page_token"] == "token_xyz" + assert query_params["view"] == "WITH_METADATA" + assert query_params["messages_source"] == "DISPATCH_SOURCE" + assert query_params["only_recipient_originated"] is True + assert query_params["channel"] == "WHATSAPP" + assert query_params["direction"] == "TO_APP" + + +def test_handle_response_expects_list_messages_response( + endpoint, mock_list_messages_response +): + """Test that a successful response is parsed to ListMessagesResponse.""" + result = endpoint.handle_response(mock_list_messages_response) + + assert isinstance(result, ListMessagesResponse) + assert result.next_page_token == "token_next_page_abc" + assert result.messages is not None + assert len(result.messages) == 1 + assert result.messages[0].id == "CAPY123456789ABCDEFGHIJKLMNOP" + + +def test_handle_response_expects_empty_messages_list(): + """Test that response with empty messages list is handled correctly.""" + request_data = ListMessagesRequest(page_size=10) + endpoint = ListMessagesEndpoint("test_project_id", request_data) + mock_response = HTTPResponse( + status_code=200, + body={"messages": [], "next_page_token": None}, + headers={"Content-Type": "application/json"}, + ) + + result = endpoint.handle_response(mock_response) + + assert isinstance(result, ListMessagesResponse) + assert result.messages == [] + assert result.next_page_token is None diff --git a/tests/unit/domains/conversation/v1/endpoints/messages/test_send_message_endpoint.py b/tests/unit/domains/conversation/v1/endpoints/messages/test_send_message_endpoint.py new file mode 100644 index 00000000..a1de2a95 --- /dev/null +++ b/tests/unit/domains/conversation/v1/endpoints/messages/test_send_message_endpoint.py @@ -0,0 +1,96 @@ +import json +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.conversation.api.v1.internal import SendMessageEndpoint +from sinch.domains.conversation.api.v1.exceptions import ConversationException +from sinch.domains.conversation.models.v1.messages.internal.request import ( + SendMessageRequest, + SendMessageRequestBody, +) +from sinch.domains.conversation.models.v1.messages.internal.request.recipient import ( + Recipient, +) +from sinch.domains.conversation.models.v1.messages.categories.text import TextMessage +from sinch.domains.conversation.models.v1.messages.response import ( + SendMessageResponse, +) + + +@pytest.fixture +def request_data(): + return SendMessageRequest( + app_id="my app ID", + recipient=Recipient(contact_id="my contact ID"), + message=SendMessageRequestBody( + text_message=TextMessage(text="This is a text message.") + ), + ) + + +@pytest.fixture +def mock_send_message_response(): + """Mock response for SendMessageResponse.""" + return HTTPResponse( + status_code=200, + body={"message_id": "01FC66621XXXXX119Z8PMV1QPQ"}, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def mock_error_response(): + """Mock error response for send message endpoint.""" + return HTTPResponse( + status_code=400, + body={ + "error": { + "code": 400, + "message": "Invalid argument", + "status": "INVALID_ARGUMENT" + } + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return SendMessageEndpoint("test_project_id", request_data) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_conversation): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_conversation) + == "https://us.conversation.api.sinch.com/v1/projects/test_project_id/messages:send" + ) + + +def test_request_body_expects_valid_json_with_app_id_recipient_message(request_data): + """Test that the endpoint produces a JSON body with app_id, recipient, and message.""" + endpoint = SendMessageEndpoint("test_project_id", request_data) + body = json.loads(endpoint.request_body()) + + assert body["app_id"] == "my app ID" + assert body["recipient"]["contact_id"] == "my contact ID" + assert "text_message" in body["message"] + assert "project_id" not in body + + +def test_handle_response_expects_send_message_response(endpoint, mock_send_message_response): + """Test that SendMessageResponse is handled correctly.""" + parsed_response = endpoint.handle_response(mock_send_message_response) + + assert isinstance(parsed_response, SendMessageResponse) + assert parsed_response.message_id == "01FC66621XXXXX119Z8PMV1QPQ" + + +def test_handle_response_expects_conversation_exception_on_error( + endpoint, mock_error_response +): + """Test that ConversationException is raised when server returns an error.""" + with pytest.raises(ConversationException) as exc_info: + endpoint.handle_response(mock_error_response) + + assert exc_info.value.is_from_server is True + assert exc_info.value.http_response.status_code == 400 diff --git a/tests/unit/domains/conversation/v1/endpoints/messages/test_update_message_endpoint.py b/tests/unit/domains/conversation/v1/endpoints/messages/test_update_message_endpoint.py new file mode 100644 index 00000000..8a3ccd29 --- /dev/null +++ b/tests/unit/domains/conversation/v1/endpoints/messages/test_update_message_endpoint.py @@ -0,0 +1,114 @@ +import json +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.conversation.api.v1.internal import UpdateMessageMetadataEndpoint +from sinch.domains.conversation.models.v1.messages.internal.request import UpdateMessageMetadataRequest +from sinch.domains.conversation.models.v1.messages.response.message_response import ( + AppMessageResponse, + ContactMessageResponse, +) +from tests.unit.domains.conversation.v1.models.response.test_conversation_message_response_model import ( + contact_message_response_data, + app_message_response_data, +) + + +@pytest.fixture +def request_data(): + return UpdateMessageMetadataRequest( + message_id="CAPY123456789ABCDEFGHIJKLMNOP", + metadata="test_metadata", + ) + + +@pytest.fixture +def mock_contact_message_response(contact_message_response_data): + """Mock response for ContactMessageResponse (Union type test).""" + return HTTPResponse( + status_code=200, + body=contact_message_response_data, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def mock_app_message_response(app_message_response_data): + """Mock response for AppMessageResponse (Union type test).""" + return HTTPResponse( + status_code=200, + body=app_message_response_data, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return UpdateMessageMetadataEndpoint("test_project_id", request_data) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_conversation): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_conversation) + == "https://us.conversation.api.sinch.com/v1/projects/test_project_id/messages/CAPY123456789ABCDEFGHIJKLMNOP" + ) + + +def test_messages_source_query_param_expects_parsed_params(request_data): + """ + Test that the URL is built correctly with messages_source query parameter. + metadata is from body application/json, so it should not be in query params. + """ + request_data.messages_source = "DISPATCH_SOURCE" + endpoint = UpdateMessageMetadataEndpoint("test_project_id", request_data) + + query_params = endpoint.build_query_params() + assert "metadata" not in query_params + assert query_params["messages_source"] == "DISPATCH_SOURCE" + + +def test_request_body_expects_excludes_message_id_and_query_params(request_data): + """ + Test that message_id and messages_source are excluded from request body. + metadata should always be included in the request body. + """ + request_data.messages_source = "CONVERSATION_SOURCE" + endpoint = UpdateMessageMetadataEndpoint("test_project_id", request_data) + body = json.loads(endpoint.request_body()) + + assert "messages_source" not in body + assert "message_id" not in body + assert "metadata" in body + assert body["metadata"] == "test_metadata" + + +def test_handle_response_expects_contact_message_mapping(endpoint, mock_contact_message_response): + """ + Test that the response handles ContactMessageResponse correctly (Union type test). + """ + parsed_response = endpoint.handle_response(mock_contact_message_response) + + assert isinstance(parsed_response, ContactMessageResponse) + assert not isinstance(parsed_response, AppMessageResponse) + + assert parsed_response.id == "CAPY123456789ABCDEFGHIJKLMNOP" + assert parsed_response.metadata == "test_metadata" + + +def test_handle_response_expects_app_message_mapping(mock_app_message_response): + """ + Test that the response handles AppMessageResponse correctly (Union type test). + """ + request_data = UpdateMessageMetadataRequest( + message_id="APP123456789ABCDEFGHIJKLMNOP", + metadata="test_metadata", + ) + endpoint = UpdateMessageMetadataEndpoint("test_project_id", request_data) + + parsed_response = endpoint.handle_response(mock_app_message_response) + + assert isinstance(parsed_response, AppMessageResponse) + assert not isinstance(parsed_response, ContactMessageResponse) + + assert parsed_response.id == "APP123456789ABCDEFGHIJKLMNOP" + assert parsed_response.metadata == "test_metadata" diff --git a/tests/unit/domains/conversation/v1/models/internal/request/test_list_last_messages_by_channel_identity_request.py b/tests/unit/domains/conversation/v1/models/internal/request/test_list_last_messages_by_channel_identity_request.py new file mode 100644 index 00000000..81617143 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/internal/request/test_list_last_messages_by_channel_identity_request.py @@ -0,0 +1,36 @@ +from datetime import datetime, timezone +from sinch.domains.conversation.models.v1.messages.internal.request import ( + ListLastMessagesByChannelIdentityRequest, +) + + +def test_list_last_messages_by_channel_identity_request_expects_parsed_input(): + """Test that the model correctly parses input with all parameters.""" + start = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + end = datetime(2025, 1, 8, 12, 0, 0, tzinfo=timezone.utc) + + request = ListLastMessagesByChannelIdentityRequest( + channel_identities=["+46701234567", "+46709876543"], + contact_ids=["CONTACT456789ABCDEFGHIJKLMNOP"], + app_id="APP123456789ABCDEFGHIJK", + messages_source="DISPATCH_SOURCE", + page_size=50, + page_token="next_page_token_abc", + view="WITH_METADATA", + start_time=start, + end_time=end, + channel="WHATSAPP", + direction="TO_CONTACT", + ) + + assert request.channel_identities == ["+46701234567", "+46709876543"] + assert request.contact_ids == ["CONTACT456789ABCDEFGHIJKLMNOP"] + assert request.app_id == "APP123456789ABCDEFGHIJK" + assert request.messages_source == "DISPATCH_SOURCE" + assert request.page_size == 50 + assert request.page_token == "next_page_token_abc" + assert request.view == "WITH_METADATA" + assert request.start_time == start + assert request.end_time == end + assert request.channel == "WHATSAPP" + assert request.direction == "TO_CONTACT" diff --git a/tests/unit/domains/conversation/v1/models/internal/request/test_list_messages_request.py b/tests/unit/domains/conversation/v1/models/internal/request/test_list_messages_request.py new file mode 100644 index 00000000..03fb01b7 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/internal/request/test_list_messages_request.py @@ -0,0 +1,41 @@ +from datetime import datetime, timezone + +from sinch.domains.conversation.models.v1.messages.internal.request import ( + ListMessagesRequest, +) + + +def test_list_messages_request_expects_parsed_input(): + """Test that the model correctly parses input with all parameters.""" + start = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + end = datetime(2025, 1, 8, 12, 0, 0, tzinfo=timezone.utc) + + request = ListMessagesRequest( + conversation_id="CONV123456789ABCDEFGHIJKLM", + contact_id="CONTACT456789ABCDEFGHIJKLMNOP", + app_id="APP123456789ABCDEFGHIJK", + channel_identity="+46701234567", + start_time=start, + end_time=end, + page_size=50, + page_token="next_page_token_abc", + view="WITH_METADATA", + messages_source="CONVERSATION_SOURCE", + only_recipient_originated=True, + channel="WHATSAPP", + direction="TO_CONTACT", + ) + + assert request.conversation_id == "CONV123456789ABCDEFGHIJKLM" + assert request.contact_id == "CONTACT456789ABCDEFGHIJKLMNOP" + assert request.app_id == "APP123456789ABCDEFGHIJK" + assert request.channel_identity == "+46701234567" + assert request.start_time == start + assert request.end_time == end + assert request.page_size == 50 + assert request.page_token == "next_page_token_abc" + assert request.view == "WITH_METADATA" + assert request.messages_source == "CONVERSATION_SOURCE" + assert request.only_recipient_originated is True + assert request.channel == "WHATSAPP" + assert request.direction == "TO_CONTACT" diff --git a/tests/unit/domains/conversation/v1/models/internal/request/test_message_id_request.py b/tests/unit/domains/conversation/v1/models/internal/request/test_message_id_request.py new file mode 100644 index 00000000..af5049b7 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/internal/request/test_message_id_request.py @@ -0,0 +1,43 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.conversation.models.v1.messages.internal.request import ( + MessageIdRequest, +) + + +def test_message_id_request_expects_accepts_snake_case_input(): + """ + Test that the model accepts snake_case input when allow_population_by_field_name is True. + """ + request = MessageIdRequest(message_id="CAPYLAKE123456789ABCDEFGHIJKL") + + assert request.message_id == "CAPYLAKE123456789ABCDEFGHIJKL" + + +@pytest.mark.parametrize("messages_source", ["CONVERSATION_SOURCE", "DISPATCH_SOURCE"]) +def test_message_id_request_expects_accepts_messages_source(messages_source): + """ + Test that the model accepts messages_source with different values. + """ + request = MessageIdRequest( + message_id="CAPYPOUND123456789ABCDEFGHIJKLM", + messages_source=messages_source + ) + + assert request.message_id == "CAPYPOUND123456789ABCDEFGHIJKLM" + assert request.messages_source == messages_source + + +def test_message_id_request_expects_validation_error_for_missing_field(): + """ + Test that the model raises a ValidationError when a required field is missing. + """ + data = {} + + with pytest.raises(ValidationError) as excinfo: + MessageIdRequest(**data) + + error_message = str(excinfo.value) + + assert "Field required" in error_message or "field required" in error_message + assert "messageId" in error_message or "message_id" in error_message diff --git a/tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request.py b/tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request.py new file mode 100644 index 00000000..9d8dd8f8 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request.py @@ -0,0 +1,109 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.conversation.models.v1.messages.categories.text import TextMessage +from sinch.domains.conversation.models.v1.messages.internal.request import ( + Recipient, + SendMessageRequestBody, + SendMessageRequest, +) + + +def test_send_message_request_expects_parsed_input(): + """ + Test that the model parses input correctly. + """ + request = SendMessageRequest( + app_id="my-app-id", + recipient=Recipient(contact_id="my-contact-id"), + message=SendMessageRequestBody(text_message=TextMessage(text="Hello")), + ) + + assert request.app_id == "my-app-id" + assert request.recipient.contact_id == "my-contact-id" + assert request.message.text_message is not None + assert request.message.text_message.text == "Hello" + + +@pytest.mark.parametrize("processing_strategy", ["DEFAULT", "DISPATCH_ONLY"]) +def test_send_message_request_expects_accepts_processing_strategy(processing_strategy): + """ + Test that the model accepts processing_strategy with different values. + """ + request = SendMessageRequest( + app_id="my-app-id", + recipient=Recipient(contact_id="my-contact-id"), + message=SendMessageRequestBody(text_message=TextMessage(text="Hello")), + processing_strategy=processing_strategy, + ) + + assert request.processing_strategy == processing_strategy + + +def test_send_message_request_serializes_event_destination_target_as_callback_url_for_api(): + """ + User-facing name is event_destination_target; JSON body uses callback_url. + """ + request = SendMessageRequest( + app_id="my-app-id", + recipient=Recipient(contact_id="my-contact-id"), + message=SendMessageRequestBody(text_message=TextMessage(text="Hello")), + event_destination_target="https://example.com/callback", + ) + payload = request.model_dump(mode="json", exclude_none=True, by_alias=True) + assert payload["callback_url"] == "https://example.com/callback" + assert "event_destination_target" not in payload + + +@pytest.mark.parametrize("ttl_input,expected_serialized", [(10, "10s"), ("10s", "10s"), ("10", "10s"), (None, None)]) +def test_send_message_request_expects_ttl_serialized_to_backend(ttl_input, expected_serialized): + """ + Test that ttl is serialized as "10s" when sent to the backend (int/str normalized to string with 's' suffix). + """ + request = SendMessageRequest( + app_id="my-app-id", + recipient=Recipient(contact_id="my-contact-id"), + message=SendMessageRequestBody(text_message=TextMessage(text="Hello")), + ttl=ttl_input, + ) + + payload = request.model_dump(mode="json", exclude_none=True) + if expected_serialized is None: + assert "ttl" not in payload + else: + assert payload["ttl"] == expected_serialized + + +def test_send_message_request_expects_validation_error_for_missing_app_id(): + """ + Test that the model raises a ValidationError when app_id field is missing. + """ + data = { + "recipient": Recipient(contact_id="my-contact-id"), + "message": SendMessageRequestBody(text_message=TextMessage(text="Hello")), + } + + with pytest.raises(ValidationError) as excinfo: + SendMessageRequest(**data) + + error_message = str(excinfo.value) + + assert "field required" in error_message.casefold() + assert "app_id" in error_message + + +def test_send_message_request_expects_validation_error_for_missing_recipient(): + """ + Test that the model raises a ValidationError when recipient field is missing. + """ + data = { + "app_id": "my-app-id", + "message": SendMessageRequestBody(text_message=TextMessage(text="Hello")), + } + + with pytest.raises(ValidationError) as excinfo: + SendMessageRequest(**data) + + error_message = str(excinfo.value) + + assert "field required" in error_message.casefold() + assert "recipient" in error_message diff --git a/tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request_body.py b/tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request_body.py new file mode 100644 index 00000000..013b70d8 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request_body.py @@ -0,0 +1,191 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.card.card_message import ( + CardMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.carousel.carousel_message import ( + CarouselMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.choice.choice_message import ( + ChoiceMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.choice.choice_options import ( + TextChoiceMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.location.location_message import ( + LocationMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.media import ( + MediaProperties, +) +from sinch.domains.conversation.models.v1.messages.categories.template import ( + TemplateMessage, + TemplateReferenceOmniChannel, +) +from sinch.domains.conversation.models.v1.messages.categories.text import ( + TextMessage, +) +from sinch.domains.conversation.models.v1.messages.internal.request import ( + SendMessageRequestBody, +) +from sinch.domains.conversation.models.v1.messages.shared.coordinates import ( + Coordinates, +) + + +def test_send_message_request_body_expects_accepts_text_message(): + """ + Test that the model accepts text_message with valid content. + """ + body = SendMessageRequestBody(text_message=TextMessage(text="Test message content")) + + assert body.text_message.text == "Test message content" + + +def test_send_message_request_body_expects_accepts_card_message(): + """ + Test that the model accepts card_message. + """ + body = SendMessageRequestBody(card_message=CardMessage(title="Card title")) + + assert body.card_message is not None + assert body.card_message.title == "Card title" + + +def test_send_message_request_body_expects_accepts_carousel_message(): + """ + Test that the model accepts carousel_message with a list of cards. + """ + body = SendMessageRequestBody( + carousel_message=CarouselMessage(cards=[CardMessage(title="Card 1")]) + ) + + assert body.carousel_message is not None + assert len(body.carousel_message.cards) == 1 + assert body.carousel_message.cards[0].title == "Card 1" + + +def test_send_message_request_body_expects_accepts_choice_message(): + """ + Test that the model accepts choice_message with choices. + """ + body = SendMessageRequestBody( + choice_message=ChoiceMessage( + choices=[TextChoiceMessage(text_message=TextMessage(text="Option 1"))] + ) + ) + + assert body.choice_message is not None + assert len(body.choice_message.choices) == 1 + assert body.choice_message.choices[0].text_message.text == "Option 1" + + +def test_send_message_request_body_expects_accepts_location_message(): + """ + Test that the model accepts location_message with coordinates and title. + """ + body = SendMessageRequestBody( + location_message=LocationMessage( + coordinates=Coordinates(latitude=59.3293, longitude=18.0686), + title="Stockholm", + ) + ) + + assert body.location_message is not None + assert body.location_message.title == "Stockholm" + assert body.location_message.coordinates.latitude == 59.3293 + assert body.location_message.coordinates.longitude == 18.0686 + + +def test_send_message_request_body_expects_accepts_media_message(): + """ + Test that the model accepts media_message with url. + """ + body = SendMessageRequestBody( + media_message=MediaProperties(url="https://example.com/image.jpg") + ) + + assert body.media_message is not None + assert body.media_message.url == "https://example.com/image.jpg" + + +def test_send_message_request_body_expects_accepts_template_message(): + """ + Test that the model accepts template_message with omni_template. + """ + body = SendMessageRequestBody( + template_message=TemplateMessage( + omni_template=TemplateReferenceOmniChannel( + template_id="tpl_123", version="latest" + ) + ) + ) + + assert body.template_message is not None + assert body.template_message.omni_template is not None + assert body.template_message.omni_template.template_id == "tpl_123" + assert body.template_message.omni_template.version == "latest" + + +def test_send_message_request_body_expects_accepts_choice_with_one_message_key(): + """ + Parsing from dict: each choice with exactly one message-type key is valid. + Choices array can include Call, Location, Text, URL, Calendar, Request location + (number limited to 10 per spec). + """ + choices = [ + {"text_message": {"text": "Option 1"}}, + {"call_message": {"title": "Call us", "phone_number": "+46732000000"}}, + {"url_message": {"title": "Website", "url": "https://example.com"}}, + { + "location_message": { + "title": "Show map", + "coordinates": {"latitude": 59.33, "longitude": 18.07}, + } + }, + { + "share_location_message": { + "title": "Share location", + "fallback_url": "https://example.com", + } + }, + ] + body = SendMessageRequestBody( + choice_message=ChoiceMessage(choices=choices) + ) + assert body.choice_message is not None + assert len(body.choice_message.choices) == 5 + assert body.choice_message.choices[0].text_message.text == "Option 1" + assert body.choice_message.choices[1].call_message.phone_number == "+46732000000" + assert body.choice_message.choices[2].url_message.url == "https://example.com" + assert body.choice_message.choices[3].location_message.title == "Show map" + assert ( + body.choice_message.choices[4].share_location_message.title + == "Share location" + ) + + +def test_send_message_request_body_expects_rejects_choice_with_zero_message_keys(): + """ + Parsing from dict: choice with no message-type key raises. + """ + with pytest.raises(ValueError, match="exactly one of"): + SendMessageRequestBody( + choice_message=ChoiceMessage(choices=[{"postback_data": "x"}]) + ) + + +def test_send_message_request_body_expects_rejects_choice_with_two_message_keys(): + """ + Parsing from dict: choice with two message-type keys raises. + """ + with pytest.raises(ValueError, match="exactly one of"): + SendMessageRequestBody( + choice_message=ChoiceMessage( + choices=[ + { + "text_message": {"text": "A"}, + "call_message": {"title": "Call", "phone_number": "1"}, + } + ] + ) + ) diff --git a/tests/unit/domains/conversation/v1/models/internal/request/test_update_message_metadata_request.py b/tests/unit/domains/conversation/v1/models/internal/request/test_update_message_metadata_request.py new file mode 100644 index 00000000..01df0e0c --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/internal/request/test_update_message_metadata_request.py @@ -0,0 +1,55 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.conversation.models.v1.messages.internal.request import ( + UpdateMessageMetadataRequest, +) + + +@pytest.mark.parametrize("messages_source", ["CONVERSATION_SOURCE", "DISPATCH_SOURCE"]) +def test_update_message_metadata_request_expects_accepts_messages_source(messages_source): + """ + Test that the model accepts messages_source with different values. + """ + request = UpdateMessageMetadataRequest( + message_id="CAPY123456789ABCDEFGHIJKLMNOP", + metadata="test_metadata", + messages_source=messages_source + ) + + assert request.message_id == "CAPY123456789ABCDEFGHIJKLMNOP" + assert request.metadata == "test_metadata" + assert request.messages_source == messages_source + + +def test_update_message_metadata_request_expects_validation_error_for_missing_message_id(): + """ + Test that the model raises a ValidationError when message_id field is missing. + """ + data = { + "metadata": "test_metadata" + } + + with pytest.raises(ValidationError) as excinfo: + UpdateMessageMetadataRequest(**data) + + error_message = str(excinfo.value) + + assert "Field required" in error_message or "field required" in error_message + assert "messageId" in error_message or "message_id" in error_message + + +def test_update_message_metadata_request_expects_validation_error_for_missing_metadata(): + """ + Test that the model raises a ValidationError when metadata field is missing. + """ + data = { + "message_id": "CAPY123456789ABCDEFGHIJKLMNOP" + } + + with pytest.raises(ValidationError) as excinfo: + UpdateMessageMetadataRequest(**data) + + error_message = str(excinfo.value) + + assert "Field required" in error_message or "field required" in error_message + assert "metadata" in error_message diff --git a/tests/unit/domains/conversation/v1/models/internal/test_list_messages_response.py b/tests/unit/domains/conversation/v1/models/internal/test_list_messages_response.py new file mode 100644 index 00000000..8ecc2d5e --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/internal/test_list_messages_response.py @@ -0,0 +1,39 @@ +from sinch.domains.conversation.models.v1.messages.internal import ( + ListMessagesResponse, +) +from tests.unit.domains.conversation.v1.models.response.test_conversation_message_response_model import ( + contact_message_response_data, + app_message_response_data, +) + + +def test_list_messages_response_expects_correct_mapping( + contact_message_response_data, + app_message_response_data, +): + """ + Test that response is correctly parsed from dict and + content property returns messages. + """ + data = { + "messages": [contact_message_response_data, app_message_response_data], + "next_page_token": "token_abc", + } + response = ListMessagesResponse.model_validate(data) + + assert response.next_page_token == "token_abc" + assert response.messages is not None + assert len(response.messages) == 2 + assert response.messages[0].id == contact_message_response_data["id"] + assert response.messages[1].id == app_message_response_data["id"] + assert response.content == response.messages + assert len(response.content) == 2 + + +def test_list_messages_response_expects_empty_messages_list(): + """Test that response with empty messages list has content as empty list.""" + response = ListMessagesResponse(messages=[], next_page_token=None) + + assert response.messages == [] + assert response.content == [] + assert response.next_page_token is None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_card_app_message.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_card_app_message.py new file mode 100644 index 00000000..9f2fe2bd --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_card_app_message.py @@ -0,0 +1,147 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + CardAppMessage, +) + + +@pytest.fixture +def card_app_message_data(): + return { + "card_message": { + "title": "title value", + "description": "description value", + "media_message": { + "url": "an url value", + "thumbnail_url": "another url", + "filename_override": "filename override value" + }, + "height": "MEDIUM", + "choices": [ + { + "text_message": { + "text": "This is a text message." + }, + "postback_data": "postback_data text" + }, + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback_data call" + }, + { + "location_message": { + "coordinates": { + "latitude": 47.6279809, + "longitude": -2.8229159 + }, + "title": "title value", + "label": "label value" + }, + "postback_data": "postback_data location" + }, + { + "url_message": { + "title": "title value", + "url": "an url value" + }, + "postback_data": "postback_data url" + }, + { + "calendar_message": { + "title": "Calendar Message Example", + "event_start": "2023-10-01T10:00:00Z", + "event_end": "2023-10-01T11:00:00Z", + "event_title": "Team Meeting", + "event_description": "Monthly team sync-up", + "fallback_url": "https://calendar.example.com/event/12345" + }, + "postback_data": "postback calendar_message data value" + }, + { + "share_location_message": { + "title": "Share Location Example", + "fallback_url": "https://maps.example.com/?q=37.7749,-122.4194" + }, + "postback_data": "postback share_location_message data value" + } + ] + }, + "explicit_channel_message": { + "KAKAOTALK": "foo value" + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "choice_message": { + "text_message": { + "text": "This is a text message." + }, + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + } + ] + } + } + }, + "channel_specific_message": { + "MESSENGER": { + "message_type": "FLOWS", + "message": { + "flow_id": "1", + "flow_cta": "Book!", + "header": { + "type": "text", + "text": "text header value" + }, + "body": { + "text": "Flow message body" + }, + "footer": { + "text": "Flow message footer" + }, + "flow_token": "AQAAAAACS5FpgQ_cAAAAAD0QI3s.", + "flow_mode": "draft", + "flow_action": "navigate", + "flow_action_payload": { + "screen": "", + "data": { + "product_name": "name", + "product_description": "description", + "product_price": 100 + } + } + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_card_app_message_expects_correct_fields(card_app_message_data): + """Test that CardAppMessage is parsed correctly with all fields.""" + parsed_response = CardAppMessage.model_validate(card_app_message_data) + + assert isinstance(parsed_response, CardAppMessage) + assert parsed_response.card_message is not None + assert parsed_response.card_message.title == "title value" + assert parsed_response.card_message.description == "description value" + assert parsed_response.card_message.height == "MEDIUM" + assert parsed_response.card_message.media_message is not None + assert parsed_response.card_message.media_message.url == "an url value" + assert parsed_response.card_message.media_message.thumbnail_url == "another url" + assert parsed_response.card_message.media_message.filename_override == "filename override value" + assert len(parsed_response.card_message.choices) == 6 + assert parsed_response.channel_specific_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert parsed_response.explicit_channel_message is not None + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_carousel_app_message.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_carousel_app_message.py new file mode 100644 index 00000000..075d1b8b --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_carousel_app_message.py @@ -0,0 +1,117 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + CarouselAppMessage, +) + + +@pytest.fixture +def carousel_app_message_data(): + return { + "carousel_message": { + "cards": [ + { + "title": "title value", + "description": "description value", + "media_message": { + "url": "an url value", + "thumbnail_url": "another url", + "filename_override": "filename override value" + }, + "height": "MEDIUM", + "choices": [ + { + "text_message": { + "text": "This is a text message." + }, + "postback_data": "postback_data text" + } + ] + } + ], + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + } + ] + }, + "explicit_channel_message": { + "KAKAOTALK": "foo value" + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "choice_message": { + "text_message": { + "text": "This is a text message." + }, + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + } + ] + } + } + }, + "channel_specific_message": { + "MESSENGER": { + "message_type": "FLOWS", + "message": { + "flow_id": "1", + "flow_cta": "Book!", + "header": { + "type": "text", + "text": "text header value" + }, + "body": { + "text": "Flow message body" + }, + "footer": { + "text": "Flow message footer" + }, + "flow_token": "AQAAAAACS5FpgQ_cAAAAAD0QI3s.", + "flow_mode": "draft", + "flow_action": "navigate", + "flow_action_payload": { + "screen": "", + "data": { + "product_name": "name", + "product_description": "description", + "product_price": 100 + } + } + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_carousel_app_message_expects_correct_fields(carousel_app_message_data): + """Test that CarouselAppMessage is parsed correctly with all fields.""" + parsed_response = CarouselAppMessage.model_validate(carousel_app_message_data) + + assert isinstance(parsed_response, CarouselAppMessage) + assert parsed_response.carousel_message is not None + assert len(parsed_response.carousel_message.cards) == 1 + assert parsed_response.carousel_message.cards[0].title == "title value" + assert parsed_response.carousel_message.cards[0].description == "description value" + assert parsed_response.carousel_message.cards[0].height == "MEDIUM" + assert len(parsed_response.carousel_message.choices) == 1 + assert parsed_response.carousel_message.choices[0].call_message.title == "title value" + assert parsed_response.carousel_message.choices[0].call_message.phone_number == "phone number value" + assert parsed_response.carousel_message.choices[0].postback_data == "postback call_message data value" + assert parsed_response.explicit_channel_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert parsed_response.channel_specific_message is not None + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_choice_app_message.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_choice_app_message.py new file mode 100644 index 00000000..7bc2e118 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_choice_app_message.py @@ -0,0 +1,140 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + ChoiceAppMessage, +) + + +@pytest.fixture +def choice_app_message_data(): + return { + "choice_message": { + "text_message": { + "text": "This is a text message." + }, + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + }, + { + "location_message": { + "coordinates": { + "latitude": 47.6279809, + "longitude": -2.8229159 + }, + "title": "title value", + "label": "label value" + }, + "postback_data": "postback location_message data value" + }, + { + "text_message": { + "text": "This is a text message." + }, + "postback_data": "postback text_message data value" + }, + { + "url_message": { + "title": "title value", + "url": "an url value" + }, + "postback_data": "postback url_message data value" + }, + { + "calendar_message": { + "title": "Calendar Message Example", + "event_start": "2023-10-01T10:00:00Z", + "event_end": "2023-10-01T11:00:00Z", + "event_title": "Team Meeting", + "event_description": "Monthly team sync-up", + "fallback_url": "https://calendar.example.com/event/12345" + }, + "postback_data": "postback calendar_message data value" + }, + { + "share_location_message": { + "title": "Share Location Example", + "fallback_url": "https://maps.example.com/?q=37.7749,-122.4194" + }, + "postback_data": "postback share_location_message data value" + } + ] + }, + "explicit_channel_message": { + "KAKAOTALK": "foo value" + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "choice_message": { + "text_message": { + "text": "This is a text message." + }, + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + } + ] + } + } + }, + "channel_specific_message": { + "MESSENGER": { + "message_type": "FLOWS", + "message": { + "flow_id": "1", + "flow_cta": "Book!", + "header": { + "type": "text", + "text": "text header value" + }, + "body": { + "text": "Flow message body" + }, + "footer": { + "text": "Flow message footer" + }, + "flow_token": "AQAAAAACS5FpgQ_cAAAAAD0QI3s.", + "flow_mode": "draft", + "flow_action": "navigate", + "flow_action_payload": { + "screen": "", + "data": { + "product_name": "name", + "product_description": "description", + "product_price": 100 + } + } + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_choice_app_message_expects_correct_fields(choice_app_message_data): + """Test that ChoiceAppMessage is parsed correctly with all fields.""" + parsed_response = ChoiceAppMessage.model_validate(choice_app_message_data) + + assert isinstance(parsed_response, ChoiceAppMessage) + assert parsed_response.choice_message is not None + assert parsed_response.choice_message.text_message is not None + assert parsed_response.choice_message.text_message.text == "This is a text message." + assert len(parsed_response.choice_message.choices) == 6 + assert parsed_response.choice_message.choices[0].call_message.title == "title value" + assert parsed_response.choice_message.choices[0].call_message.phone_number == "phone number value" + assert parsed_response.choice_message.choices[0].postback_data == "postback call_message data value" + assert parsed_response.explicit_channel_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert parsed_response.channel_specific_message is not None + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_contact_info_app_message.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_contact_info_app_message.py new file mode 100644 index 00000000..862491d3 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_contact_info_app_message.py @@ -0,0 +1,138 @@ +import pytest +from datetime import date +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + ContactInfoAppMessage, +) + + +@pytest.fixture +def contact_info_app_message_data(): + return { + "contact_info_message": { + "name": { + "full_name": "full_name value", + "first_name": "first_name value", + "last_name": "last_name value", + "middle_name": "middle_name value", + "prefix": "prefix value", + "suffix": "suffix value" + }, + "phone_numbers": [ + { + "phone_number": "phone_number value", + "type": "type value" + } + ], + "addresses": [ + { + "city": "city value", + "country": "country value", + "state": "state va@lue", + "zip": "zip value", + "country_code": "country_code value" + } + ], + "email_addresses": [ + { + "email_address": "email_address value", + "type": "type value" + } + ], + "organization": { + "company": "company value", + "department": "department value", + "title": "title value" + }, + "urls": [ + { + "url": "url value", + "type": "type value" + } + ], + "birthday": "1968-07-07" + }, + "explicit_channel_message": { + "KAKAOTALK": "foo value" + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "choice_message": { + "text_message": { + "text": "This is a text message." + }, + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + } + ] + } + } + }, + "channel_specific_message": { + "MESSENGER": { + "message_type": "FLOWS", + "message": { + "flow_id": "1", + "flow_cta": "Book!", + "header": { + "type": "text", + "text": "text header value" + }, + "body": { + "text": "Flow message body" + }, + "footer": { + "text": "Flow message footer" + }, + "flow_token": "AQAAAAACS5FpgQ_cAAAAAD0QI3s.", + "flow_mode": "draft", + "flow_action": "navigate", + "flow_action_payload": { + "screen": "", + "data": { + "product_name": "name", + "product_description": "description", + "product_price": 100 + } + } + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_contact_info_app_message_expects_correct_fields(contact_info_app_message_data): + """Test that ContactInfoAppMessage is parsed correctly with all fields.""" + parsed_response = ContactInfoAppMessage.model_validate(contact_info_app_message_data) + + assert isinstance(parsed_response, ContactInfoAppMessage) + assert parsed_response.contact_info_message is not None + assert parsed_response.contact_info_message.name is not None + assert parsed_response.contact_info_message.name.full_name == "full_name value" + assert parsed_response.contact_info_message.name.first_name == "first_name value" + assert parsed_response.contact_info_message.name.last_name == "last_name value" + assert parsed_response.contact_info_message.name.middle_name == "middle_name value" + assert parsed_response.contact_info_message.name.prefix == "prefix value" + assert parsed_response.contact_info_message.name.suffix == "suffix value" + assert len(parsed_response.contact_info_message.phone_numbers) == 1 + assert parsed_response.contact_info_message.phone_numbers[0].phone_number == "phone_number value" + assert parsed_response.contact_info_message.phone_numbers[0].type == "type value" + assert len(parsed_response.contact_info_message.addresses) == 1 + assert len(parsed_response.contact_info_message.email_addresses) == 1 + assert parsed_response.contact_info_message.organization is not None + assert len(parsed_response.contact_info_message.urls) == 1 + assert isinstance(parsed_response.contact_info_message.birthday, date) + assert parsed_response.contact_info_message.birthday == date(1968, 7, 7) + assert parsed_response.channel_specific_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert parsed_response.explicit_channel_message is not None + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_list_app_message.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_list_app_message.py new file mode 100644 index 00000000..28d7f0de --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_list_app_message.py @@ -0,0 +1,122 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + ListAppMessage, +) + + +@pytest.fixture +def list_app_message_data(): + return { + "list_message": { + "title": "a list message title value", + "sections": [ + { + "title": "a list section title value", + "items": [ + { + "choice": { + "title": "choice title", + "description": "description value", + "media": { + "url": "an url value", + "thumbnail_url": "another url", + "filename_override": "filename override value" + }, + "postback_data": "postback value" + } + } + ] + } + ], + "description": "description value", + "message_properties": { + "catalog_id": "catalog ID value", + "menu": "menu value" + }, + "media": { + "url": "an url value", + "thumbnail_url": "another url", + "filename_override": "filename override value" + } + }, + "explicit_channel_message": { + "KAKAOTALK": "foo value" + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "choice_message": { + "text_message": { + "text": "This is a text message." + }, + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + } + ] + } + } + }, + "channel_specific_message": { + "MESSENGER": { + "message_type": "FLOWS", + "message": { + "flow_id": "1", + "flow_cta": "Book!", + "header": { + "type": "text", + "text": "text header value" + }, + "body": { + "text": "Flow message body" + }, + "footer": { + "text": "Flow message footer" + }, + "flow_token": "AQAAAAACS5FpgQ_cAAAAAD0QI3s.", + "flow_mode": "draft", + "flow_action": "navigate", + "flow_action_payload": { + "screen": "", + "data": { + "product_name": "name", + "product_description": "description", + "product_price": 100 + } + } + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_list_app_message_expects_correct_fields(list_app_message_data): + """Test that ListAppMessage is parsed correctly with all fields.""" + parsed_response = ListAppMessage.model_validate(list_app_message_data) + + assert isinstance(parsed_response, ListAppMessage) + assert parsed_response.list_message is not None + assert parsed_response.list_message.title == "a list message title value" + assert parsed_response.list_message.description == "description value" + assert len(parsed_response.list_message.sections) == 1 + assert parsed_response.list_message.sections[0].title == "a list section title value" + assert len(parsed_response.list_message.sections[0].items) == 1 + assert parsed_response.list_message.sections[0].items[0].choice.title == "choice title" + assert parsed_response.list_message.sections[0].items[0].choice.description == "description value" + assert parsed_response.list_message.message_properties is not None + assert parsed_response.list_message.message_properties.catalog_id == "catalog ID value" + assert parsed_response.list_message.message_properties.menu == "menu value" + assert parsed_response.list_message.media is not None + assert parsed_response.list_message.media.url == "an url value" + assert parsed_response.explicit_channel_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert parsed_response.channel_specific_message is not None + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_location_app_message.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_location_app_message.py new file mode 100644 index 00000000..b7ff9055 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_location_app_message.py @@ -0,0 +1,90 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + LocationAppMessage, +) + + +@pytest.fixture +def location_app_message_data(): + return { + "location_message": { + "coordinates": { + "latitude": 47.6279809, + "longitude": -2.8229159 + }, + "label": "label value", + "title": "title value" + }, + "explicit_channel_message": { + "KAKAOTALK": "foo value" + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "choice_message": { + "choices": [ + { + "call_message": { + "phone_number": "phone number value", + "title": "title value" + }, + "postback_data": "postback call_message data value" + } + ], + "text_message": { + "text": "This is a text message." + } + } + } + }, + "channel_specific_message": { + "MESSENGER": { + "message_type": "FLOWS", + "message": { + "header": { + "type": "text", + "text": "text header value" + }, + "body": { + "text": "Flow message body" + }, + "footer": { + "text": "Flow message footer" + }, + "flow_id": "1", + "flow_token": "AQAAAAACS5FpgQ_cAAAAAD0QI3s.", + "flow_mode": "draft", + "flow_cta": "Book!", + "flow_action": "navigate", + "flow_action_payload": { + "screen": "", + "data": { + "product_price": 100, + "product_description": "description", + "product_name": "name" + } + } + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_location_app_message_expects_correct_fields(location_app_message_data): + """Test that LocationAppMessage is parsed correctly with all fields.""" + parsed_response = LocationAppMessage.model_validate(location_app_message_data) + + assert isinstance(parsed_response, LocationAppMessage) + assert parsed_response.location_message is not None + assert parsed_response.location_message.title == "title value" + assert parsed_response.location_message.label == "label value" + assert parsed_response.location_message.coordinates.latitude == 47.6279809 + assert parsed_response.location_message.coordinates.longitude == -2.8229159 + assert parsed_response.explicit_channel_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert parsed_response.channel_specific_message is not None + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_media_app_message.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_media_app_message.py new file mode 100644 index 00000000..3316e9d4 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_media_app_message.py @@ -0,0 +1,86 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + MediaAppMessage, +) + + +@pytest.fixture +def media_app_message_data(): + return { + "media_message": { + "url": "an url value", + "thumbnail_url": "another url", + "filename_override": "filename override value" + }, + "explicit_channel_message": { + "KAKAOTALK": "foo value" + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "choice_message": { + "text_message": { + "text": "This is a text message." + }, + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + } + ] + } + } + }, + "channel_specific_message": { + "MESSENGER": { + "message_type": "FLOWS", + "message": { + "flow_id": "1", + "flow_cta": "Book!", + "header": { + "type": "text", + "text": "text header value" + }, + "body": { + "text": "Flow message body" + }, + "footer": { + "text": "Flow message footer" + }, + "flow_token": "AQAAAAACS5FpgQ_cAAAAAD0QI3s.", + "flow_mode": "draft", + "flow_action": "navigate", + "flow_action_payload": { + "screen": "", + "data": { + "product_name": "name", + "product_description": "description", + "product_price": 100 + } + } + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_media_app_message_expects_correct_fields(media_app_message_data): + """Test that MediaAppMessage is parsed correctly with all fields.""" + parsed_response = MediaAppMessage.model_validate(media_app_message_data) + + assert isinstance(parsed_response, MediaAppMessage) + assert parsed_response.media_message is not None + assert parsed_response.media_message.url == "an url value" + assert parsed_response.media_message.thumbnail_url == "another url" + assert parsed_response.media_message.filename_override == "filename override value" + assert parsed_response.explicit_channel_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert parsed_response.channel_specific_message is not None + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_card.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_card.py new file mode 100644 index 00000000..3292b266 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_card.py @@ -0,0 +1,119 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + CardAppMessage, +) + + +@pytest.fixture +def card_app_message_with_omni_override_card_data(): + return { + "card_message": { + "title": "title value", + "description": "description value", + "media_message": { + "url": "an url value", + "thumbnail_url": "another url", + "filename_override": "filename override value" + }, + "height": "MEDIUM", + "choices": [ + { + "text_message": { + "text": "This is a text message." + }, + "postback_data": "postback_data text" + } + ] + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "card_message": { + "title": "title value", + "description": "description value", + "media_message": { + "url": "an url value", + "thumbnail_url": "another url", + "filename_override": "filename override value" + }, + "height": "MEDIUM", + "choices": [ + { + "text_message": { + "text": "This is a text message." + }, + "postback_data": "postback_data text" + }, + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback_data call" + }, + { + "location_message": { + "coordinates": { + "latitude": 47.6279809, + "longitude": -2.8229159 + }, + "title": "title value", + "label": "label value" + }, + "postback_data": "postback_data location" + }, + { + "url_message": { + "title": "title value", + "url": "an url value" + }, + "postback_data": "postback_data url" + }, + { + "calendar_message": { + "title": "Calendar Message Example", + "event_start": "2023-10-01T10:00:00Z", + "event_end": "2023-10-01T11:00:00Z", + "event_title": "Team Meeting", + "event_description": "Monthly team sync-up", + "fallback_url": "https://calendar.example.com/event/12345" + }, + "postback_data": "postback calendar_message data value" + }, + { + "share_location_message": { + "title": "Share Location Example", + "fallback_url": "https://maps.example.com/?q=37.7749,-122.4194" + }, + "postback_data": "postback share_location_message data value" + } + ] + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_card_app_message_with_omni_override_card_expects_correct_fields( + card_app_message_with_omni_override_card_data, +): + """Test that CardAppMessage with OmniMessageOverrideCard is parsed correctly.""" + parsed_response = CardAppMessage.model_validate( + card_app_message_with_omni_override_card_data + ) + + assert isinstance(parsed_response, CardAppMessage) + assert parsed_response.card_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert "KAKAOTALK" in parsed_response.explicit_channel_omni_message + omni_override = parsed_response.explicit_channel_omni_message["KAKAOTALK"] + assert omni_override.card_message is not None + assert omni_override.card_message.title == "title value" + assert omni_override.card_message.description == "description value" + assert omni_override.card_message.height == "MEDIUM" + assert len(omni_override.card_message.choices) == 6 + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_carousel.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_carousel.py new file mode 100644 index 00000000..629cdb2f --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_carousel.py @@ -0,0 +1,185 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + CarouselAppMessage, +) + + +@pytest.fixture +def carousel_app_message_with_omni_override_carousel_data(): + return { + "carousel_message": { + "cards": [ + { + "title": "title value", + "description": "description value", + "media_message": { + "url": "an url value", + "thumbnail_url": "another url", + "filename_override": "filename override value" + }, + "height": "MEDIUM", + "choices": [ + { + "text_message": { + "text": "This is a text message." + }, + "postback_data": "postback_data text" + } + ] + } + ], + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + } + ] + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "carousel_message": { + "cards": [ + { + "title": "title value", + "description": "description value", + "media_message": { + "url": "an url value", + "thumbnail_url": "another url", + "filename_override": "filename override value" + }, + "height": "MEDIUM", + "choices": [ + { + "text_message": { + "text": "This is a text message." + }, + "postback_data": "postback_data text" + }, + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback_data call" + }, + { + "location_message": { + "coordinates": { + "latitude": 47.6279809, + "longitude": -2.8229159 + }, + "title": "title value", + "label": "label value" + }, + "postback_data": "postback_data location" + }, + { + "url_message": { + "title": "title value", + "url": "an url value" + }, + "postback_data": "postback_data url" + }, + { + "calendar_message": { + "title": "Calendar Message Example", + "event_start": "2023-10-01T10:00:00Z", + "event_end": "2023-10-01T11:00:00Z", + "event_title": "Team Meeting", + "event_description": "Monthly team sync-up", + "fallback_url": "https://calendar.example.com/event/12345" + }, + "postback_data": "postback calendar_message data value" + }, + { + "share_location_message": { + "title": "Share Location Example", + "fallback_url": "https://maps.example.com/?q=37.7749,-122.4194" + }, + "postback_data": "postback share_location_message data value" + } + ] + } + ], + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + }, + { + "location_message": { + "coordinates": { + "latitude": 47.6279809, + "longitude": -2.8229159 + }, + "title": "title value", + "label": "label value" + }, + "postback_data": "postback location_message data value" + }, + { + "text_message": { + "text": "This is a text message." + }, + "postback_data": "postback text_message data value" + }, + { + "url_message": { + "title": "title value", + "url": "an url value" + }, + "postback_data": "postback url_message data value" + }, + { + "calendar_message": { + "title": "Calendar Message Example", + "event_start": "2023-10-01T10:00:00Z", + "event_end": "2023-10-01T11:00:00Z", + "event_title": "Team Meeting", + "event_description": "Monthly team sync-up", + "fallback_url": "https://calendar.example.com/event/12345" + }, + "postback_data": "postback calendar_message data value" + }, + { + "share_location_message": { + "title": "Share Location Example", + "fallback_url": "https://maps.example.com/?q=37.7749,-122.4194" + }, + "postback_data": "postback share_location_message data value" + } + ] + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_carousel_app_message_with_omni_override_carousel_expects_correct_fields( + carousel_app_message_with_omni_override_carousel_data, +): + """Test that CarouselAppMessage with OmniMessageOverrideCarousel is parsed correctly.""" + parsed_response = CarouselAppMessage.model_validate( + carousel_app_message_with_omni_override_carousel_data + ) + + assert isinstance(parsed_response, CarouselAppMessage) + assert parsed_response.carousel_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert "KAKAOTALK" in parsed_response.explicit_channel_omni_message + omni_override = parsed_response.explicit_channel_omni_message["KAKAOTALK"] + assert omni_override.carousel_message is not None + assert len(omni_override.carousel_message.cards) == 1 + assert len(omni_override.carousel_message.choices) == 6 + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_choice.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_choice.py new file mode 100644 index 00000000..57a73ef4 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_choice.py @@ -0,0 +1,114 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + ChoiceAppMessage, +) + + +@pytest.fixture +def choice_app_message_with_omni_override_choice_data(): + return { + "choice_message": { + "text_message": { + "text": "This is a text message." + }, + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + } + ] + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "choice_message": { + "text_message": { + "text": "This is a text message." + }, + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + }, + { + "location_message": { + "coordinates": { + "latitude": 47.6279809, + "longitude": -2.8229159 + }, + "title": "title value", + "label": "label value" + }, + "postback_data": "postback location_message data value" + }, + { + "text_message": { + "text": "This is a text message." + }, + "postback_data": "postback text_message data value" + }, + { + "url_message": { + "title": "title value", + "url": "an url value" + }, + "postback_data": "postback url_message data value" + }, + { + "calendar_message": { + "title": "Calendar Message Example", + "event_start": "2023-10-01T10:00:00Z", + "event_end": "2023-10-01T11:00:00Z", + "event_title": "Team Meeting", + "event_description": "Monthly team sync-up", + "fallback_url": "https://calendar.example.com/event/12345" + }, + "postback_data": "postback calendar_message data value" + }, + { + "share_location_message": { + "title": "Share Location Example", + "fallback_url": "https://maps.example.com/?q=37.7749,-122.4194" + }, + "postback_data": "postback share_location_message data value" + } + ] + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_choice_app_message_with_omni_override_choice_expects_correct_fields( + choice_app_message_with_omni_override_choice_data, +): + """Test that ChoiceAppMessage with OmniMessageOverrideChoice is parsed correctly.""" + parsed_response = ChoiceAppMessage.model_validate( + choice_app_message_with_omni_override_choice_data + ) + + assert isinstance(parsed_response, ChoiceAppMessage) + assert parsed_response.choice_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert "KAKAOTALK" in parsed_response.explicit_channel_omni_message + omni_override = parsed_response.explicit_channel_omni_message["KAKAOTALK"] + assert omni_override.choice_message is not None + assert omni_override.choice_message.text_message is not None + assert len(omni_override.choice_message.choices) == 6 + assert omni_override.choice_message.choices[0].call_message is not None + assert omni_override.choice_message.choices[1].location_message is not None + assert omni_override.choice_message.choices[2].text_message is not None + assert omni_override.choice_message.choices[3].url_message is not None + assert omni_override.choice_message.choices[4].calendar_message is not None + assert omni_override.choice_message.choices[5].share_location_message is not None + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_contact_info.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_contact_info.py new file mode 100644 index 00000000..9cc83df6 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_contact_info.py @@ -0,0 +1,101 @@ +import pytest +from datetime import date +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + ContactInfoAppMessage, +) + + +@pytest.fixture +def contact_info_app_message_with_omni_override_contact_info_data(): + return { + "contact_info_message": { + "name": { + "full_name": "full_name value", + "first_name": "first_name value", + "last_name": "last_name value" + }, + "phone_numbers": [] + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "contact_info_message": { + "name": { + "full_name": "full_name value", + "first_name": "first_name value", + "last_name": "last_name value", + "middle_name": "middle_name value", + "prefix": "prefix value", + "suffix": "suffix value" + }, + "phone_numbers": [ + { + "phone_number": "phone_number value", + "type": "type value" + } + ], + "addresses": [ + { + "city": "city value", + "country": "country value", + "state": "state va@lue", + "zip": "zip value", + "country_code": "country_code value" + } + ], + "email_addresses": [ + { + "email_address": "email_address value", + "type": "type value" + } + ], + "organization": { + "company": "company value", + "department": "department value", + "title": "title value" + }, + "urls": [ + { + "url": "url value", + "type": "type value" + } + ], + "birthday": "1968-07-07" + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_contact_info_app_message_with_omni_override_contact_info_expects_correct_fields( + contact_info_app_message_with_omni_override_contact_info_data, +): + """Test that ContactInfoAppMessage with OmniMessageOverrideContactInfo is parsed correctly.""" + parsed_response = ContactInfoAppMessage.model_validate( + contact_info_app_message_with_omni_override_contact_info_data + ) + + assert isinstance(parsed_response, ContactInfoAppMessage) + assert parsed_response.contact_info_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert "KAKAOTALK" in parsed_response.explicit_channel_omni_message + omni_override = parsed_response.explicit_channel_omni_message["KAKAOTALK"] + assert omni_override.contact_info_message is not None + assert omni_override.contact_info_message.name.full_name == "full_name value" + assert omni_override.contact_info_message.name.first_name == "first_name value" + assert omni_override.contact_info_message.name.last_name == "last_name value" + assert omni_override.contact_info_message.name.middle_name == "middle_name value" + assert omni_override.contact_info_message.name.prefix == "prefix value" + assert omni_override.contact_info_message.name.suffix == "suffix value" + assert len(omni_override.contact_info_message.phone_numbers) == 1 + assert len(omni_override.contact_info_message.addresses) == 1 + assert len(omni_override.contact_info_message.email_addresses) == 1 + assert omni_override.contact_info_message.organization is not None + assert len(omni_override.contact_info_message.urls) == 1 + assert isinstance(omni_override.contact_info_message.birthday, date) + assert omni_override.contact_info_message.birthday == date(1968, 7, 7) + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_list.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_list.py new file mode 100644 index 00000000..34aa95cd --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_list.py @@ -0,0 +1,91 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + ListAppMessage, +) + + +@pytest.fixture +def list_app_message_with_omni_override_list_data(): + return { + "list_message": { + "title": "a list message title value", + "sections": [ + { + "title": "a list section title value", + "items": [ + { + "product": { + "id": "product ID value", + "marketplace": "marketplace value", + "quantity": 4, + "item_price": 3.14159, + "currency": "currency value" + } + } + ] + } + ] + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "list_message": { + "title": "a list message title value", + "sections": [ + { + "title": "a list section title value", + "items": [ + { + "product": { + "id": "product ID value", + "marketplace": "marketplace value", + "quantity": 4, + "item_price": 3.14159, + "currency": "currency value" + } + } + ] + } + ], + "description": "description value", + "message_properties": { + "catalog_id": "catalog ID value", + "menu": "menu value" + }, + "media": { + "url": "an url value", + "thumbnail_url": "another url", + "filename_override": "filename override value" + } + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_list_app_message_with_omni_override_list_expects_correct_fields( + list_app_message_with_omni_override_list_data, +): + """Test that ListAppMessage with OmniMessageOverrideList is parsed correctly.""" + parsed_response = ListAppMessage.model_validate( + list_app_message_with_omni_override_list_data + ) + + assert isinstance(parsed_response, ListAppMessage) + assert parsed_response.list_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert "KAKAOTALK" in parsed_response.explicit_channel_omni_message + omni_override = parsed_response.explicit_channel_omni_message["KAKAOTALK"] + assert omni_override.list_message is not None + assert omni_override.list_message.title == "a list message title value" + assert len(omni_override.list_message.sections) == 1 + assert omni_override.list_message.description == "description value" + assert omni_override.list_message.message_properties is not None + assert omni_override.list_message.message_properties.catalog_id == "catalog ID value" + assert omni_override.list_message.message_properties.menu == "menu value" + assert omni_override.list_message.media is not None + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_location.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_location.py new file mode 100644 index 00000000..2ad82be8 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_location.py @@ -0,0 +1,56 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + LocationAppMessage, +) + + +@pytest.fixture +def location_app_message_with_omni_override_location_data(): + return { + "location_message": { + "coordinates": { + "latitude": 47.6279809, + "longitude": -2.8229159 + }, + "title": "title value", + "label": "label value" + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "location_message": { + "coordinates": { + "latitude": 47.6279809, + "longitude": -2.8229159 + }, + "title": "title value", + "label": "label value" + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_location_app_message_with_omni_override_location_expects_correct_fields( + location_app_message_with_omni_override_location_data, +): + """Test that LocationAppMessage with OmniMessageOverrideLocation is parsed correctly.""" + parsed_response = LocationAppMessage.model_validate( + location_app_message_with_omni_override_location_data + ) + + assert isinstance(parsed_response, LocationAppMessage) + assert parsed_response.location_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert "KAKAOTALK" in parsed_response.explicit_channel_omni_message + omni_override = parsed_response.explicit_channel_omni_message["KAKAOTALK"] + assert omni_override.location_message is not None + assert omni_override.location_message.coordinates.latitude == 47.6279809 + assert omni_override.location_message.coordinates.longitude == -2.8229159 + assert omni_override.location_message.title == "title value" + assert omni_override.location_message.label == "label value" + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_media.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_media.py new file mode 100644 index 00000000..d6e40946 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_media.py @@ -0,0 +1,49 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + MediaAppMessage, +) + + +@pytest.fixture +def media_app_message_with_omni_override_media_data(): + return { + "media_message": { + "url": "an url value", + "thumbnail_url": "another url", + "filename_override": "filename override value" + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "media_message": { + "url": "an url value", + "thumbnail_url": "another url", + "filename_override": "filename override value" + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_media_app_message_with_omni_override_media_expects_correct_fields( + media_app_message_with_omni_override_media_data, +): + """Test that MediaAppMessage with OmniMessageOverrideMedia is parsed correctly.""" + parsed_response = MediaAppMessage.model_validate( + media_app_message_with_omni_override_media_data + ) + + assert isinstance(parsed_response, MediaAppMessage) + assert parsed_response.media_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert "KAKAOTALK" in parsed_response.explicit_channel_omni_message + omni_override = parsed_response.explicit_channel_omni_message["KAKAOTALK"] + assert omni_override.media_message is not None + assert omni_override.media_message.url == "an url value" + assert omni_override.media_message.thumbnail_url == "another url" + assert omni_override.media_message.filename_override == "filename override value" + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_template_reference.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_template_reference.py new file mode 100644 index 00000000..b5368c54 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_template_reference.py @@ -0,0 +1,65 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + TemplateAppMessage, +) + + +@pytest.fixture +def template_app_message_with_omni_override_template_reference_data(): + return { + "template_message": { + "channel_template": { + "KAKAOTALK": { + "template_id": "my template ID value", + "language_code": "en-US" + } + }, + "omni_template": { + "template_id": "another template ID", + "version": "another version", + "language_code": "another language", + "parameters": { + "name": "Value for the name parameter used in the version 1 and language \"en-US\" of the template" + } + } + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "template_reference": { + "template_id": "another template ID", + "version": "another version", + "language_code": "another language", + "parameters": { + "name": "Value for the name parameter used in the version 1 and language \"en-US\" of the template" + } + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_template_app_message_with_omni_override_template_reference_expects_correct_fields( + template_app_message_with_omni_override_template_reference_data, +): + """Test that TemplateAppMessage with OmniMessageOverrideTemplateReference is parsed correctly.""" + parsed_response = TemplateAppMessage.model_validate( + template_app_message_with_omni_override_template_reference_data + ) + + assert isinstance(parsed_response, TemplateAppMessage) + assert parsed_response.template_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert "KAKAOTALK" in parsed_response.explicit_channel_omni_message + omni_override = parsed_response.explicit_channel_omni_message["KAKAOTALK"] + assert omni_override.template_reference is not None + assert omni_override.template_reference.template_id == "another template ID" + assert omni_override.template_reference.version == "another version" + assert omni_override.template_reference.language_code == "another language" + assert omni_override.template_reference.parameters is not None + assert omni_override.template_reference.parameters["name"] == "Value for the name parameter used in the version 1 and language \"en-US\" of the template" + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_text.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_text.py new file mode 100644 index 00000000..99d424f6 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_text.py @@ -0,0 +1,43 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + TextAppMessage, +) + + +@pytest.fixture +def text_app_message_with_omni_override_text_data(): + return { + "text_message": { + "text": "This is a text message." + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "text_message": { + "text": "This is a text message." + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_text_app_message_with_omni_override_text_expects_correct_fields( + text_app_message_with_omni_override_text_data, +): + """Test that TextAppMessage with OmniMessageOverrideText is parsed correctly.""" + parsed_response = TextAppMessage.model_validate( + text_app_message_with_omni_override_text_data + ) + + assert isinstance(parsed_response, TextAppMessage) + assert parsed_response.text_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert "KAKAOTALK" in parsed_response.explicit_channel_omni_message + omni_override = parsed_response.explicit_channel_omni_message["KAKAOTALK"] + assert omni_override.text_message is not None + assert omni_override.text_message.text == "This is a text message." + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_template_app_message.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_template_app_message.py new file mode 100644 index 00000000..e62616cb --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_template_app_message.py @@ -0,0 +1,103 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + TemplateAppMessage, +) + + +@pytest.fixture +def template_app_message_data(): + return { + "template_message": { + "channel_template": { + "KAKAOTALK": { + "template_id": "my template ID value", + "language_code": "en-US" + } + }, + "omni_template": { + "template_id": "another template ID", + "version": "another version", + "language_code": "another language", + "parameters": { + "name": "Value for the name parameter used in the version 1 and language \"en-US\" of the template" + } + } + }, + "explicit_channel_message": { + "KAKAOTALK": "foo value" + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "choice_message": { + "text_message": { + "text": "This is a text message." + }, + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + } + ] + } + } + }, + "channel_specific_message": { + "MESSENGER": { + "message_type": "FLOWS", + "message": { + "flow_id": "1", + "flow_cta": "Book!", + "header": { + "type": "text", + "text": "text header value" + }, + "body": { + "text": "Flow message body" + }, + "footer": { + "text": "Flow message footer" + }, + "flow_token": "AQAAAAACS5FpgQ_cAAAAAD0QI3s.", + "flow_mode": "draft", + "flow_action": "navigate", + "flow_action_payload": { + "screen": "", + "data": { + "product_name": "name", + "product_description": "description", + "product_price": 100 + } + } + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_template_app_message_expects_correct_fields(template_app_message_data): + """Test that TemplateAppMessage is parsed correctly with all fields.""" + parsed_response = TemplateAppMessage.model_validate(template_app_message_data) + + assert isinstance(parsed_response, TemplateAppMessage) + assert parsed_response.template_message is not None + assert parsed_response.template_message.channel_template is not None + assert "KAKAOTALK" in parsed_response.template_message.channel_template + assert parsed_response.template_message.channel_template["KAKAOTALK"].template_id == "my template ID value" + assert parsed_response.template_message.channel_template["KAKAOTALK"].language_code == "en-US" + assert parsed_response.template_message.omni_template is not None + assert parsed_response.template_message.omni_template.template_id == "another template ID" + assert parsed_response.template_message.omni_template.version == "another version" + assert parsed_response.template_message.omni_template.language_code == "another language" + assert parsed_response.template_message.omni_template.parameters is not None + assert parsed_response.explicit_channel_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert parsed_response.channel_specific_message is not None + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_text_app_message.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_text_app_message.py new file mode 100644 index 00000000..ed453387 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_text_app_message.py @@ -0,0 +1,82 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + TextAppMessage, +) + + +@pytest.fixture +def text_app_message_data(): + return { + "text_message": { + "text": "This is a text message." + }, + "explicit_channel_message": { + "KAKAOTALK": "foo value" + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "choice_message": { + "text_message": { + "text": "This is a text message." + }, + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + } + ] + } + } + }, + "channel_specific_message": { + "MESSENGER": { + "message_type": "FLOWS", + "message": { + "flow_id": "1", + "flow_cta": "Book!", + "header": { + "type": "text", + "text": "text header value" + }, + "body": { + "text": "Flow message body" + }, + "footer": { + "text": "Flow message footer" + }, + "flow_token": "AQAAAAACS5FpgQ_cAAAAAD0QI3s.", + "flow_mode": "draft", + "flow_action": "navigate", + "flow_action_payload": { + "screen": "", + "data": { + "product_name": "name", + "product_description": "description", + "product_price": 100 + } + } + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_text_app_message_expects_correct_fields(text_app_message_data): + """Test that TextAppMessage is parsed correctly with all fields.""" + parsed_response = TextAppMessage.model_validate(text_app_message_data) + + assert isinstance(parsed_response, TextAppMessage) + assert parsed_response.text_message is not None + assert parsed_response.text_message.text == "This is a text message." + assert parsed_response.explicit_channel_message is not None + assert parsed_response.channel_specific_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/contact_message/test_channel_specific_contact_message.py b/tests/unit/domains/conversation/v1/models/response/contact_message/test_channel_specific_contact_message.py new file mode 100644 index 00000000..403ece7a --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/contact_message/test_channel_specific_contact_message.py @@ -0,0 +1,45 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.contact.contact_message import ( + ChannelSpecificContactMessage, +) + + +@pytest.fixture +def channel_specific_contact_message_data(): + return { + "channel_specific_message": { + "message_type": "nfm_reply", + "message": { + "type": "nfm_reply", + "nfm_reply": { + "name": "address_message", + "response_json": "{\"key\": \"value\"}", + "body": "nfm reply body value" + } + } + }, + "reply_to": { + "message_id": "message id value" + } + } + + +def test_parsing_channel_specific_contact_message_expects_correct_fields( + channel_specific_contact_message_data, +): + """Test that ChannelSpecificContactMessage is parsed correctly with all fields.""" + parsed_response = ChannelSpecificContactMessage.model_validate( + channel_specific_contact_message_data + ) + + assert isinstance(parsed_response, ChannelSpecificContactMessage) + assert parsed_response.channel_specific_message is not None + assert parsed_response.channel_specific_message.message_type == "nfm_reply" + assert parsed_response.channel_specific_message.message is not None + assert parsed_response.channel_specific_message.message.type == "nfm_reply" + assert parsed_response.channel_specific_message.message.nfm_reply is not None + assert parsed_response.channel_specific_message.message.nfm_reply.name == "address_message" + assert parsed_response.channel_specific_message.message.nfm_reply.response_json == "{\"key\": \"value\"}" + assert parsed_response.channel_specific_message.message.nfm_reply.body == "nfm reply body value" + assert parsed_response.reply_to is not None + assert parsed_response.reply_to.message_id == "message id value" diff --git a/tests/unit/domains/conversation/v1/models/response/contact_message/test_choice_response_contact_message.py b/tests/unit/domains/conversation/v1/models/response/contact_message/test_choice_response_contact_message.py new file mode 100644 index 00000000..8340cac9 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/contact_message/test_choice_response_contact_message.py @@ -0,0 +1,32 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.contact.contact_message import ( + ChoiceResponseContactMessage, +) + + +@pytest.fixture +def choice_response_contact_message_data(): + return { + "choice_response_message": { + "message_id": "message id value", + "postback_data": "postback data value" + }, + "reply_to": { + "message_id": "message id value" + } + } + + +def test_parsing_choice_response_contact_message_expects_correct_fields( + choice_response_contact_message_data, +): + """Test that ChoiceResponseContactMessage is parsed correctly with all fields.""" + parsed_response = ChoiceResponseContactMessage.model_validate( + choice_response_contact_message_data + ) + + assert isinstance(parsed_response, ChoiceResponseContactMessage) + assert parsed_response.choice_response_message is not None + assert parsed_response.choice_response_message.message_id == "message id value" + assert parsed_response.choice_response_message.postback_data == "postback data value" + assert parsed_response.reply_to is not None diff --git a/tests/unit/domains/conversation/v1/models/response/contact_message/test_fallback_contact_message.py b/tests/unit/domains/conversation/v1/models/response/contact_message/test_fallback_contact_message.py new file mode 100644 index 00000000..1ad6ae81 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/contact_message/test_fallback_contact_message.py @@ -0,0 +1,39 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.contact.contact_message import ( + FallbackContactMessage, +) + + +@pytest.fixture +def fallback_contact_message_data(): + return { + "fallback_message": { + "raw_message": "raw message value", + "reason": { + "code": "RECIPIENT_NOT_OPTED_IN", + "description": "reason description", + "sub_code": "UNSPECIFIED_SUB_CODE", + "channel_code": "a channel code" + } + }, + "reply_to": { + "message_id": "message id value" + } + } + + +def test_parsing_fallback_contact_message_expects_correct_fields( + fallback_contact_message_data, +): + """Test that FallbackContactMessage is parsed correctly with all fields.""" + parsed_response = FallbackContactMessage.model_validate(fallback_contact_message_data) + + assert isinstance(parsed_response, FallbackContactMessage) + assert parsed_response.fallback_message is not None + assert parsed_response.fallback_message.raw_message == "raw message value" + assert parsed_response.fallback_message.reason is not None + assert parsed_response.fallback_message.reason.code == "RECIPIENT_NOT_OPTED_IN" + assert parsed_response.fallback_message.reason.description == "reason description" + assert parsed_response.fallback_message.reason.sub_code == "UNSPECIFIED_SUB_CODE" + assert parsed_response.fallback_message.reason.channel_code == "a channel code" + assert parsed_response.reply_to is not None diff --git a/tests/unit/domains/conversation/v1/models/response/contact_message/test_location_contact_message.py b/tests/unit/domains/conversation/v1/models/response/contact_message/test_location_contact_message.py new file mode 100644 index 00000000..4baf19d5 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/contact_message/test_location_contact_message.py @@ -0,0 +1,38 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.contact.contact_message import ( + LocationContactMessage, +) + + +@pytest.fixture +def location_contact_message_data(): + return { + "location_message": { + "coordinates": { + "latitude": 47.6279809, + "longitude": -2.8229159 + }, + "label": "label value", + "title": "title value" + }, + "reply_to": { + "message_id": "message id value" + } + } + + +def test_parsing_location_contact_message_expects_correct_fields( + location_contact_message_data, +): + """Test that LocationContactMessage is parsed correctly with all fields.""" + parsed_response = LocationContactMessage.model_validate( + location_contact_message_data + ) + + assert isinstance(parsed_response, LocationContactMessage) + assert parsed_response.location_message is not None + assert parsed_response.location_message.coordinates.latitude == 47.6279809 + assert parsed_response.location_message.coordinates.longitude == -2.8229159 + assert parsed_response.location_message.label == "label value" + assert parsed_response.location_message.title == "title value" + assert parsed_response.reply_to is not None diff --git a/tests/unit/domains/conversation/v1/models/response/contact_message/test_media_card_contact_message.py b/tests/unit/domains/conversation/v1/models/response/contact_message/test_media_card_contact_message.py new file mode 100644 index 00000000..f63ed465 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/contact_message/test_media_card_contact_message.py @@ -0,0 +1,32 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.contact.contact_message import ( + MediaCardContactMessage, +) + + +@pytest.fixture +def media_card_contact_message_data(): + return { + "media_card_message": { + "caption": "caption value", + "url": "an url value" + }, + "reply_to": { + "message_id": "message id value" + } + } + + +def test_parsing_media_card_contact_message_expects_correct_fields( + media_card_contact_message_data, +): + """Test that MediaCardContactMessage is parsed correctly with all fields.""" + parsed_response = MediaCardContactMessage.model_validate( + media_card_contact_message_data + ) + + assert isinstance(parsed_response, MediaCardContactMessage) + assert parsed_response.media_card_message is not None + assert parsed_response.media_card_message.caption == "caption value" + assert parsed_response.media_card_message.url == "an url value" + assert parsed_response.reply_to is not None diff --git a/tests/unit/domains/conversation/v1/models/response/contact_message/test_media_contact_message.py b/tests/unit/domains/conversation/v1/models/response/contact_message/test_media_contact_message.py new file mode 100644 index 00000000..880306d7 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/contact_message/test_media_contact_message.py @@ -0,0 +1,30 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.contact.contact_message import ( + MediaContactMessage, +) + + +@pytest.fixture +def media_contact_message_data(): + return { + "media_message": { + "thumbnail_url": "another url", + "url": "an url value", + "filename_override": "filename override value" + }, + "reply_to": { + "message_id": "message id value" + } + } + + +def test_parsing_media_contact_message_expects_correct_fields(media_contact_message_data): + """Test that MediaContactMessage is parsed correctly with all fields.""" + parsed_response = MediaContactMessage.model_validate(media_contact_message_data) + + assert isinstance(parsed_response, MediaContactMessage) + assert parsed_response.media_message is not None + assert parsed_response.media_message.thumbnail_url == "another url" + assert parsed_response.media_message.url == "an url value" + assert parsed_response.media_message.filename_override == "filename override value" + assert parsed_response.reply_to is not None diff --git a/tests/unit/domains/conversation/v1/models/response/contact_message/test_product_response_contact_message.py b/tests/unit/domains/conversation/v1/models/response/contact_message/test_product_response_contact_message.py new file mode 100644 index 00000000..1ef65819 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/contact_message/test_product_response_contact_message.py @@ -0,0 +1,48 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.contact.contact_message import ( + ProductResponseContactMessage, +) + + +@pytest.fixture +def product_response_contact_message_data(): + return { + "product_response_message": { + "products": [ + { + "id": "product ID value", + "marketplace": "marketplace value", + "quantity": 4, + "item_price": 3.14159, + "currency": "currency value" + } + ], + "title": "a product response message title value", + "catalog_id": "catalog id value" + }, + "reply_to": { + "message_id": "message id value" + } + } + + +def test_parsing_product_response_contact_message_expects_correct_fields( + product_response_contact_message_data, +): + """Test that ProductResponseContactMessage is parsed correctly with all fields.""" + parsed_response = ProductResponseContactMessage.model_validate( + product_response_contact_message_data + ) + + assert isinstance(parsed_response, ProductResponseContactMessage) + assert parsed_response.product_response_message is not None + assert len(parsed_response.product_response_message.products) == 1 + product = parsed_response.product_response_message.products[0] + assert product.id == "product ID value" + assert product.marketplace == "marketplace value" + assert product.quantity == 4 + assert product.item_price == 3.14159 + assert product.currency == "currency value" + assert parsed_response.product_response_message.title == "a product response message title value" + assert parsed_response.product_response_message.catalog_id == "catalog id value" + assert parsed_response.reply_to is not None diff --git a/tests/unit/domains/conversation/v1/models/response/contact_message/test_text_contact_message.py b/tests/unit/domains/conversation/v1/models/response/contact_message/test_text_contact_message.py new file mode 100644 index 00000000..b67b2fa4 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/contact_message/test_text_contact_message.py @@ -0,0 +1,59 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.contact.contact_message import ( + TextContactMessage, +) + + +@pytest.fixture +def text_contact_message_data(): + return { + "text_message": { + "text": "This is a text message." + }, + "reply_to": { + "message_id": "message id value" + } + } + + +def test_parsing_text_contact_message_expects_correct_fields(text_contact_message_data): + """Test that TextContactMessage is parsed correctly with reply_to present.""" + parsed_response = TextContactMessage.model_validate(text_contact_message_data) + + assert isinstance(parsed_response, TextContactMessage) + assert parsed_response.text_message is not None + assert parsed_response.text_message.text == "This is a text message." + assert parsed_response.reply_to is not None + + +def test_parsing_text_contact_message_allows_missing_reply_to(): + """Test that TextContactMessage accepts payloads without reply_to.""" + parsed_response = TextContactMessage.model_validate( + { + "text_message": { + "text": "This is a text message." + } + } + ) + + assert isinstance(parsed_response, TextContactMessage) + assert parsed_response.text_message is not None + assert parsed_response.text_message.text == "This is a text message." + assert parsed_response.reply_to is None + + +def test_parsing_text_contact_message_allows_null_reply_to(): + """Test that TextContactMessage accepts payloads with reply_to set to null.""" + parsed_response = TextContactMessage.model_validate( + { + "text_message": { + "text": "This is a text message." + }, + "reply_to": None + } + ) + + assert isinstance(parsed_response, TextContactMessage) + assert parsed_response.text_message is not None + assert parsed_response.text_message.text == "This is a text message." + assert parsed_response.reply_to is None diff --git a/tests/unit/domains/conversation/v1/models/response/test_conversation_message_response_model.py b/tests/unit/domains/conversation/v1/models/response/test_conversation_message_response_model.py new file mode 100644 index 00000000..ebbdecff --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/test_conversation_message_response_model.py @@ -0,0 +1,176 @@ +import pytest +from datetime import datetime, timezone +from sinch.domains.conversation.models.v1.messages.response.message_response import ( + AppMessageResponse, + ContactMessageResponse, +) + + +@pytest.fixture +def contact_message_response_data(): + """Test data for ContactMessageResponse.""" + return { + "id": "CAPY123456789ABCDEFGHIJKLMNOP", + "conversation_id": "CONV987654321ZYXWVUTSRQPONMLK", + "contact_id": "CONTACT456789ABCDEFGHIJKLMNOPQR", + "direction": "UNDEFINED_DIRECTION", + "channel_identity": { + "app_id": "APP123456789ABCDEFGHIJK", + "channel": "WHATSAPP", + "identity": "+46701234567" + }, + "metadata": "test_metadata", + "accept_time": "2026-01-14T20:32:31.147Z", + "injected": True, + "sender_id": "SENDER123456789ABCDEFGHIJK", + "processing_mode": "CONVERSATION", + "contact_message": { + "channel_specific_message": { + "message_type": "nfm_reply", + "message": { + "type": "nfm_reply", + "nfm_reply": { + "name": "flow", + "response_json": "{\"key\": \"value\"}", + "body": "Message body text" + } + } + }, + "reply_to": { + "message_id": "REPLY_TO_MSG123456789ABCDEF" + } + } + } + + +@pytest.fixture +def app_message_response_data(): + """Test data for AppMessageResponse.""" + return { + "id": "APP123456789ABCDEFGHIJKLMNOP", + "conversation_id": "CONV987654321ZYXWVUTSRQPONMLK", + "contact_id": "CONTACT456789ABCDEFGHIJKLMNOPQR", + "direction": "UNDEFINED_DIRECTION", + "channel_identity": { + "app_id": "APP123456789ABCDEFGHIJK", + "channel": "WHATSAPP", + "identity": "+46701234567" + }, + "metadata": "test_metadata", + "accept_time": "2026-01-14T20:32:31.147Z", + "injected": True, + "sender_id": "SENDER123456789ABCDEFGHIJK", + "processing_mode": "CONVERSATION", + "app_message": { + "card_message": { + "choices": [ + { + "call_message": { + "phone_number": "+15551231234", + "title": "Message text" + }, + "postback_data": None + } + ], + "description": "Card description text", + "height": "UNSPECIFIED_HEIGHT", + "title": "Card title", + "media_message": { + "thumbnail_url": "https://example.com/thumbnail.jpg", + "url": "https://example.com/media.jpg", + "filename_override": "custom_filename.jpg" + }, + "message_properties": { + "whatsapp_header": "WhatsApp header text" + } + }, + "agent": { + "display_name": "Agent Name", + "type": "UNKNOWN_AGENT_TYPE", + "picture_url": "https://example.com/agent.jpg" + } + } + } + + +def test_parsing_contact_message_response_expects_correct_fields(contact_message_response_data): + """Test that ContactMessageResponse is parsed correctly with all fields.""" + parsed_response = ContactMessageResponse.model_validate(contact_message_response_data) + + # ConversationMessageResponse is a Union of AppMessageResponse and ContactMessageResponse + # In this test case, we expect a ContactMessageResponse + assert isinstance(parsed_response, ContactMessageResponse) + assert not isinstance(parsed_response, AppMessageResponse) + + assert parsed_response.id == "CAPY123456789ABCDEFGHIJKLMNOP" + assert parsed_response.conversation_id == "CONV987654321ZYXWVUTSRQPONMLK" + assert parsed_response.contact_id == "CONTACT456789ABCDEFGHIJKLMNOPQR" + assert parsed_response.direction == "UNDEFINED_DIRECTION" + assert parsed_response.metadata == "test_metadata" + assert parsed_response.contact_message is not None + assert parsed_response.contact_message.channel_specific_message is not None + assert parsed_response.contact_message.channel_specific_message.message_type == "nfm_reply" + assert parsed_response.contact_message.channel_specific_message.message.type == "nfm_reply" + assert parsed_response.contact_message.channel_specific_message.message.nfm_reply.name == "flow" + assert parsed_response.contact_message.channel_specific_message.message.nfm_reply.response_json == "{\"key\": \"value\"}" + assert parsed_response.contact_message.channel_specific_message.message.nfm_reply.body == "Message body text" + assert parsed_response.contact_message.reply_to is not None + assert parsed_response.contact_message.reply_to.message_id == "REPLY_TO_MSG123456789ABCDEF" + assert parsed_response.channel_identity is not None + assert parsed_response.channel_identity.app_id == "APP123456789ABCDEFGHIJK" + assert parsed_response.channel_identity.channel == "WHATSAPP" + assert parsed_response.channel_identity.identity == "+46701234567" + assert parsed_response.injected is True + assert parsed_response.sender_id == "SENDER123456789ABCDEFGHIJK" + assert parsed_response.processing_mode == "CONVERSATION" + + assert parsed_response.accept_time == datetime( + 2026, 1, 14, 20, 32, 31, 147000, tzinfo=timezone.utc + ) + + +def test_parsing_app_message_response_expects_correct_fields(app_message_response_data): + """Test that AppMessageResponse is parsed correctly with all fields.""" + parsed_response = AppMessageResponse.model_validate(app_message_response_data) + + # ConversationMessageResponse is a Union of AppMessageResponse and ContactMessageResponse + # In this test case, we expect an AppMessageResponse + assert isinstance(parsed_response, AppMessageResponse) + assert not isinstance(parsed_response, ContactMessageResponse) + + assert parsed_response.id == "APP123456789ABCDEFGHIJKLMNOP" + assert parsed_response.conversation_id == "CONV987654321ZYXWVUTSRQPONMLK" + assert parsed_response.contact_id == "CONTACT456789ABCDEFGHIJKLMNOPQR" + assert parsed_response.direction == "UNDEFINED_DIRECTION" + assert parsed_response.metadata == "test_metadata" + assert parsed_response.app_message is not None + assert parsed_response.app_message.card_message is not None + assert parsed_response.app_message.card_message.title == "Card title" + assert parsed_response.app_message.card_message.description == "Card description text" + assert parsed_response.app_message.card_message.height == "UNSPECIFIED_HEIGHT" + assert parsed_response.app_message.card_message.choices is not None + assert len(parsed_response.app_message.card_message.choices) == 1 + assert parsed_response.app_message.card_message.choices[0].call_message is not None + assert parsed_response.app_message.card_message.choices[0].call_message.phone_number == "+15551231234" + assert parsed_response.app_message.card_message.choices[0].call_message.title == "Message text" + assert parsed_response.app_message.card_message.media_message is not None + assert parsed_response.app_message.card_message.media_message.url == "https://example.com/media.jpg" + assert parsed_response.app_message.card_message.media_message.thumbnail_url == "https://example.com/thumbnail.jpg" + assert parsed_response.app_message.card_message.media_message.filename_override == "custom_filename.jpg" + assert parsed_response.app_message.card_message.message_properties is not None + assert parsed_response.app_message.card_message.message_properties.whatsapp_header == "WhatsApp header text" + assert parsed_response.app_message.agent is not None + assert parsed_response.app_message.agent.display_name == "Agent Name" + assert parsed_response.app_message.agent.type == "UNKNOWN_AGENT_TYPE" + assert parsed_response.app_message.agent.picture_url == "https://example.com/agent.jpg" + assert parsed_response.channel_identity is not None + assert parsed_response.channel_identity.app_id == "APP123456789ABCDEFGHIJK" + assert parsed_response.channel_identity.channel == "WHATSAPP" + assert parsed_response.channel_identity.identity == "+46701234567" + assert parsed_response.injected is True + assert parsed_response.sender_id == "SENDER123456789ABCDEFGHIJK" + assert parsed_response.processing_mode == "CONVERSATION" + + assert parsed_response.accept_time == datetime( + 2026, 1, 14, 20, 32, 31, 147000, tzinfo=timezone.utc + ) diff --git a/tests/unit/domains/conversation/v1/models/response/test_send_message_response.py b/tests/unit/domains/conversation/v1/models/response/test_send_message_response.py new file mode 100644 index 00000000..0ce5e801 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/test_send_message_response.py @@ -0,0 +1,35 @@ +import pytest +from datetime import datetime, timezone +from sinch.domains.conversation.models.v1.messages.response import ( + SendMessageResponse, +) + + +def test_parsing_send_message_response_expects_message_id_only(): + """Test that SendMessageResponse parses with required message_id only.""" + data = {"message_id": "01FC66621XXXXX119Z8PMV1QPQ"} + parsed = SendMessageResponse.model_validate(data) + + assert isinstance(parsed, SendMessageResponse) + assert parsed.message_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed.accepted_time is None + + +def test_parsing_send_message_response_expects_accepted_time(): + """Test that SendMessageResponse parses accepted_time from ISO string.""" + data = { + "message_id": "01FC66621XXXXX119Z8PMV1QPQ", + "accepted_time": "2026-01-14T20:32:31.147Z", + } + parsed = SendMessageResponse.model_validate(data) + + assert parsed.message_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed.accepted_time == datetime( + 2026, 1, 14, 20, 32, 31, 147000, tzinfo=timezone.utc + ) + + +def test_send_message_response_expects_message_id_required(): + """Test that SendMessageResponse requires message_id.""" + with pytest.raises(ValueError): + SendMessageResponse.model_validate({"accepted_time": "2026-01-14T20:32:31.147Z"}) diff --git a/tests/unit/domains/conversation/v1/models/sinch_events/events/test_conversation_sinch_event_model.py b/tests/unit/domains/conversation/v1/models/sinch_events/events/test_conversation_sinch_event_model.py new file mode 100644 index 00000000..fe5e5c13 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/sinch_events/events/test_conversation_sinch_event_model.py @@ -0,0 +1,82 @@ +"""Unit tests for Conversation Sinch Event models.""" +import pytest + +from sinch.domains.conversation.models.v1.sinch_events import ( + ConversationSinchEventBase, + MessageDeliveryReceiptEvent, + MessageDeliveryReport, + MessageInboundEvent, + MessageSubmitEvent, +) + + +@pytest.fixture +def message_delivery_report_data(): + return { + "message_id": "01EQBC1A3BEK731GY4YXEN0C2R", + "conversation_id": "01EPYATA64TMNZ1FV02JKF12JF", + "status": "QUEUED_ON_CHANNEL", + "contact_id": "01EXA07N79THJ20WSN6AS30TMW", + "channel_identity": {"channel": "WHATSAPP", "identity": "1234567890"}, + } + + +def test_message_delivery_report_expects_parsed(message_delivery_report_data): + report = MessageDeliveryReport(**message_delivery_report_data) + assert report.message_id == "01EQBC1A3BEK731GY4YXEN0C2R" + assert report.conversation_id == "01EPYATA64TMNZ1FV02JKF12JF" + assert report.status == "QUEUED_ON_CHANNEL" + assert report.contact_id == "01EXA07N79THJ20WSN6AS30TMW" + assert report.channel_identity is not None + assert report.channel_identity.channel == "WHATSAPP" + assert report.channel_identity.identity == "1234567890" + + +def test_message_delivery_receipt_event_expects_parsed(message_delivery_report_data): + payload = { + "app_id": "app1", + "project_id": "proj1", + "accepted_time": "2020-11-17T15:09:11.659Z", + "message_delivery_report": message_delivery_report_data, + } + event = MessageDeliveryReceiptEvent(**payload) + assert event.app_id == "app1" + assert event.message_delivery_report is not None + assert event.message_delivery_report.message_id == "01EQBC1A3BEK731GY4YXEN0C2R" + + +def test_message_inbound_event_expects_parsed(): + payload = { + "app_id": "app1", + "message": { + "contact_id": "contact1", + "contact_message": {"text_message": {"text": "Hello"}}, + "channel_identity": {"channel": "SMS", "identity": "+15551234567"}, + }, + } + event = MessageInboundEvent(**payload) + assert event.message is not None + assert event.message.contact_id == "contact1" + assert event.message.contact_message.text_message.text == "Hello" + + +def test_message_submit_event_expects_parsed(): + payload = { + "app_id": "app1", + "message_submit_notification": { + "contact_id": "contact1", + "channel_identity": {"channel": "MESSENGER", "identity": "123"}, + }, + } + event = MessageSubmitEvent(**payload) + assert event.message_submit_notification is not None + assert event.message_submit_notification.contact_id == "contact1" + + +def test_conversation_sinch_event_base_optional_fields(): + payload = {"app_id": "app1"} + event = ConversationSinchEventBase(**payload) + assert event.app_id == "app1" + assert event.project_id is None + assert event.accepted_time is None + assert event.event_time is None diff --git a/tests/unit/domains/conversation/v1/sinch_events/test_conversation_sinch_event.py b/tests/unit/domains/conversation/v1/sinch_events/test_conversation_sinch_event.py new file mode 100644 index 00000000..16d416ca --- /dev/null +++ b/tests/unit/domains/conversation/v1/sinch_events/test_conversation_sinch_event.py @@ -0,0 +1,129 @@ +"""Unit tests for Conversation API Sinch Events (signature validation and parse_event).""" +from datetime import datetime, timezone + +import pytest + +from sinch.domains.conversation.sinch_events.v1 import ConversationSinchEvent +from sinch.domains.conversation.models.v1.sinch_events import ( + MessageDeliveryReceiptEvent, + MessageInboundEvent, + MessageSubmitEvent, +) + + +@pytest.fixture +def callback_secret(): + return "foo_secret1234" + + +@pytest.fixture +def conversation_sinch_event(callback_secret): + return ConversationSinchEvent(callback_secret) + + +@pytest.fixture +def sample_body(): + return ( + '{"app_id":"01EB37HMH1M6SV18BSNS3G135H","accepted_time":"2020-11-17T15:09:11.659Z",' + '"project_id":"c36f3d3d-1513-2edd-ae42-11995557ff61",' + '"message_delivery_report":{"message_id":"01EQBC1A3BEK731GY4YXEN0C2R",' + '"conversation_id":"01EPYATA64TMNZ1FV02JKF12JF","status":"QUEUED_ON_CHANNEL",' + '"contact_id":"01EXA07N79THJ20WSN6AS30TMW"}}' + ) + + +VALID_SIGNATURE_HEADERS = { + "x-sinch-webhook-signature": "Yc+3R1pIS78xLASybulhs8BsSo9BPB3Pr92QCUoczfk=", + "x-sinch-webhook-signature-nonce": "01FJA8B4A7BM43YGWSG9GBV067", + "x-sinch-webhook-signature-timestamp": "1634579353", +} + + +def test_validate_authentication_header_valid_expects_true(conversation_sinch_event, sample_body): + assert conversation_sinch_event.validate_authentication_header( + VALID_SIGNATURE_HEADERS, sample_body + ) is True + + +def test_validate_authentication_header_missing_headers_expects_false(conversation_sinch_event, sample_body): + assert conversation_sinch_event.validate_authentication_header({}, sample_body) is False + + +def test_validate_authentication_header_invalid_signature_expects_false(conversation_sinch_event, sample_body): + headers = { + "x-sinch-webhook-signature": "invalid", + "x-sinch-webhook-signature-nonce": "01FJA8B4A7BM43YGWSG9GBV067", + "x-sinch-webhook-signature-timestamp": "1634579353", + } + assert conversation_sinch_event.validate_authentication_header(headers, sample_body) is False + + +def test_parse_event_message_delivery_expects_message_delivery_receipt_event(conversation_sinch_event): + payload = { + "app_id": "01EB37HMH1M6SV18BSNS3G135H", + "project_id": "c36f3d3d-1513-2edd-ae42-11995557ff61", + "accepted_time": "2020-11-17T15:09:11.659Z", + "event_time": "2020-11-17T15:09:13.267185Z", + "message_delivery_report": { + "message_id": "01EQBC1A3BEK731GY4YXEN0C2R", + "conversation_id": "01EPYATA64TMNZ1FV02JKF12JF", + "status": "QUEUED_ON_CHANNEL", + "contact_id": "01EXA07N79THJ20WSN6AS30TMW", + }, + } + event = conversation_sinch_event.parse_event(payload) + assert isinstance(event, MessageDeliveryReceiptEvent) + assert event.message_delivery_report is not None + assert event.message_delivery_report.message_id == "01EQBC1A3BEK731GY4YXEN0C2R" + assert event.message_delivery_report.status == "QUEUED_ON_CHANNEL" + assert event.accepted_time == datetime(2020, 11, 17, 15, 9, 11, 659000, tzinfo=timezone.utc) + + +def test_parse_event_message_inbound_expects_message_inbound_event(conversation_sinch_event): + payload = { + "app_id": "01EB37HMH1M6SV18BSNS3G135H", + "project_id": "c36f3d3d-1513-2edd-ae42-11995557ff61", + "accepted_time": "2020-11-17T15:09:11.659Z", + "message": { + "contact_id": "01EXA07N79THJ20WSN6AS30TMW", + "contact_message": {"text_message": {"text": "Hello"}}, + "channel_identity": {"channel": "WHATSAPP", "identity": "1234567890"}, + }, + } + event = conversation_sinch_event.parse_event(payload) + assert isinstance(event, MessageInboundEvent) + assert event.message is not None + assert event.message.contact_id == "01EXA07N79THJ20WSN6AS30TMW" + assert event.message.contact_message is not None + assert hasattr(event.message.contact_message, "text_message") + assert event.message.contact_message.text_message.text == "Hello" + + +def test_parse_event_message_submit_expects_message_submit_event(conversation_sinch_event): + payload = { + "app_id": "01EB37HMH1M6SV18BSNS3G135H", + "project_id": "c36f3d3d-1513-2edd-ae42-11995557ff61", + "accepted_time": "2020-11-17T15:09:11.659Z", + "message_submit_notification": { + "contact_id": "01EXA07N79THJ20WSN6AS30TMW", + "channel_identity": {"channel": "WHATSAPP", "identity": "1234567890"}, + "submitted_message": {"text_message": {"text": "Hi"}}, + }, + } + event = conversation_sinch_event.parse_event(payload) + assert isinstance(event, MessageSubmitEvent) + assert event.message_submit_notification is not None + assert event.message_submit_notification.contact_id == "01EXA07N79THJ20WSN6AS30TMW" + + +def test_parse_event_json_string_expects_parsed(conversation_sinch_event): + payload_str = '{"app_id":"app1","message_delivery_report":{"status":"DELIVERED"}}' + event = conversation_sinch_event.parse_event(payload_str) + assert isinstance(event, MessageDeliveryReceiptEvent) + assert event.app_id == "app1" + assert event.message_delivery_report.status == "DELIVERED" + + +def test_parse_event_invalid_json_expects_value_error(conversation_sinch_event): + with pytest.raises(ValueError, match="Failed to decode JSON"): + conversation_sinch_event.parse_event("not json") diff --git a/tests/unit/domains/conversation/v1/test_conversation_messages.py b/tests/unit/domains/conversation/v1/test_conversation_messages.py new file mode 100644 index 00000000..5791e003 --- /dev/null +++ b/tests/unit/domains/conversation/v1/test_conversation_messages.py @@ -0,0 +1,342 @@ +""" +Unit tests for Conversation Messages API +""" +from unittest.mock import MagicMock +import pytest +from sinch.domains.conversation.conversation import Conversation +from sinch.domains.conversation.api.v1 import Messages +from sinch.core.pagination import TokenBasedPaginator +from sinch.domains.conversation.api.v1.internal import ( + DeleteMessageEndpoint, + GetMessageEndpoint, + ListMessagesEndpoint, + SendMessageEndpoint, + UpdateMessageMetadataEndpoint, +) +from sinch.domains.conversation.models.v1.messages.internal import ( + ListMessagesResponse, +) +from sinch.domains.conversation.models.v1.messages.internal.request import ( + ListMessagesRequest, + MessageIdRequest, + UpdateMessageMetadataRequest, + SendMessageRequest, +) +from sinch.domains.conversation.models.v1.messages.response import ( + SendMessageResponse, +) + + +@pytest.fixture +def mock_send_message_response(): + return SendMessageResponse( + message_id="01FC66621SND04119Z8PMV1QPQ", + ) + + +@pytest.fixture +def mock_conversation_message_response(): + response = MagicMock() + response.id = "01FC66621GET02119Z8PMV1QPQ" + return response + + +def test_conversation_expects_messages_attribute(mock_sinch_client_conversation): + """Test that Conversation exposes .messages as Messages instance.""" + conversation = Conversation(mock_sinch_client_conversation) + assert isinstance(conversation.messages, Messages) + + +def test_messages_delete_expects_correct_request( + mock_sinch_client_conversation, mocker +): + """Test that delete sends the correct request.""" + mock_sinch_client_conversation.configuration.transport.request.return_value = ( + None + ) + spy_endpoint = mocker.spy(DeleteMessageEndpoint, "__init__") + + message_id = "01FC66621DEL01119Z8PMV1QPQ" + conversation = Conversation(mock_sinch_client_conversation) + conversation.messages.delete(message_id=message_id) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], MessageIdRequest) + assert kwargs["request_data"].message_id == message_id + mock_sinch_client_conversation.configuration.transport.request.assert_called_once() + + +def test_messages_delete_with_messages_source_expects_correct_request( + mock_sinch_client_conversation, mocker +): + """Test that delete with messages_source sends the correct request.""" + mock_sinch_client_conversation.configuration.transport.request.return_value = ( + None + ) + spy_endpoint = mocker.spy(DeleteMessageEndpoint, "__init__") + + conversation = Conversation(mock_sinch_client_conversation) + message_id = "01FC66621DL205119Z8PMV1QPQ" + conversation.messages.delete( + message_id=message_id, + messages_source="DISPATCH_SOURCE", + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["request_data"].message_id == message_id + assert kwargs["request_data"].messages_source == "DISPATCH_SOURCE" + + +def test_messages_list_expects_correct_request( + mock_sinch_client_conversation, mocker +): + """ + Test that the Messages.list() method sends the correct request + and handles the response properly. + """ + mock_response = ListMessagesResponse(messages=[], next_page_token=None) + mock_sinch_client_conversation.configuration.transport.request.return_value = ( + mock_response + ) + + # Spy on the ListMessagesEndpoint to capture calls + spy_endpoint = mocker.spy(ListMessagesEndpoint, "__init__") + + conversation = Conversation(mock_sinch_client_conversation) + response = conversation.messages.list(page_size=10) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == ListMessagesRequest(page_size=10) + + assert isinstance(response, TokenBasedPaginator) + assert hasattr(response, "has_next_page") + assert response.result == mock_response + mock_sinch_client_conversation.configuration.transport.request.assert_called_once() + + +def test_messages_get_expects_correct_request( + mock_sinch_client_conversation, mock_conversation_message_response, mocker +): + """Test that get sends the correct request and returns the response.""" + message_id = "01FC66621GET02119Z8PMV1QPQ" + mock_conversation_message_response.id = message_id + mock_sinch_client_conversation.configuration.transport.request.return_value = ( + mock_conversation_message_response + ) + spy_endpoint = mocker.spy(GetMessageEndpoint, "__init__") + + conversation = Conversation(mock_sinch_client_conversation) + response = conversation.messages.get(message_id=message_id) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], MessageIdRequest) + assert kwargs["request_data"].message_id == message_id + assert response.id == message_id + mock_sinch_client_conversation.configuration.transport.request.assert_called_once() + + +def test_messages_update_expects_correct_request( + mock_sinch_client_conversation, mock_conversation_message_response, mocker +): + """Test that update sends the correct request and returns the response.""" + message_id = "01FC66621UPD03119Z8PMV1QPQ" + mock_conversation_message_response.id = message_id + mock_sinch_client_conversation.configuration.transport.request.return_value = ( + mock_conversation_message_response + ) + spy_endpoint = mocker.spy(UpdateMessageMetadataEndpoint, "__init__") + + conversation = Conversation(mock_sinch_client_conversation) + response = conversation.messages.update( + message_id=message_id, + metadata="updated-metadata", + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], UpdateMessageMetadataRequest) + assert kwargs["request_data"].message_id == message_id + assert kwargs["request_data"].metadata == "updated-metadata" + assert response.id == message_id + mock_sinch_client_conversation.configuration.transport.request.assert_called_once() + + +def test_messages_send_expects_correct_request( + mock_sinch_client_conversation, mock_send_message_response, mocker +): + """Test that send sends the correct request and returns SendMessageResponse.""" + mock_sinch_client_conversation.configuration.transport.request.return_value = ( + mock_send_message_response + ) + spy_endpoint = mocker.spy(SendMessageEndpoint, "__init__") + + conversation = Conversation(mock_sinch_client_conversation) + response = conversation.messages.send( + app_id="APP_ID", + message={"text_message": {"text": "Hello"}}, + recipient_identities=[ + {"channel": "RCS", "identity": "+46701234567"}, + ], + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], SendMessageRequest) + assert kwargs["request_data"].app_id == "APP_ID" + assert kwargs["request_data"].message.text_message is not None + assert kwargs["request_data"].message.text_message.text == "Hello" + assert isinstance(response, SendMessageResponse) + assert response.message_id == "01FC66621SND04119Z8PMV1QPQ" + mock_sinch_client_conversation.configuration.transport.request.assert_called_once() + + +def test_messages_send_with_contact_id_expects_correct_request( + mock_sinch_client_conversation, mock_send_message_response, mocker +): + """Test that send with contact_id builds recipient correctly.""" + mock_sinch_client_conversation.configuration.transport.request.return_value = ( + mock_send_message_response + ) + spy_endpoint = mocker.spy(SendMessageEndpoint, "__init__") + + conversation = Conversation(mock_sinch_client_conversation) + response = conversation.messages.send( + app_id="APP_ID", + message={"text_message": {"text": "Hi"}}, + contact_id="CONTACT_123", + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert isinstance(kwargs["request_data"], SendMessageRequest) + assert kwargs["request_data"].app_id == "APP_ID" + assert kwargs["request_data"].recipient.contact_id == "CONTACT_123" + assert isinstance(response, SendMessageResponse) + mock_sinch_client_conversation.configuration.transport.request.assert_called_once() + + +def test_messages_send_text_message_expects_correct_request( + mock_sinch_client_conversation, mock_send_message_response, mocker +): + """Test that send_text_message sends the correct request.""" + mock_sinch_client_conversation.configuration.transport.request.return_value = ( + mock_send_message_response + ) + spy_endpoint = mocker.spy(SendMessageEndpoint, "__init__") + + conversation = Conversation(mock_sinch_client_conversation) + response = conversation.messages.send_text_message( + app_id="APP_ID", + text="Hello", + recipient_identities=[ + {"channel": "RCS", "identity": "+46701234567"}, + ], + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert isinstance(kwargs["request_data"], SendMessageRequest) + assert kwargs["request_data"].app_id == "APP_ID" + assert kwargs["request_data"].message.text_message is not None + assert kwargs["request_data"].message.text_message.text == "Hello" + assert isinstance(response, SendMessageResponse) + assert response.message_id == "01FC66621SND04119Z8PMV1QPQ" + mock_sinch_client_conversation.configuration.transport.request.assert_called_once() + + +def test_messages_send_card_message_expects_correct_request( + mock_sinch_client_conversation, mock_send_message_response, mocker +): + """Test that send_card_message sends the correct request.""" + mock_sinch_client_conversation.configuration.transport.request.return_value = ( + mock_send_message_response + ) + spy_endpoint = mocker.spy(SendMessageEndpoint, "__init__") + + conversation = Conversation(mock_sinch_client_conversation) + response = conversation.messages.send_card_message( + app_id="APP_ID", + card_message={"title": "Card title", "description": "Description"}, + recipient_identities=[ + {"channel": "RCS", "identity": "+46701234567"}, + ], + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert isinstance(kwargs["request_data"], SendMessageRequest) + assert kwargs["request_data"].app_id == "APP_ID" + assert kwargs["request_data"].message.card_message is not None + assert kwargs["request_data"].message.card_message.title == "Card title" + assert isinstance(response, SendMessageResponse) + mock_sinch_client_conversation.configuration.transport.request.assert_called_once() + + +def test_messages_send_choice_message_expects_correct_request( + mock_sinch_client_conversation, mock_send_message_response, mocker +): + """Test that send_choice_message sends the correct request.""" + mock_sinch_client_conversation.configuration.transport.request.return_value = ( + mock_send_message_response + ) + spy_endpoint = mocker.spy(SendMessageEndpoint, "__init__") + + conversation = Conversation(mock_sinch_client_conversation) + response = conversation.messages.send_choice_message( + app_id="APP_ID", + choice_message={ + "text_message": {"text": "Choose:"}, + "choices": [ + {"text_message": {"text": "Option A"}, "postback_data": "a"}, + ], + }, + recipient_identities=[ + {"channel": "RCS", "identity": "+46701234567"}, + ], + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert isinstance(kwargs["request_data"], SendMessageRequest) + assert kwargs["request_data"].app_id == "APP_ID" + assert kwargs["request_data"].message.choice_message is not None + assert kwargs["request_data"].message.choice_message.text_message.text == "Choose:" + assert len(kwargs["request_data"].message.choice_message.choices) == 1 + assert isinstance(response, SendMessageResponse) + mock_sinch_client_conversation.configuration.transport.request.assert_called_once() + + +def test_messages_send_media_message_expects_correct_request( + mock_sinch_client_conversation, mock_send_message_response, mocker +): + """Test that send_media_message sends the correct request.""" + mock_sinch_client_conversation.configuration.transport.request.return_value = ( + mock_send_message_response + ) + spy_endpoint = mocker.spy(SendMessageEndpoint, "__init__") + + conversation = Conversation(mock_sinch_client_conversation) + response = conversation.messages.send_media_message( + app_id="APP_ID", + media_message={"url": "https://example.com/image.jpg"}, + recipient_identities=[ + {"channel": "RCS", "identity": "+46701234567"}, + ], + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["request_data"].message.media_message is not None + assert kwargs["request_data"].message.media_message.url == "https://example.com/image.jpg" + assert isinstance(response, SendMessageResponse) + mock_sinch_client_conversation.configuration.transport.request.assert_called_once() diff --git a/tests/unit/domains/conversation/v1/utils/test_message_helpers.py b/tests/unit/domains/conversation/v1/utils/test_message_helpers.py new file mode 100644 index 00000000..1674b68e --- /dev/null +++ b/tests/unit/domains/conversation/v1/utils/test_message_helpers.py @@ -0,0 +1,147 @@ +import pytest +from sinch.domains.conversation.api.v1.utils import ( + build_recipient_dict, + coerce_recipient, + split_send_kwargs, +) +from sinch.domains.conversation.models.v1.messages.internal.request import ( + Recipient, +) + + +class TestBuildRecipientDict: + + @pytest.mark.parametrize( + "contact_id,recipient_identities,expected", + [ + ("contact-123", None, {"contact_id": "contact-123"}), + ( + None, + [{"channel": "RCS", "identity": "+46701234567"}], + {"channel_identities": [{"channel": "RCS", "identity": "+46701234567"}]}, + ), + ], + ) + def test_build_recipient_dict_expects_valid_input_returns_recipient_dict( + self, contact_id, recipient_identities, expected + ): + """Test that providing contact_id or recipient_identities returns the expected dict.""" + result = build_recipient_dict( + contact_id=contact_id, recipient_identities=recipient_identities + ) + assert result == expected + + @pytest.mark.parametrize( + "contact_id,recipient_identities,error_substring", + [ + ( + "contact-123", + [{"channel": "RCS", "identity": "+46701234567"}], + "Cannot specify both", + ), + (None, None, "Must provide either"), + ], + ) + def test_build_recipient_dict_expects_value_error_when_invalid( + self, contact_id, recipient_identities, error_substring + ): + """Test that invalid combinations raise ValueError with expected message.""" + with pytest.raises(ValueError) as excinfo: + build_recipient_dict( + contact_id=contact_id, recipient_identities=recipient_identities + ) + assert error_substring in str(excinfo.value) + + +class TestCoerceRecipient: + + def test_coerce_recipient_expects_recipient_instance_returned_unchanged(self): + """Passing a Recipient returns the same instance with contact_id preserved.""" + recipient_input = Recipient(contact_id="contact-123") + result = coerce_recipient(recipient_input) + assert isinstance(result, Recipient) + assert result is recipient_input + assert result.contact_id == "contact-123" + + def test_coerce_recipient_expects_dict_with_contact_id_converted_to_recipient( + self, + ): + """Passing a dict with contact_id returns a new Recipient with that contact_id.""" + recipient_input = {"contact_id": "contact-456"} + result = coerce_recipient(recipient_input) + assert isinstance(result, Recipient) + assert result is not recipient_input + assert result.contact_id == "contact-456" + + def test_coerce_recipient_expects_dict_with_channel_identities_converted_to_recipient( + self, + ): + """Passing a dict with channel_identities returns Recipient with identified_by.""" + recipient_input = { + "channel_identities": [{"channel": "RCS", "identity": "+46701234567"}] + } + result = coerce_recipient(recipient_input) + assert isinstance(result, Recipient) + assert result.identified_by is not None + assert len(result.identified_by.channel_identities) == 1 + assert ( + result.identified_by.channel_identities[0].identity == "+46701234567" + ) + + def test_coerce_recipient_expects_dict_with_identified_by_converted_to_recipient( + self, + ): + """Passing a dict with identified_by.channel_identities returns Recipient.""" + recipient_input = { + "identified_by": { + "channel_identities": [ + {"channel": "RCS", "identity": "+46701234567"}, + ] + } + } + result = coerce_recipient(recipient_input) + assert isinstance(result, Recipient) + assert result.identified_by is not None + assert len(result.identified_by.channel_identities) == 1 + assert ( + result.identified_by.channel_identities[0].identity == "+46701234567" + ) + + +class TestSplitSendKwargs: + + @pytest.mark.parametrize( + "kwargs,expected_message_kwargs,expected_request_kwargs", + [ + ({}, {}, {}), + ( + {"text_message": {"text": "Hello"}}, + {"text_message": {"text": "Hello"}}, + {}, + ), + ( + {"ttl": 10, "event_destination_target": "https://example.com/callback"}, + {}, + {"ttl": 10, "event_destination_target": "https://example.com/callback"}, + ), + ( + { + "text_message": {"text": "Hi"}, + "ttl": 30, + "media_message": {"url": "https://example.com/image.jpg"}, + }, + { + "text_message": {"text": "Hi"}, + "media_message": {"url": "https://example.com/image.jpg"}, + }, + {"ttl": 30}, + ), + ], + ) + def test_split_send_kwargs_expects_kwargs_split_into_message_and_request( + self, kwargs, expected_message_kwargs, expected_request_kwargs + ): + """Test that kwargs are split into message_kwargs and request_kwargs.""" + message_kwargs, request_kwargs = split_send_kwargs(kwargs) + assert message_kwargs == expected_message_kwargs + assert request_kwargs == expected_request_kwargs diff --git a/tests/unit/domains/number_lookup/v1/endpoints/test_lookup_number_endpoint.py b/tests/unit/domains/number_lookup/v1/endpoints/test_lookup_number_endpoint.py new file mode 100644 index 00000000..5d7b63ba --- /dev/null +++ b/tests/unit/domains/number_lookup/v1/endpoints/test_lookup_number_endpoint.py @@ -0,0 +1,125 @@ +import json +from unittest.mock import MagicMock +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.number_lookup.api.v1.internal import LookupNumberEndpoint +from sinch.domains.number_lookup.models.v1.internal import LookupNumberRequest +from sinch.domains.number_lookup.models.v1.response import LookupNumberResponse + + +@pytest.fixture +def request_data(): + return LookupNumberRequest( + number="+12312312312", features=["LineType", "SimSwap"] + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "line": { + "carrier": "T-Mobile USA", + "type": "Mobile", + "mobileCountryCode": "310", + "mobileNetworkCode": "260", + "ported": True, + "portingDate": "2000-01-01T00:00:00+00:00", + }, + "simSwap": {"swapped": True, "swapPeriod": "SP24H"}, + "countryCode": "US", + "traceId": "84c1fd4063c38d9f3900d06e56542d48", + "number": "+12312312312", + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return LookupNumberEndpoint("test_project_id", request_data) + + +def test_build_url_expects_correct_url( + endpoint, mock_sinch_client_number_lookup +): + """Check if endpoint URL is constructed correctly.""" + expected_url = ( + "https://lookup.api.sinch.com/v2/projects/test_project_id/lookups" + ) + assert endpoint.build_url(mock_sinch_client_number_lookup) == expected_url + + +def test_build_url_with_custom_domain_expects_overridden_url(): + """Check if endpoint URL uses custom domain when configured.""" + request_data = LookupNumberRequest( + number="+12312312312", features=["LineType", "SimSwap"] + ) + endpoint = LookupNumberEndpoint("test_project_id", request_data) + + class MockConfiguration: + number_lookup_origin = "https://custom.lookup.domain.com" + project_id = "test_project_id" + transport = MagicMock() + transport.request = MagicMock() + + class MockSinchClient: + configuration = MockConfiguration() + + mock_sinch = MockSinchClient() + + expected_url = ( + "https://custom.lookup.domain.com/v2/projects/test_project_id/lookups" + ) + assert endpoint.build_url(mock_sinch) == expected_url + + +def test_request_body_expects_correct_json(endpoint): + """Check if request body is correctly serialized to JSON.""" + body = endpoint.request_body() + parsed_body = json.loads(body) + assert parsed_body["number"] == "+12312312312" + assert parsed_body["features"] == ["LineType", "SimSwap"] + + +def test_request_body_with_rnd_options_expects_correct_json(): + """Check if request body includes RND options when provided.""" + request_data = LookupNumberRequest( + number="+12312312312", + features=["RND"], + rnd_feature_options={"contact_date": "2025-01-01"}, + ) + endpoint = LookupNumberEndpoint("test_project_id", request_data) + body = endpoint.request_body() + parsed_body = json.loads(body) + assert parsed_body["number"] == "+12312312312" + assert parsed_body["features"] == ["RND"] + assert parsed_body["rndFeatureOptions"] == {"contact_date": "2025-01-01"} + + +def test_request_body_excludes_none_expects_correct_json(): + """Check if None values are excluded from request body.""" + request_data = LookupNumberRequest(number="+12312312312") + endpoint = LookupNumberEndpoint("test_project_id", request_data) + body = endpoint.request_body() + parsed_body = json.loads(body) + assert "number" in parsed_body + assert "features" not in parsed_body + assert "rndFeatureOptions" not in parsed_body + + +def test_handle_response_success_expects_valid_response( + endpoint, mock_response +): + """Check if successful response is handled correctly.""" + response = endpoint.handle_response(mock_response) + assert isinstance(response, LookupNumberResponse) + assert response.number == "+12312312312" + assert response.country_code == "US" + assert response.trace_id == "84c1fd4063c38d9f3900d06e56542d48" + assert response.line is not None + assert response.line.carrier == "T-Mobile USA" + assert response.line.type == "Mobile" + assert response.sim_swap is not None + assert response.sim_swap.swapped is True diff --git a/tests/unit/domains/number_lookup/v1/models/shared/test_line_model.py b/tests/unit/domains/number_lookup/v1/models/shared/test_line_model.py new file mode 100644 index 00000000..c3ce8c14 --- /dev/null +++ b/tests/unit/domains/number_lookup/v1/models/shared/test_line_model.py @@ -0,0 +1,38 @@ +from datetime import datetime, timezone +from sinch.domains.number_lookup.models.v1.shared import Line + + +def test_line_valid_expects_parsed_data(): + """Test a valid instance of Line""" + data = { + "carrier": "T-Mobile USA", + "type": "Mobile", + "mobileCountryCode": "310", + "mobileNetworkCode": "260", + "ported": True, + "portingDate": "2024-06-15T14:30:00+00:00", + } + line = Line(**data) + + assert line.carrier == "T-Mobile USA" + assert line.type == "Mobile" + assert line.mobile_country_code == "310" + assert line.mobile_network_code == "260" + assert line.ported is True + expected_porting_date = datetime( + 2024, 6, 15, 14, 30, 0, tzinfo=timezone.utc + ) + assert line.porting_date == expected_porting_date + + +def test_line_optional_fields_expects_parsed_data(): + """Test missing optional fields in Line""" + data = {} + line = Line(**data) + + assert line.carrier is None + assert line.type is None + assert line.mobile_country_code is None + assert line.mobile_network_code is None + assert line.ported is None + assert line.porting_date is None diff --git a/tests/unit/domains/number_lookup/v1/models/shared/test_lookup_error_model.py b/tests/unit/domains/number_lookup/v1/models/shared/test_lookup_error_model.py new file mode 100644 index 00000000..30a5f355 --- /dev/null +++ b/tests/unit/domains/number_lookup/v1/models/shared/test_lookup_error_model.py @@ -0,0 +1,32 @@ +from sinch.domains.number_lookup.models.v1.shared import LookupError + + +def test_lookup_error_valid_expects_parsed_data(): + """ + Test a valid instance of LookupError + """ + data = { + "status": 100, + "title": "Feature Disabled", + "detail": "VoIPDetection feature is currently disabled.", + "type": "validation_error", + } + error = LookupError(**data) + + assert error.status == 100 + assert error.title == "Feature Disabled" + assert error.detail == "VoIPDetection feature is currently disabled." + assert error.type == "validation_error" + + +def test_lookup_error_optional_fields_expects_parsed_data(): + """ + Test missing optional fields in LookupError + """ + data = {} + error = LookupError(**data) + + assert error.status is None + assert error.title is None + assert error.detail is None + assert error.type is None diff --git a/tests/unit/domains/number_lookup/v1/models/shared/test_rnd_model.py b/tests/unit/domains/number_lookup/v1/models/shared/test_rnd_model.py new file mode 100644 index 00000000..5723ce43 --- /dev/null +++ b/tests/unit/domains/number_lookup/v1/models/shared/test_rnd_model.py @@ -0,0 +1,17 @@ +from sinch.domains.number_lookup.models.v1.shared import Rnd + + +def test_rnd_valid_expects_parsed_data(): + """Test a valid instance of Rnd""" + data = {"disconnected": True} + rnd = Rnd(**data) + + assert rnd.disconnected is True + + +def test_rnd_optional_fields_expects_parsed_data(): + """Test missing optional fields in Rnd""" + data = {} + rnd = Rnd(**data) + + assert rnd.disconnected is None diff --git a/tests/unit/domains/number_lookup/v1/models/shared/test_sim_swap_model.py b/tests/unit/domains/number_lookup/v1/models/shared/test_sim_swap_model.py new file mode 100644 index 00000000..7a448d0c --- /dev/null +++ b/tests/unit/domains/number_lookup/v1/models/shared/test_sim_swap_model.py @@ -0,0 +1,38 @@ +from sinch.domains.number_lookup.models.v1.shared import SimSwap + + +def test_sim_swap_valid_expects_parsed_data(): + """Test a valid instance of SimSwap""" + data = { + "swapped": True, + "swapPeriod": "SP24H", + } + sim_swap = SimSwap(**data) + + assert sim_swap.swapped is True + assert sim_swap.swap_period == "SP24H" + + +def test_sim_swap_error_expects_parsed_data(): + """Test a valid instance of SimSwap with error""" + data = { + "error": { + "status": 100, + "title": "Feature Disabled", + "detail": "SimSwap feature is currently disabled.", + } + } + sim_swap = SimSwap(**data) + + assert sim_swap.error.status == 100 + assert sim_swap.error.title == "Feature Disabled" + assert sim_swap.error.detail == "SimSwap feature is currently disabled." + + +def test_sim_swap_optional_fields_expects_parsed_data(): + """Test missing optional fields in SimSwap""" + data = {} + sim_swap = SimSwap(**data) + + assert sim_swap.swapped is None + assert sim_swap.swap_period is None diff --git a/tests/unit/domains/number_lookup/v1/models/shared/test_voip_detection_model.py b/tests/unit/domains/number_lookup/v1/models/shared/test_voip_detection_model.py new file mode 100644 index 00000000..472b7682 --- /dev/null +++ b/tests/unit/domains/number_lookup/v1/models/shared/test_voip_detection_model.py @@ -0,0 +1,17 @@ +from sinch.domains.number_lookup.models.v1.shared import VoIPDetection + + +def test_voip_detection_valid_expects_parsed_data(): + """Test a valid instance of VoIPDetection""" + data = {"probability": "High"} + voip_detection = VoIPDetection(**data) + + assert voip_detection.probability == "High" + + +def test_voip_detection_optional_fields_expects_parsed_data(): + """Test missing optional fields in VoIPDetection""" + data = {} + voip_detection = VoIPDetection(**data) + + assert voip_detection.probability is None diff --git a/tests/unit/domains/number_lookup/v1/models/test_lookup_request_model.py b/tests/unit/domains/number_lookup/v1/models/test_lookup_request_model.py new file mode 100644 index 00000000..2505bfab --- /dev/null +++ b/tests/unit/domains/number_lookup/v1/models/test_lookup_request_model.py @@ -0,0 +1,120 @@ +from datetime import datetime, date +from sinch.domains.number_lookup.models.v1.internal import LookupNumberRequest + + +def test_lookup_number_request_minimal_expects_valid_request(): + """Test minimal lookup request with only number.""" + request = LookupNumberRequest(number="+15551234567") + assert request.number == "+15551234567" + assert request.features is None + assert request.rnd_feature_options is None + + +def test_lookup_number_request_with_features_expects_valid_request(): + """Test lookup request with features.""" + request = LookupNumberRequest( + number="+15552345678", features=["LineType", "SimSwap"] + ) + assert request.number == "+15552345678" + assert request.features == ["LineType", "SimSwap"] + + +def test_lookup_number_request_with_rnd_options_expects_valid_request(): + """Test lookup request with RND feature options.""" + request = LookupNumberRequest( + number="+15553456789", + features=["RND"], + rnd_feature_options={"contact_date": "2025-01-01"}, + ) + assert request.number == "+15553456789" + assert request.features == ["RND"] + assert request.rnd_feature_options == {"contact_date": "2025-01-01"} + + +def test_lookup_number_request_with_rnd_options_datetime_expects_valid_request(): + """Test lookup request with RND feature options using datetime object.""" + contact_date = datetime(2025, 1, 15) + request = LookupNumberRequest( + number="+15553456789", + features=["RND"], + rnd_feature_options={"contact_date": contact_date}, + ) + assert request.number == "+15553456789" + assert request.features == ["RND"] + # The datetime should be stored as-is in the model + assert request.rnd_feature_options == {"contact_date": contact_date} + + # When serialized, datetime should be converted to ISO 8601 date format + dumped = request.model_dump(by_alias=True) + assert dumped["rndFeatureOptions"]["contact_date"] == "2025-01-15" + + +def test_lookup_number_request_with_rnd_options_date_object_expects_iso_format(): + """Test that date objects in rnd_feature_options are converted to ISO 8601 format.""" + contact_date = datetime(2025, 1, 15, 10, 10, 10) + request = LookupNumberRequest( + number="+15553456785", + features=["RND"], + rnd_feature_options={"contact_date": contact_date}, + ) + assert request.number == "+15553456785" + dumped = request.model_dump(by_alias=True) + assert dumped["rndFeatureOptions"]["contact_date"] == "2025-01-15" + + +def test_lookup_number_request_with_rnd_options_different_string_format_passed_directly(): + """Test that different string formats in rnd_feature_options are passed directly to backend.""" + formats = [ + "2025/01/15", + "01-15-2025", + "15.01.2025", + "2025-01-15T00:00:00Z", + ] + + for date_format in formats: + request = LookupNumberRequest( + number="+15553456785", + features=["RND"], + rnd_feature_options={"contact_date": date_format}, + ) + dumped = request.model_dump(by_alias=True) + # String should be passed directly to backend without modification + assert dumped["rndFeatureOptions"]["contact_date"] == date_format + + +def test_lookup_number_request_with_rnd_options_string_passed_directly(): + """Test that string values in rnd_feature_options are passed directly to backend.""" + request1 = LookupNumberRequest( + number="+15553456789", + features=["RND"], + rnd_feature_options={"contact_date": "2025-01-15"}, + ) + dumped1 = request1.model_dump(by_alias=True) + assert dumped1["rndFeatureOptions"]["contact_date"] == "2025-01-15" + + +def test_lookup_number_request_model_dump_expects_camel_case(): + """Test that model dump converts to camelCase.""" + request = LookupNumberRequest( + number="+15554567890", + features=["LineType"], + rnd_feature_options={"contact_date": "2025-01-01"}, + ) + dumped = request.model_dump(by_alias=True) + assert "number" in dumped + assert "features" in dumped + assert "rndFeatureOptions" in dumped + assert "rnd_feature_options" not in dumped + + +def test_lookup_number_request_all_features_expects_valid_request(): + """Test lookup request with all available features.""" + request = LookupNumberRequest( + number="+15555678901", + features=["LineType", "SimSwap", "VoIPDetection", "RND"], + ) + assert len(request.features) == 4 + assert "LineType" in request.features + assert "SimSwap" in request.features + assert "VoIPDetection" in request.features + assert "RND" in request.features diff --git a/tests/unit/domains/number_lookup/v1/models/test_lookup_response_model.py b/tests/unit/domains/number_lookup/v1/models/test_lookup_response_model.py new file mode 100644 index 00000000..e1f7a162 --- /dev/null +++ b/tests/unit/domains/number_lookup/v1/models/test_lookup_response_model.py @@ -0,0 +1,121 @@ +from datetime import datetime, timezone +from sinch.domains.number_lookup.models.v1.response import ( + LookupNumberResponse, +) + + +def test_lookup_number_response_minimal_expects_valid_response(): + """Test minimal lookup response.""" + response_data = { + "number": "+15551234567", + "countryCode": "US", + "traceId": "test-trace-id", + } + response = LookupNumberResponse.model_validate(response_data) + assert response.number == "+15551234567" + assert response.country_code == "US" + assert response.trace_id == "test-trace-id" + assert response.line is None + assert response.sim_swap is None + assert response.voip_detection is None + assert response.rnd is None + + +def test_lookup_number_response_with_line_info_expects_valid_response(): + """Test lookup response with line information.""" + response_data = { + "number": "+15552345678", + "line": { + "carrier": "T-Mobile USA", + "type": "Mobile", + "mobileCountryCode": "310", + "mobileNetworkCode": "260", + "ported": True, + "portingDate": "2024-06-15T14:30:00+00:00", + }, + } + response = LookupNumberResponse.model_validate(response_data) + assert response.line is not None + assert response.line.carrier == "T-Mobile USA" + assert response.line.type == "Mobile" + assert response.line.mobile_country_code == "310" + assert response.line.mobile_network_code == "260" + assert response.line.ported is True + assert response.line.porting_date == datetime( + 2024, 6, 15, 14, 30, 0, tzinfo=timezone.utc + ) + + +def test_lookup_number_response_with_sim_swap_expects_valid_response(): + """Test lookup response with SIM swap information.""" + response_data = { + "number": "+15553456789", + "simSwap": {"swapped": True, "swapPeriod": "SP24H"}, + } + response = LookupNumberResponse.model_validate(response_data) + assert response.sim_swap is not None + assert response.sim_swap.swapped is True + assert response.sim_swap.swap_period == "SP24H" + + +def test_lookup_number_response_with_voip_detection_expects_valid_response(): + """Test lookup response with VoIP detection information.""" + response_data = { + "number": "+15554567890", + "voIPDetection": {"probability": "High"}, + } + response = LookupNumberResponse.model_validate(response_data) + assert response.voip_detection.probability == "High" + + +def test_lookup_number_response_with_rnd_expects_valid_response(): + """Test lookup response with RND information.""" + response_data = {"number": "+15555678901", "rnd": {"disconnected": True}} + response = LookupNumberResponse.model_validate(response_data) + assert response.rnd is not None + assert response.rnd.disconnected is True + + +def test_lookup_number_response_with_errors_expects_valid_response(): + """Test lookup response with error information.""" + response_data = { + "number": "+15556789012", + "line": {"error": {"code": "ERROR_CODE", "message": "Error message"}}, + "simSwap": { + "error": {"code": "ERROR_CODE_2", "message": "Error message 2"} + }, + } + response = LookupNumberResponse.model_validate(response_data) + assert response.line.error is not None + assert response.line.error.code == "ERROR_CODE" + assert response.line.error.message == "Error message" + assert response.sim_swap.error is not None + assert response.sim_swap.error.code == "ERROR_CODE_2" + + +def test_lookup_number_response_full_expects_valid_response(): + """Test complete lookup response with all features.""" + response_data = { + "line": { + "carrier": "T-Mobile USA", + "type": "Mobile", + "mobileCountryCode": "310", + "mobileNetworkCode": "260", + "ported": True, + "portingDate": "2024-08-20T10:15:30+00:00", + }, + "simSwap": {"swapped": True, "swapPeriod": "SP24H"}, + "voIPDetection": {"probability": "High"}, + "rnd": {"disconnected": True}, + "countryCode": "US", + "traceId": "84c1fd4063c38d9f3900d06e56542d48", + "number": "+15557890123", + } + response = LookupNumberResponse.model_validate(response_data) + assert response.number == "+15557890123" + assert response.country_code == "US" + assert response.trace_id == "84c1fd4063c38d9f3900d06e56542d48" + assert response.line is not None + assert response.sim_swap is not None + assert response.voip_detection is not None + assert response.rnd is not None diff --git a/tests/unit/domains/number_lookup/v1/test_number_lookup.py b/tests/unit/domains/number_lookup/v1/test_number_lookup.py new file mode 100644 index 00000000..534985b1 --- /dev/null +++ b/tests/unit/domains/number_lookup/v1/test_number_lookup.py @@ -0,0 +1,182 @@ +from datetime import datetime, timezone +from sinch.domains.number_lookup.api.v1.number_lookup_apis import NumberLookup +from sinch.domains.number_lookup.api.v1.internal import LookupNumberEndpoint +from sinch.domains.number_lookup.models.v1.internal import LookupNumberRequest +from sinch.domains.number_lookup.models.v1.response import LookupNumberResponse +from sinch.domains.number_lookup.models.v1.shared import Line, SimSwap, VoIPDetection, Rnd +from sinch.domains.number_lookup.models.v1.types import RndFeatureOptionsDict + + +def test_lookup_expects_valid_request(mock_sinch_client_number_lookup, mocker): + """ + Test that the NumberLookup.lookup() method sends the correct request + and handles the response properly. + """ + mock_response = LookupNumberResponse( + number="+15551234567", country_code="US" + ) + mock_sinch_client_number_lookup.configuration.transport.request.return_value = mock_response + + # Spy on the LookupNumberEndpoint to capture calls + spy_endpoint = mocker.spy(LookupNumberEndpoint, "__init__") + + number_lookup = NumberLookup(mock_sinch_client_number_lookup) + response = number_lookup.lookup("+15551234567") + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == LookupNumberRequest(number="+15551234567") + + assert isinstance(response, LookupNumberResponse) + assert response.number == "+15551234567" + assert response.country_code == "US" + mock_sinch_client_number_lookup.configuration.transport.request.assert_called_once() + + +def test_lookup_with_features_expects_valid_request( + mock_sinch_client_number_lookup, mocker +): + """ + Test that the NumberLookup.lookup() method with features sends the correct request + and handles the response properly. + """ + mock_response = LookupNumberResponse( + number="+15552345678", country_code="US" + ) + mock_sinch_client_number_lookup.configuration.transport.request.return_value = mock_response + + spy_endpoint = mocker.spy(LookupNumberEndpoint, "__init__") + + number_lookup = NumberLookup(mock_sinch_client_number_lookup) + response = number_lookup.lookup( + "+15552345678", features=["LineType", "SimSwap", "VoIPDetection"] + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == LookupNumberRequest( + number="+15552345678", + features=["LineType", "SimSwap", "VoIPDetection"], + ) + + assert isinstance(response, LookupNumberResponse) + assert response.number == "+15552345678" + mock_sinch_client_number_lookup.configuration.transport.request.assert_called_once() + + +def test_lookup_with_rnd_options_expects_valid_request( + mock_sinch_client_number_lookup, mocker +): + """ + Test that the NumberLookup.lookup() method with RND options sends the correct request + and handles the response properly. + """ + mock_response = LookupNumberResponse( + number="+15553456789", country_code="US" + ) + mock_sinch_client_number_lookup.configuration.transport.request.return_value = mock_response + + rnd_options: RndFeatureOptionsDict = {"contact_date": "2025-01-01"} + spy_endpoint = mocker.spy(LookupNumberEndpoint, "__init__") + + number_lookup = NumberLookup(mock_sinch_client_number_lookup) + response = number_lookup.lookup( + "+15553456789", features=["RND"], rnd_feature_options=rnd_options + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == LookupNumberRequest( + number="+15553456789", + features=["RND"], + rnd_feature_options=rnd_options, + ) + + assert isinstance(response, LookupNumberResponse) + assert response.number == "+15553456789" + mock_sinch_client_number_lookup.configuration.transport.request.assert_called_once() + + +def test_lookup_missing_project_id_expects_error(): + """ + Test that missing project_id raises an error. + """ + from unittest.mock import Mock + import pytest + + sinch = Mock() + sinch.configuration = Mock() + sinch.configuration.project_id = None + + number_lookup = NumberLookup(sinch) + + with pytest.raises(ValueError, match="project_id is required"): + number_lookup.lookup("+15554567890") + + +def test_lookup_full_response_expects_valid_request( + mock_sinch_client_number_lookup, mocker +): + """ + Test that the NumberLookup.lookup() method with all features sends the correct request + and handles the full response properly. + """ + mock_response = LookupNumberResponse( + number="+15555678901", + country_code="US", + trace_id="test-trace-id", + line=Line( + carrier="T-Mobile USA", + type="Mobile", + mobile_country_code="310", + mobile_network_code="260", + ported=True, + porting_date=datetime(2024, 8, 20, 10, 15, 30, tzinfo=timezone.utc), + ), + sim_swap=SimSwap(swapped=True, swap_period="SP24H"), + voip_detection=VoIPDetection(probability="High"), + rnd=Rnd(disconnected=True), + ) + mock_sinch_client_number_lookup.configuration.transport.request.return_value = mock_response + + spy_endpoint = mocker.spy(LookupNumberEndpoint, "__init__") + + number_lookup = NumberLookup(mock_sinch_client_number_lookup) + response = number_lookup.lookup( + "+15555678901", + features=["LineType", "SimSwap", "VoIPDetection", "RND"], + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == LookupNumberRequest( + number="+15555678901", + features=["LineType", "SimSwap", "VoIPDetection", "RND"], + ) + + assert isinstance(response, LookupNumberResponse) + assert response.number == "+15555678901" + assert response.country_code == "US" + assert response.trace_id == "test-trace-id" + assert response.line is not None + assert response.line.carrier == "T-Mobile USA" + assert response.line.type == "Mobile" + assert response.line.mobile_country_code == "310" + assert response.line.mobile_network_code == "260" + assert response.line.ported is True + assert response.line.porting_date == datetime(2024, 8, 20, 10, 15, 30, tzinfo=timezone.utc) + assert response.sim_swap.swapped is True + assert response.sim_swap.swap_period == "SP24H" + assert response.voip_detection is not None + assert response.voip_detection.probability == "High" + assert response.rnd is not None + assert response.rnd.disconnected is True + mock_sinch_client_number_lookup.configuration.transport.request.assert_called_once() diff --git a/tests/unit/domains/numbers/v1/endpoints/active/test_get_active_numbers_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/active/test_get_active_numbers_endpoint.py new file mode 100644 index 00000000..ee3444a4 --- /dev/null +++ b/tests/unit/domains/numbers/v1/endpoints/active/test_get_active_numbers_endpoint.py @@ -0,0 +1,72 @@ +import pytest +from datetime import datetime, timezone +from decimal import Decimal +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.api.v1.internal import GetNumberConfigurationEndpoint +from sinch.domains.numbers.models.v1.internal import NumberRequest +from sinch.domains.numbers.models.v1.response import ActiveNumber + + +@pytest.fixture +def request_data(): + return NumberRequest( + phone_number="+1234567890" + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "phoneNumber": "+1234567890", + "projectId": "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a", + "displayName": "", + "regionCode": "US", + "type": "LOCAL", + "capability": ["SMS", "VOICE"], + "money": { + "currencyCode": "EUR", + "amount": "0.80" + }, + "paymentIntervalMonths": 1, + "nextChargeDate": "2025-02-28T14:04:26.190127Z", + "expireAt": "2025-02-28T14:04:26.190127Z", + "callbackUrl": "https://yourcallback/numbers" + }, + headers={"Content-Type": "application/json"} + ) + + +@pytest.fixture +def endpoint(request_data): + return GetNumberConfigurationEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_numbers): + assert (endpoint.build_url(mock_sinch_client_numbers) == + "https://mock-numbers-api.sinch.com/v1/projects/test_project_id/activeNumbers/+1234567890") + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + parsed_response = endpoint.handle_response(mock_response) + assert isinstance(parsed_response, ActiveNumber) + assert parsed_response.phone_number == "+1234567890" + assert parsed_response.project_id == "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a" + assert parsed_response.display_name == "" + assert parsed_response.region_code == "US" + assert parsed_response.type == "LOCAL" + assert parsed_response.capability == ["SMS", "VOICE"] + assert parsed_response.money.currency_code == "EUR" + assert parsed_response.money.amount == Decimal("0.80") + assert parsed_response.payment_interval_months == 1 + expected_next_charge_date = ( + datetime(2025, 2, 28, 14, 4, 26, 190127, tzinfo=timezone.utc)) + assert parsed_response.next_charge_date == expected_next_charge_date + expected_expire_at = ( + datetime(2025, 2, 28, 14, 4, 26, 190127, tzinfo=timezone.utc)) + assert parsed_response.expire_at == expected_expire_at + assert parsed_response.event_destination_target == "https://yourcallback/numbers" diff --git a/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py new file mode 100644 index 00000000..aa66041a --- /dev/null +++ b/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py @@ -0,0 +1,122 @@ +from datetime import datetime, timezone +from decimal import Decimal + +import pytest +from sinch.domains.numbers.api.v1.internal import ListActiveNumbersEndpoint +from sinch.domains.numbers.models.v1.internal import ListActiveNumbersRequest, ListActiveNumbersResponse +from sinch.core.models.http_response import HTTPResponse + + +@pytest.fixture +def request_data(): + return ListActiveNumbersRequest( + region_code="US", + number_type="LOCAL", + page_size=15, + capabilities=["SMS", "VOICE"], + number_pattern="123", + number_search_pattern="STARTS_WITH" + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "activeNumbers": [ + { + "phoneNumber": "+1234567890", + "projectId": "37b62a7b-0177-429a-bb0b-e10f848de0b8", + "displayName": "", + "regionCode": "US", + "type": "LOCAL", + "capability": ["SMS", "VOICE"], + "money": { + "currencyCode": "EUR", + "amount": "0.80" + }, + "paymentIntervalMonths": 1, + "nextChargeDate": "2025-02-28T14:04:26.190127Z", + "expireAt": "2025-02-28T14:04:26.190127Z", + "callbackUrl": "https://yourcallback/numbers" + } + ], + "nextPageToken": "CgtwaG9uoLnNDQzajQSDCsxMzE1OTA0MzM1OQ==", + "totalSize": 10 + }, + headers={"Content-Type": "application/json"} + ) + + +@pytest.fixture +def endpoint(request_data): + return ListActiveNumbersEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_numbers): + assert (endpoint.build_url(mock_sinch_client_numbers) == + "https://mock-numbers-api.sinch.com/v1/projects/test_project_id/activeNumbers") + + +def test_build_query_params_expects_correct_mapping(endpoint): + """ + Check if Query params is handled and mapped to the appropriate fields correctly. + """ + expected_params = { + "regionCode": "US", + "type": "LOCAL", + "pageSize": 15, + "capabilities": ["SMS", "VOICE"], + "numberPattern.pattern": "123", + "numberPattern.searchPattern": "STARTS_WITH" + } + assert endpoint.build_query_params() == expected_params + + +def test_build_query_params_omits_none_region_and_type(): + """ + Optional query params must not be sent when unset. + """ + request_data = ListActiveNumbersRequest( + page_size=10, + capabilities=["SMS"], + ) + endpoint = ListActiveNumbersEndpoint("test_project_id", request_data) + assert endpoint.build_query_params() == { + "pageSize": 10, + "capabilities": ["SMS"], + } + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + parsed_response = endpoint.handle_response(mock_response) + assert isinstance(parsed_response, ListActiveNumbersResponse) + assert hasattr(parsed_response, "content") + assert parsed_response.content == parsed_response.active_numbers + assert len(parsed_response.active_numbers) == 1 + + number = parsed_response.active_numbers[0] + assert number.phone_number == "+1234567890" + assert number.project_id == "37b62a7b-0177-429a-bb0b-e10f848de0b8" + assert number.display_name == "" + assert number.region_code == "US" + assert number.type == "LOCAL" + assert number.capability == ["SMS", "VOICE"] + assert number.money.currency_code == "EUR" + assert number.money.amount == Decimal("0.80") + assert number.payment_interval_months == 1 + expected_next_charge_date = datetime( + 2025, 2, 28, 14, 4, 26, 190127, tzinfo=timezone.utc + ) + assert number.next_charge_date == expected_next_charge_date + expected_expire_at = datetime( + 2025, 2, 28, 14, 4, 26, 190127, tzinfo=timezone.utc + ) + assert number.expire_at == expected_expire_at + assert number.event_destination_target == "https://yourcallback/numbers" + assert parsed_response.next_page_token == "CgtwaG9uoLnNDQzajQSDCsxMzE1OTA0MzM1OQ==" + assert parsed_response.total_size == 10 diff --git a/tests/unit/domains/numbers/v1/endpoints/active/test_release_active_numbers_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/active/test_release_active_numbers_endpoint.py new file mode 100644 index 00000000..f09c88a3 --- /dev/null +++ b/tests/unit/domains/numbers/v1/endpoints/active/test_release_active_numbers_endpoint.py @@ -0,0 +1,72 @@ +import pytest +from datetime import datetime, timezone +from decimal import Decimal +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.api.v1.internal import ReleaseNumberFromProjectEndpoint +from sinch.domains.numbers.models.v1.internal import NumberRequest +from sinch.domains.numbers.models.v1.response import ActiveNumber + + +@pytest.fixture +def request_data(): + return NumberRequest( + phone_number="+1234567890" + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "phoneNumber": "+1234567890", + "projectId": "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a", + "displayName": "", + "regionCode": "US", + "type": "LOCAL", + "capability": ["SMS", "VOICE"], + "money": { + "currencyCode": "EUR", + "amount": "0.80" + }, + "paymentIntervalMonths": 1, + "nextChargeDate": "2025-02-28T14:04:26.190127Z", + "expireAt": "2025-02-28T14:04:26.190127Z", + "callbackUrl": "https://yourcallback/numbers" + }, + headers={"Content-Type": "application/json"} + ) + + +@pytest.fixture +def endpoint(request_data): + return ReleaseNumberFromProjectEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_numbers): + assert (endpoint.build_url(mock_sinch_client_numbers) == + "https://mock-numbers-api.sinch.com/v1/projects/test_project_id/activeNumbers/+1234567890:release") + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + parsed_response = endpoint.handle_response(mock_response) + assert isinstance(parsed_response, ActiveNumber) + assert parsed_response.phone_number == "+1234567890" + assert parsed_response.project_id == "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a" + assert parsed_response.display_name == "" + assert parsed_response.region_code == "US" + assert parsed_response.type == "LOCAL" + assert parsed_response.capability == ["SMS", "VOICE"] + assert parsed_response.money.currency_code == "EUR" + assert parsed_response.money.amount == Decimal("0.80") + assert parsed_response.payment_interval_months == 1 + expected_next_charge_date = ( + datetime(2025, 2, 28, 14, 4, 26, 190127, tzinfo=timezone.utc)) + assert parsed_response.next_charge_date == expected_next_charge_date + expected_expire_at = ( + datetime(2025, 2, 28, 14, 4, 26, 190127, tzinfo=timezone.utc)) + assert parsed_response.expire_at == expected_expire_at + assert parsed_response.event_destination_target == "https://yourcallback/numbers" diff --git a/tests/unit/domains/numbers/v1/endpoints/active/test_update_active_numbers_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/active/test_update_active_numbers_endpoint.py new file mode 100644 index 00000000..34af95e3 --- /dev/null +++ b/tests/unit/domains/numbers/v1/endpoints/active/test_update_active_numbers_endpoint.py @@ -0,0 +1,107 @@ +import json + +import pytest +from datetime import datetime, timezone +from decimal import Decimal +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.api.v1.internal import UpdateNumberConfigurationEndpoint +from sinch.domains.numbers.models.v1.internal import UpdateNumberConfigurationRequest +from sinch.domains.numbers.models.v1.response import ActiveNumber + + +@pytest.fixture +def request_data(): + return UpdateNumberConfigurationRequest( + phone_number="+1234567890", + display_name="Display Name", + sms_configuration={ + "service_plan_id": "Service Plan Id" + }, + voice_configuration={ + "type": "RTC", + "app_id": "App Id" + } + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "phoneNumber": "+1234567890", + "projectId": "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a", + "displayName": "Display Name", + "regionCode": "US", + "type": "LOCAL", + "capability": ["SMS", "VOICE"], + "money": { + "currencyCode": "EUR", + "amount": "0.80" + }, + "paymentIntervalMonths": 1, + "nextChargeDate": "2025-02-28T14:04:26.190127Z", + "expireAt": "2025-02-28T14:04:26.190127Z", + "callbackUrl": "https://yourcallback/numbers" + }, + headers={"Content-Type": "application/json"} + ) + + +@pytest.fixture +def mock_response_body(): + expected_body = { + "phoneNumber": "+1234567890", + "displayName": "Display Name", + "smsConfiguration": { + "servicePlanId": "Service Plan Id" + }, + "voiceConfiguration": { + "type": "RTC", + "appId": "App Id" + } + } + return json.dumps(expected_body) + + +@pytest.fixture +def endpoint(request_data): + return UpdateNumberConfigurationEndpoint("test_project_id", request_data) + + +def test_request_body_expects_correct_json(request_data, mock_response_body): + """ + Check if request body is constructed correctly based on input data. + """ + endpoint = UpdateNumberConfigurationEndpoint(project_id="test_project", request_data=request_data) + request_body = endpoint.request_body() + assert request_body == mock_response_body + + +def test_build_url(endpoint, mock_sinch_client_numbers): + assert (endpoint.build_url(mock_sinch_client_numbers) == + "https://mock-numbers-api.sinch.com/v1/projects/test_project_id/activeNumbers/+1234567890") + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + parsed_response = endpoint.handle_response(mock_response) + assert isinstance(parsed_response, ActiveNumber) + assert parsed_response.phone_number == "+1234567890" + assert parsed_response.project_id == "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a" + assert parsed_response.display_name == "Display Name" + assert parsed_response.region_code == "US" + assert parsed_response.type == "LOCAL" + assert parsed_response.capability == ["SMS", "VOICE"] + assert parsed_response.money.currency_code == "EUR" + assert parsed_response.money.amount == Decimal("0.80") + assert parsed_response.payment_interval_months == 1 + expected_next_charge_date = ( + datetime(2025, 2, 28, 14, 4, 26, 190127, tzinfo=timezone.utc)) + assert parsed_response.next_charge_date == expected_next_charge_date + expected_expire_at = ( + datetime(2025, 2, 28, 14, 4, 26, 190127, tzinfo=timezone.utc)) + assert parsed_response.expire_at == expected_expire_at + assert parsed_response.event_destination_target == "https://yourcallback/numbers" diff --git a/tests/unit/domains/numbers/v1/endpoints/available/test_list_available_numbers_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_list_available_numbers_endpoint.py new file mode 100644 index 00000000..da912345 --- /dev/null +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_list_available_numbers_endpoint.py @@ -0,0 +1,132 @@ +from decimal import Decimal +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.api.v1.internal import AvailableNumbersEndpoint +from sinch.domains.numbers.models.v1.internal import ListAvailableNumbersRequest, ListAvailableNumbersResponse + + +@pytest.fixture +def request_data(): + return ListAvailableNumbersRequest( + region_code="US", + number_type="MOBILE", + page_size=10, + capabilities=["SMS"], + number_pattern="123", + number_search_pattern="STARTS_WITH", + extra_field="extra value" + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "availableNumbers": [ + { + "phoneNumber": "+1234567890", + "regionCode": "US", + "type": "LOCAL", + "capability": [ + "SMS", + "VOICE" + ], + "setupPrice": { + "currencyCode": "EUR", + "amount": "0.80" + }, + "monthlyPrice": { + "currencyCode": "EUR", + "amount": "0.85" + }, + "paymentIntervalMonths": 1, + "supportingDocumentationRequired": True + }, + { + "phoneNumber": "+13456789012", + "regionCode": "US", + "type": "LOCAL", + "capability": [ + "SMS", + "VOICE" + ], + "setupPrice": { + "currencyCode": "EUR", + "amount": "0.80" + }, + "monthlyPrice": { + "currencyCode": "EUR", + "amount": "1.00" + }, + "paymentIntervalMonths": 2, + "supportingDocumentationRequired": True + } + ], + }, + headers={"Content-Type": "application/json"} + ) + + +@pytest.fixture +def endpoint(request_data): + return AvailableNumbersEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_numbers): + """ + Check if endpoint URL is constructed correctly based on input data. + """ + expected_url = "https://mock-numbers-api.sinch.com/v1/projects/test_project_id/availableNumbers" + assert endpoint.build_url(mock_sinch_client_numbers) == expected_url + + +def test_build_query_params_expects_correct_mapping(endpoint): + """ + Check if Query params is handled and mapped to the appropriate fields correctly. + """ + expected_params = { + "regionCode": "US", + "type": "MOBILE", + "size": 10, + "capabilities": ["SMS"], + "numberPattern.pattern": "123", + "numberPattern.searchPattern": "STARTS_WITH", + "extraField": "extra value" + } + assert endpoint.build_query_params() == expected_params + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + parsed_response = endpoint.handle_response(mock_response) + assert isinstance(parsed_response, ListAvailableNumbersResponse) + assert hasattr(parsed_response, "content") + assert parsed_response.content == parsed_response.available_numbers + assert len(parsed_response.available_numbers) == 2 + + first_number = parsed_response.available_numbers[0] + assert first_number.phone_number == "+1234567890" + assert first_number.region_code == "US" + assert first_number.type == "LOCAL" + assert first_number.capability == ["SMS", "VOICE"] + assert first_number.setup_price.currency_code == "EUR" + assert first_number.setup_price.amount == Decimal("0.80") + assert first_number.monthly_price.currency_code == "EUR" + assert first_number.monthly_price.amount == Decimal("0.85") + assert first_number.payment_interval_months == 1 + assert first_number.supporting_documentation_required is True + + second_number = parsed_response.available_numbers[1] + assert second_number.phone_number == "+13456789012" + assert second_number.region_code == "US" + assert second_number.type == "LOCAL" + assert second_number.capability == ["SMS", "VOICE"] + assert second_number.setup_price.currency_code == "EUR" + assert second_number.setup_price.amount == Decimal("0.80") + assert second_number.monthly_price.currency_code == "EUR" + assert second_number.monthly_price.amount == 1.00 + assert second_number.payment_interval_months == 2 + assert second_number.supporting_documentation_required is True diff --git a/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py new file mode 100644 index 00000000..ee521ea0 --- /dev/null +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py @@ -0,0 +1,143 @@ +import pytest +import json +from datetime import datetime, timezone +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.api.v1.internal import RentAnyNumberEndpoint +from sinch.domains.numbers.models.v1.internal import RentAnyNumberRequest +from sinch.domains.numbers.models.v1.response import ActiveNumber + + +@pytest.fixture +def valid_request_data(): + """ + Provides valid mock request data for RentAnyNumberRequest. + """ + return RentAnyNumberRequest( + region_code="US", + number_type="MOBILE", + number_pattern={"pattern": "string", "searchPattern": "START"}, + capabilities=["SMS"], + sms_configuration={"servicePlanId": "string", "campaignId": "string"}, + voice_configuration={"appId": "string"}, + event_destination_target="https://www.your-callback-server.com/callback", + ) + + +@pytest.fixture +def valid_response_data(): + """ + Provides valid mock response data for ActiveNumer. + """ + return { + "phoneNumber": "+12025550134", + "projectId": "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a", + "displayName": "string", + "regionCode": "US", + "type": "MOBILE", + "capability": ["SMS"], + "money": {"currencyCode": "USD", "amount": "2.00"}, + "paymentIntervalMonths": 0, + "nextChargeDate": "2025-01-24T09:32:27.437Z", + "expireAt": "2025-01-25T09:32:27.437Z", + "smsConfiguration": { + "servicePlanId": "string", + "campaignId": "string", + "scheduledProvisioning": { + "servicePlanId": "string", + "campaignId": "string", + "status": "PROVISIONING_STATUS_UNSPECIFIED", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "errorCodes": ["ERROR_CODE_UNSPECIFIED"], + }, + }, + "voiceConfiguration": { + "type": "RTC", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "scheduledVoiceProvisioning": { + "type": "RTC", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "status": "PROVISIONING_STATUS_UNSPECIFIED", + "trunkId": "string", + }, + "appId": "string", + }, + "callbackUrl": "https://www.your-callback-server.com/callback", + } + + +def test_build_url_expects_correct_format(mock_sinch_client_numbers, valid_request_data): + """ + Test that the build_url method constructs the URL correctly. + """ + endpoint = RentAnyNumberEndpoint(project_id="test_project", request_data=valid_request_data) + expected_url = "https://mock-numbers-api.sinch.com/v1/projects/test_project/availableNumbers:rentAny" + assert endpoint.build_url(mock_sinch_client_numbers) == expected_url + + +def test_request_body_expects_correct_json(valid_request_data): + """ + Test that the request_body method returns the correct JSON structure. + """ + endpoint = RentAnyNumberEndpoint(project_id="test_project", request_data=valid_request_data) + request_body = endpoint.request_body() + + expected_body = { + "numberPattern": {"pattern": "string", "searchPattern": "START"}, + "regionCode": "US", + "type": "MOBILE", + "capabilities": ["SMS"], + "smsConfiguration": {"servicePlanId": "string", "campaignId": "string"}, + "voiceConfiguration": {"appId": "string"}, + "callbackUrl": "https://www.your-callback-server.com/callback", + } + + assert json.loads(request_body) == expected_body + + +def test_handle_response_expects_valid_mapping(valid_response_data): + """ + Test that the handle_response method correctly maps the response data. + """ + mock_response = HTTPResponse(status_code=200, body=valid_response_data, + headers="Content-Type:application/json") + + endpoint = RentAnyNumberEndpoint(project_id="test_project", request_data=None) + response = endpoint.handle_response(mock_response) + + # Validate response fields + assert isinstance(response, ActiveNumber) + assert response.phone_number == "+12025550134" + assert response.project_id == "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a" + assert response.region_code == "US" + assert response.type == "MOBILE" + assert response.capability == ["SMS"] + assert response.money.currency_code == "USD" + assert response.money.amount == 2.00 + assert response.payment_interval_months == 0 + expected_next_charge_date = ( + datetime(2025, 1, 24, 9, 32, 27, 437000, tzinfo=timezone.utc)) + assert response.next_charge_date == expected_next_charge_date + expected_expire_at = ( + datetime(2025, 1, 25, 9, 32, 27, 437000, tzinfo=timezone.utc)) + assert response.expire_at == expected_expire_at + + sms_config = response.sms_configuration + assert sms_config.service_plan_id == "string" + assert sms_config.campaign_id == "string" + assert sms_config.scheduled_provisioning.service_plan_id == "string" + assert sms_config.scheduled_provisioning.campaign_id == "string" + assert sms_config.scheduled_provisioning.status == "PROVISIONING_STATUS_UNSPECIFIED" + expected_last_updated_time = ( + datetime(2025, 1, 24, 9, 32, 27, 437000, tzinfo=timezone.utc)) + assert sms_config.scheduled_provisioning.last_updated_time == expected_last_updated_time + assert sms_config.scheduled_provisioning.error_codes == ["ERROR_CODE_UNSPECIFIED"] + + voice_config = response.voice_configuration + assert voice_config.type == "RTC" + assert voice_config.last_updated_time == expected_last_updated_time + assert voice_config.scheduled_voice_provisioning.type == "RTC" + assert voice_config.scheduled_voice_provisioning.last_updated_time == expected_last_updated_time + assert voice_config.scheduled_voice_provisioning.status == "PROVISIONING_STATUS_UNSPECIFIED" + assert voice_config.scheduled_voice_provisioning.trunk_id == "string" + assert voice_config.app_id == "string" + assert response.event_destination_target == "https://www.your-callback-server.com/callback" diff --git a/tests/unit/domains/numbers/v1/endpoints/available/test_rent_number_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_rent_number_endpoint.py new file mode 100644 index 00000000..edd8efb4 --- /dev/null +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_rent_number_endpoint.py @@ -0,0 +1,94 @@ +import pytest +import json +from sinch.domains.numbers.api.v1.internal import RentNumberEndpoint +from sinch.domains.numbers.models.v1.internal import RentNumberRequest +from sinch.core.models.http_response import HTTPResponse + + +@pytest.fixture +def mock_request_data(): + return RentNumberRequest( + phone_number="+1234567890", + sms_configuration={"servicePlanId": "YOUR_SMS_servicePlanId"}, + voice_configuration={"type": "RTC", "appId": "YOUR_Voice_appId"} + ) + + +@pytest.fixture +def mock_request_data_snake_case(): + return RentNumberRequest( + phone_number="+1234567890", + sms_configuration={"service_plan_id": "YOUR_SMS_servicePlanId"}, + voice_configuration={"type": "RTC", "appId": "YOUR_Voice_appId"} + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "phoneNumber": "+1234567890", + "regionCode": "US", + "type": "mobile", + "capability": ["SMS", "Voice"] + }, + headers={"Content-Type": "application/json"} + ) + + +@pytest.fixture +def mock_response_body(): + expected_body = { + "phoneNumber": "+1234567890", + "smsConfiguration": { + "servicePlanId": "YOUR_SMS_servicePlanId" + }, + "voiceConfiguration": { + "type": "RTC", + "appId": "YOUR_Voice_appId" + } + } + return json.dumps(expected_body) + + +def test_build_url_expects_correct_url(mock_sinch_client_numbers, mock_request_data): + """ + Check if endpoint URL is constructed correctly based on input data. + """ + endpoint = RentNumberEndpoint(project_id="test_project", request_data=mock_request_data) + expected_url = "https://mock-numbers-api.sinch.com/v1/projects/test_project/availableNumbers/+1234567890:rent" + assert endpoint.build_url(mock_sinch_client_numbers) == expected_url + + +def test_request_body_expects_correct_json(mock_request_data, mock_response_body): + """ + Check if request body is constructed correctly based on input data. + """ + endpoint = RentNumberEndpoint(project_id="test_project", request_data=mock_request_data) + request_body = endpoint.request_body() + assert request_body == mock_response_body + + +def test_request_body_snake_case_dict_expects_correct_json(mock_request_data_snake_case, mock_response_body): + """ + Check if request body is constructed correctly based on input data. + """ + endpoint = RentNumberEndpoint(project_id="test_project", request_data=mock_request_data_snake_case) + request_body = endpoint.request_body() + + assert request_body == mock_response_body + + +def test_handle_response_expects_correct_mapping(mock_request_data, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + endpoint = RentNumberEndpoint(project_id="test_project", request_data=mock_request_data) + response = endpoint.handle_response(mock_response) + + # Verify each field is mapped as expected + assert response.phone_number == "+1234567890" + assert response.region_code == "US" + assert response.type == "mobile" + assert response.capability == ["SMS", "Voice"] diff --git a/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py new file mode 100644 index 00000000..8856e76a --- /dev/null +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py @@ -0,0 +1,90 @@ +import pytest +from sinch.domains.numbers.api.v1.internal import SearchForNumberEndpoint +from sinch.domains.numbers.models.v1.internal import NumberRequest +from sinch.domains.numbers.models.v1.response import AvailableNumber +from sinch.core.models.http_response import HTTPResponse + + +@pytest.fixture +def mock_request_data(): + """ + Mock the request data for the endpoint. + """ + return NumberRequest(phone_number="+1234567890") + + +@pytest.fixture +def mock_response(): + """ + Mock the HTTP response object returned by the API. + """ + return HTTPResponse( + status_code=200, + body={ + "phoneNumber": "+1234567890", + "regionCode": "US", + "type": "MOBILE", + "capability": [ + "SMS", + "VOICE" + ], + "setupPrice": { + "currencyCode": "USD", + "amount": "2.00" + }, + "monthlyPrice": { + "currencyCode": "USD", + "amount": "2.00" + }, + "paymentIntervalMonths": 0, + "supportingDocumentationRequired": True + }, + headers={"Content-Type": "application/json"} + ) + + +def test_build_url_expects_correct_url(mock_sinch_client_numbers, mock_request_data): + """ + Check if endpoint URL is constructed correctly based on input data. + """ + endpoint = SearchForNumberEndpoint(project_id="test_project", request_data=mock_request_data) + expected_url = "https://mock-numbers-api.sinch.com/v1/projects/test_project/availableNumbers/+1234567890" + assert endpoint.build_url(mock_sinch_client_numbers) == expected_url + + +def test_handle_response_expects_correct_mapping(mock_request_data, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + endpoint = SearchForNumberEndpoint(project_id="test_project", request_data=mock_request_data) + response = endpoint.handle_response(mock_response) + + assert isinstance(response, AvailableNumber) + assert response.phone_number == "+1234567890" + assert response.region_code == "US" + assert response.type == "MOBILE" + assert response.capability == ["SMS", "VOICE"] + assert response.setup_price.currency_code == "USD" + assert response.setup_price.amount == 2.00 + assert response.monthly_price.currency_code == "USD" + assert response.monthly_price.amount == 2.00 + assert response.payment_interval_months == 0 + assert response.supporting_documentation_required is True + + +def test_handle_response_expects_missing_fields(mock_response): + """ + Check if response handles missing fields by excluding them without failure. + """ + mock_response.body.pop("paymentIntervalMonths") + endpoint = SearchForNumberEndpoint(project_id="test_project", request_data=None) + response = endpoint.handle_response(mock_response) + + assert response.phone_number == "+1234567890" + assert response.region_code == "US" + assert response.type == "MOBILE" + assert response.capability == ["SMS", "VOICE"] + assert response.monthly_price.currency_code == "USD" + assert response.monthly_price.amount == 2.00 + assert response.supporting_documentation_required is True + assert response.payment_interval_months is None diff --git a/tests/unit/domains/numbers/v1/endpoints/event_destination/test_get_event_destination_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/event_destination/test_get_event_destination_endpoint.py new file mode 100644 index 00000000..4f8af3b0 --- /dev/null +++ b/tests/unit/domains/numbers/v1/endpoints/event_destination/test_get_event_destination_endpoint.py @@ -0,0 +1,78 @@ +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.api.v1.internal import GetEventDestinationEndpoint +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest +from sinch.domains.numbers.models.v1.response import EventDestinationResponse + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "projectId": "j55aa9aa-b888-777c-dd6d-ee55e1010101010", + "hmacSecret": "your_hmac_secret" + }, + headers={"Content-Type": "application/json"} + ) + + +@pytest.fixture +def endpoint_empty_request_data(): + return GetEventDestinationEndpoint("test_project_id", request_data=None) + + +@pytest.fixture +def endpoint_extra_request_data(): + data = { + "key": "value", + "extra_field": "extra value" + } + request_model = BaseModelConfigurationRequest(**data) + return GetEventDestinationEndpoint("test_project_id", request_data=request_model) + + +endpoint_fixtures = pytest.mark.parametrize("endpoint_fixture", [ + "endpoint_empty_request_data", + "endpoint_extra_request_data" +]) + + +@endpoint_fixtures +def test_build_url(endpoint_fixture, mock_sinch_client_numbers, request): + """ + Check if endpoint URL is constructed correctly based on input data. + """ + endpoint = request.getfixturevalue(endpoint_fixture) + expected_url = "https://mock-numbers-api.sinch.com/v1/projects/test_project_id/callbackConfiguration" + assert endpoint.build_url(mock_sinch_client_numbers) == expected_url + + +def test_build_empty_query_params_expects_correct_mapping(endpoint_empty_request_data): + """ + Check if empty Query params is handled and mapped to the appropriate fields correctly. + """ + assert endpoint_empty_request_data.build_query_params() == {} + + +def test_build_query_params_expects_correct_mapping(endpoint_extra_request_data): + """ + Check if Query params is handled and mapped to the appropriate fields correctly. + """ + expected_params = { + "key": "value", + "extraField": "extra value" + } + assert endpoint_extra_request_data.build_query_params() == expected_params + + +@endpoint_fixtures +def test_handle_response_expects_correct_mapping(endpoint_fixture, mock_response, request): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + endpoint = request.getfixturevalue(endpoint_fixture) + parsed_response = endpoint.handle_response(mock_response) + assert isinstance(parsed_response, EventDestinationResponse) + assert parsed_response.project_id == "j55aa9aa-b888-777c-dd6d-ee55e1010101010" + assert parsed_response.hmac_secret == "your_hmac_secret" diff --git a/tests/unit/domains/numbers/v1/endpoints/event_destination/test_update_event_destination_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/event_destination/test_update_event_destination_endpoint.py new file mode 100644 index 00000000..0b1871f4 --- /dev/null +++ b/tests/unit/domains/numbers/v1/endpoints/event_destination/test_update_event_destination_endpoint.py @@ -0,0 +1,64 @@ +import json +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.api.v1.internal import UpdateEventDestinationEndpoint +from sinch.domains.numbers.models.v1.internal import UpdateEventDestinationRequest +from sinch.domains.numbers.models.v1.response import EventDestinationResponse + + +@pytest.fixture +def mock_request_data(): + return UpdateEventDestinationRequest( + hmac_secret="your_hmac_secret" + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "projectId": "a99aa9aa-b888-777c-dd6d-ee55e5555555", + "hmacSecret": "your_hmac_secret" + }, + headers={"Content-Type": "application/json"} + ) + + +@pytest.fixture +def mock_response_body(): + expected_body = { + "hmacSecret": "your_hmac_secret" + } + return json.dumps(expected_body) + + +@pytest.fixture +def endpoint(mock_request_data): + return UpdateEventDestinationEndpoint("test_project_id", mock_request_data) + + +def test_build_url(endpoint, mock_sinch_client_numbers): + """ + Check if endpoint URL is constructed correctly based on input data. + """ + expected_url = "https://mock-numbers-api.sinch.com/v1/projects/test_project_id/callbackConfiguration" + assert endpoint.build_url(mock_sinch_client_numbers) == expected_url + + +def test_request_body_expects_correct_mapping(endpoint, mock_response_body): + """ + Check if request body is properly formatted as JSON. + """ + request_body = endpoint.request_body() + assert request_body == mock_response_body + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + parsed_response = endpoint.handle_response(mock_response) + assert isinstance(parsed_response, EventDestinationResponse) + assert parsed_response.project_id == "a99aa9aa-b888-777c-dd6d-ee55e5555555" + assert parsed_response.hmac_secret == "your_hmac_secret" diff --git a/tests/unit/domains/numbers/v1/endpoints/regions/test_list_available_regions_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/regions/test_list_available_regions_endpoint.py new file mode 100644 index 00000000..c5d3e588 --- /dev/null +++ b/tests/unit/domains/numbers/v1/endpoints/regions/test_list_available_regions_endpoint.py @@ -0,0 +1,73 @@ +import pytest +from sinch.domains.numbers.api.v1.internal.available_regions_endpoints import ListAvailableRegionsEndpoint +from sinch.domains.numbers.models.v1.internal import ListAvailableRegionsRequest, ListAvailableRegionsResponse +from sinch.core.models.http_response import HTTPResponse + + +@pytest.fixture +def request_data(): + return ListAvailableRegionsRequest( + types=["LOCAL", "MOBILE"] + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "availableRegions": [ + { + "regionCode": "US", + "regionName": "United States", + "types": ["LOCAL", "MOBILE", "TOLL_FREE"] + }, + { + "regionCode": "SE", + "regionName": "Sweden", + "types": ["LOCAL", "MOBILE"] + } + ] + }, + headers={"Content-Type": "application/json"} + ) + + +@pytest.fixture +def endpoint(request_data): + return ListAvailableRegionsEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_numbers): + assert (endpoint.build_url(mock_sinch_client_numbers) == + "https://mock-numbers-api.sinch.com/v1/projects/test_project_id/availableRegions") + + +def test_build_query_params_expects_correct_mapping(endpoint): + """ + Check if Query params is handled and mapped to the appropriate fields correctly. + """ + expected_params = { + "types": ["LOCAL", "MOBILE"] + } + assert endpoint.build_query_params() == expected_params + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + parsed_response = endpoint.handle_response(mock_response) + assert isinstance(parsed_response, ListAvailableRegionsResponse) + assert hasattr(parsed_response, "available_regions") + assert len(parsed_response.available_regions) == 2 + + region = parsed_response.available_regions[0] + assert region.region_code == "US" + assert region.region_name == "United States" + assert len(region.types) == 3 + + region = parsed_response.available_regions[1] + assert region.region_code == "SE" + assert region.region_name == "Sweden" + assert len(region.types) == 2 diff --git a/tests/unit/domains/numbers/v1/models/errors/test_errors_model.py b/tests/unit/domains/numbers/v1/models/errors/test_errors_model.py new file mode 100644 index 00000000..4fe383e8 --- /dev/null +++ b/tests/unit/domains/numbers/v1/models/errors/test_errors_model.py @@ -0,0 +1,29 @@ +from sinch.domains.numbers.models.v1.errors import NotFoundError + + +def test_not_found_error_deserialize_with_snake_case(): + data = { + 'code': 404, + 'message': '', + 'status': 'NOT_FOUND', + 'details': [ + { + 'type': 'ResourceInfo', + 'resourceType': 'AvailableNumber', + 'resourceName': '+1234567890', + 'owner': '', + 'description': '' + } + ] + } + + not_found_error = NotFoundError.model_validate(data) + + assert not_found_error.code == 404 + assert not_found_error.message == '' + assert not_found_error.status == 'NOT_FOUND' + assert not_found_error.details[0].type == 'ResourceInfo' + assert not_found_error.details[0].resource_type == 'AvailableNumber' + assert not_found_error.details[0].resource_name == '+1234567890' + assert not_found_error.details[0].owner == '' + assert not_found_error.details[0].description == '' diff --git a/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_requests.py b/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_requests.py new file mode 100644 index 00000000..c6cf1fca --- /dev/null +++ b/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_requests.py @@ -0,0 +1,56 @@ +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest + + +def test_to_camel_case_expects_parsed_standard_cases(): + """ + Test standard snake_case to camelCase conversion. + """ + assert BaseModelConfigurationRequest._to_camel_case("foo_bar") == "fooBar" + assert BaseModelConfigurationRequest._to_camel_case("hello_world") == "helloWorld" + assert BaseModelConfigurationRequest._to_camel_case("this_is_a_test") == "thisIsATest" + assert BaseModelConfigurationRequest._to_camel_case("PHONE_NUMBER") == "phoneNumber" + assert BaseModelConfigurationRequest._to_camel_case("appId") == "appId" + + +def test_to_camel_case_expects_parsed_edge_cases(): + """ + Test edge cases like leading/trailing underscores and multiple underscores. + """ + assert BaseModelConfigurationRequest._to_camel_case("foo__bar") == "foo_Bar" + assert BaseModelConfigurationRequest._to_camel_case("foo___bar") == "foo__Bar" + assert BaseModelConfigurationRequest._to_camel_case("trailing_") == "trailing_" + + +def test_to_camel_case_expects_empty_string(): + """ + Test empty string case. + """ + assert BaseModelConfigurationRequest._to_camel_case("") == "" + + +def test_to_camel_case_expects_single_word(): + """ + Test single-word cases. + """ + assert BaseModelConfigurationRequest._to_camel_case("word") == "word" + assert BaseModelConfigurationRequest._to_camel_case("single") == "single" + + +def test_dict_expects_camel_case_input(): + """ + Test that the model correctly handles camelCase input. + """ + data = { + "sms_configuration": {"service_plan_id": "YOUR_SMS_servicePlanId"}, + "voice_configuration": { + "appId": "YOUR_voice_appID", + "type": "RTC" + } + } + request = BaseModelConfigurationRequest(**data) + response = request.model_dump() + + assert response == { + 'smsConfiguration': {'servicePlanId': 'YOUR_SMS_servicePlanId'}, + 'voiceConfiguration': {'appId': 'YOUR_voice_appID', 'type': 'RTC'} + } diff --git a/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_response.py b/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_response.py new file mode 100644 index 00000000..2b2767cf --- /dev/null +++ b/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_response.py @@ -0,0 +1,16 @@ +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse + + +def test_base_model_response_expects_unrecognized_fields_snake_case(): + """ + Expects unrecognized fields to be dynamically added as snake_case attributes. + """ + data = { + "unexpectedField": "unexpectedValue", + "anotherExtraField": 42, + } + response = BaseModelConfigurationResponse(**data) + + # Assert unrecognized fields are dynamically added + assert response.unexpected_field == "unexpectedValue" + assert response.another_extra_field == 42 diff --git a/tests/unit/domains/numbers/v1/models/internal/test_list_active_numbers_request_model.py b/tests/unit/domains/numbers/v1/models/internal/test_list_active_numbers_request_model.py new file mode 100644 index 00000000..cedae857 --- /dev/null +++ b/tests/unit/domains/numbers/v1/models/internal/test_list_active_numbers_request_model.py @@ -0,0 +1,71 @@ +import pytest +from sinch.domains.numbers.models.v1.internal import ListActiveNumbersRequest + + +@pytest.mark.parametrize( + "order_by_input, expected_order_by", + [ + ("phone_number", "phoneNumber"), + ("PHONE_NUMBER", "phoneNumber"), + ("display_name", "displayName"), + ("DISPLAY_NAME", "displayName"), + ("new_field", "newField"), + ("newField", "newField"), + (None, None) + ] +) +def test_list_active_numbers_orderby_field_request_expects_camel_case_input(order_by_input, expected_order_by): + """ + Test that the model correctly parses order_by field. + """ + data = { + "region_code": "US", + "number_type": "MOBILE", + "order_by": order_by_input + } + + request = ListActiveNumbersRequest(**data) + + assert request.region_code == "US" + assert request.number_type == "MOBILE" + assert request.order_by == expected_order_by + + +def test_list_active_numbers_request_expects_parsed_input(): + """ + Test that the model correctly parses input. + """ + data = { + "region_code": "GB", + "number_type": "LOCAL", + "page_size": 5, + "capabilities": ["SMS", "VOICE"], + "number_search_pattern": "START", + "number_pattern": "5678", + "page_token": "abc123", + "order_by": "PHONE_NUMBER" + } + + request = ListActiveNumbersRequest(**data) + + assert request.region_code == "GB" + assert request.number_type == "LOCAL" + assert request.page_size == 5 + assert request.capabilities == ["SMS", "VOICE"] + assert request.number_search_pattern == "START" + assert request.number_pattern == "5678" + assert request.page_token == "abc123" + assert request.order_by == "phoneNumber" + + +def test_list_available_numbers_request_expects_camel_case_input(): + """ + Test that the model correctly handles camelCase input. + """ + data = { + "regionCode": "US", + "number_type": "MOBILE", + } + request = ListActiveNumbersRequest(**data) + assert request.region_code == "US" + assert request.number_type == "MOBILE" diff --git a/tests/unit/domains/numbers/v1/models/internal/test_list_active_numbers_response_model.py b/tests/unit/domains/numbers/v1/models/internal/test_list_active_numbers_response_model.py new file mode 100644 index 00000000..851396fd --- /dev/null +++ b/tests/unit/domains/numbers/v1/models/internal/test_list_active_numbers_response_model.py @@ -0,0 +1,100 @@ +from datetime import datetime, timezone +from decimal import Decimal +import pytest +from sinch.domains.numbers.models.v1.internal import ListActiveNumbersResponse + + +@pytest.fixture +def test_data(): + return { + "activeNumbers": [ + { + "phoneNumber": "+12085088605", + "projectId": "37b62a7b-0177-429a-bb0b-e10f848de0b8", + "displayName": "", + "regionCode": "US", + "type": "LOCAL", + "capability": [ + "SMS", + "VOICE" + ], + "money": { + "currencyCode": "EUR", + "amount": "0.80" + }, + "paymentIntervalMonths": 1, + "nextChargeDate": "2025-03-04T15:28:16.449951Z", + "expireAt": None, + "smsConfiguration": { + "servicePlanId": "al_2308", + "scheduledProvisioning": None, + "campaignId": "" + }, + "voiceConfiguration": { + "appId": "", + "scheduledVoiceProvisioning": { + "appId": "123456", + "status": "FAILED", + "lastUpdatedTime": "2025-02-04T15:32:06.693027Z", + "type": "RTC", + "trunkId": "", + "serviceId": "" + }, + "lastUpdatedTime": None, + "type": "RTC", + "trunkId": "", + "serviceId": "" + }, + "callbackUrl": "" + } + ], + "nextPageToken": "CgtwaG9uZU51bWJlchJnCjl0eXBlLmdvb2dsZWFwaXMuY29tL3NpbmNoLn==", + "totalSize": 10 + } + + +def assert_voice_configuration(voice_config): + assert voice_config.app_id == "" + assert voice_config.scheduled_voice_provisioning.app_id == "123456" + assert voice_config.scheduled_voice_provisioning.status == "FAILED" + expected_last_updated_time = ( + datetime(2025, 2, 4, 15, 32, 6, 693027, tzinfo=timezone.utc)) + assert voice_config.scheduled_voice_provisioning.last_updated_time == expected_last_updated_time + assert voice_config.scheduled_voice_provisioning.type == "RTC" + assert voice_config.scheduled_voice_provisioning.trunk_id == "" + assert voice_config.scheduled_voice_provisioning.service_id == "" + + +def assert_sms_configuration(sms_config): + assert sms_config.service_plan_id == "al_2308" + assert sms_config.scheduled_provisioning is None + assert sms_config.campaign_id == "" + + +def test_list_active_numbers_response_expects_correct_mapping(test_data): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + response = ListActiveNumbersResponse(**test_data) + assert hasattr(response, "content") + assert response.content == response.active_numbers + + number = response.active_numbers[0] + assert number.phone_number == "+12085088605" + assert number.project_id == "37b62a7b-0177-429a-bb0b-e10f848de0b8" + assert number.display_name == "" + assert number.region_code == "US" + assert number.type == "LOCAL" + assert number.capability == ["SMS", "VOICE"] + assert number.money.currency_code == "EUR" + assert number.money.amount == Decimal("0.80") + assert number.payment_interval_months == 1 + expected_next_charge_date = datetime( + 2025, 3, 4, 15, 28, 16, 449951, tzinfo=timezone.utc + ) + assert number.next_charge_date == expected_next_charge_date + assert number.expire_at is None + assert_sms_configuration(number.sms_configuration) + assert_voice_configuration(number.voice_configuration) + assert response.next_page_token == "CgtwaG9uZU51bWJlchJnCjl0eXBlLmdvb2dsZWFwaXMuY29tL3NpbmNoLn==" + assert response.total_size == 10 diff --git a/tests/unit/domains/numbers/v1/models/internal/test_list_available_numbers_request_model.py b/tests/unit/domains/numbers/v1/models/internal/test_list_available_numbers_request_model.py new file mode 100644 index 00000000..da3d02f9 --- /dev/null +++ b/tests/unit/domains/numbers/v1/models/internal/test_list_available_numbers_request_model.py @@ -0,0 +1,145 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.numbers.models.v1.internal import ListAvailableNumbersRequest + + +def test_list_available_numbers_request_expects_snake_case_input(): + """ + Test that the model correctly handles snake_case input. + """ + data = { + "region_code": "US", + "number_type": "MOBILE", + "page_size": 10, + "capabilities": ["SMS", "VOICE"], + "number_search_pattern": "prefix", + "number_pattern": "12345" + } + + # Instantiate the model + request = ListAvailableNumbersRequest(**data) + + # Assert the field values + assert request.region_code == "US" + assert request.number_type == "MOBILE" + assert request.page_size == 10 + assert request.capabilities == ["SMS", "VOICE"] + assert request.number_search_pattern == "prefix" + assert request.number_pattern == "12345" + + +def test_list_available_numbers_request_expects_camel_case_input(): + """ + Test that the model correctly handles camelCase input. + """ + data = { + "regionCode": "US", + "type": "MOBILE", + "size": 10, + "capabilities": ["SMS", "VOICE"], + "numberPattern.searchPattern": "prefix", + "numberPattern.pattern": "12345" + } + + # Instantiate the model + request = ListAvailableNumbersRequest(**data) + + # Assert the field values + assert request.region_code == "US" + assert request.number_type == "MOBILE" + assert request.page_size == 10 + assert request.capabilities == ["SMS", "VOICE"] + assert request.number_search_pattern == "prefix" + assert request.number_pattern == "12345" + + +def test_list_available_numbers_request_expects_mixed_case_input(): + """ + Test that the model correctly handles mixed camelCase and snake_case input. + """ + data = { + "region_code": "US", + "type": "MOBILE", + "size": 10, + "capabilities": ["SMS", "VOICE"], + "number_search_pattern": "prefix", + "numberPattern.pattern": "12345" + } + + # Instantiate the model + request = ListAvailableNumbersRequest(**data) + + # Assert the field values + assert request.region_code == "US" + assert request.number_type == "MOBILE" + assert request.page_size == 10 + assert request.capabilities == ["SMS", "VOICE"] + assert request.number_search_pattern == "prefix" + assert request.number_pattern == "12345" + + +def test_list_available_numbers_request_expects_validation_error_for_missing_required_field(): + """ + Test that the model raises a validation error for missing required fields. + """ + data = { + "number_type": "MOBILE", + "size": 10, + "capabilities": ["SMS", "VOICE"], + } + + with pytest.raises(ValidationError) as exc_info: + ListAvailableNumbersRequest(**data) + + assert "region_code" in str(exc_info.value) or "regionCode" in str(exc_info.value) + + +def test_list_available_numbers_expects_parsed_extra_field_snake_case(): + """ + Expects unrecognized fields to be dynamically added as snake_case attributes. + """ + data = { + "number_type": "MOBILE", + "size": 10, + "region_code": "US", + "capabilities": ["SMS", "VOICE"], + "extraField": "Extra Value" + } + response = ListAvailableNumbersRequest(**data) + + # Assert unknown fields + assert response.extraField == "Extra Value" + + +def test_list_available_numbers_expects_snake_case_to_parsed_extra_field_snake_case(): + """ + Expects unrecognized fields to be dynamically added as snake_case attributes. + """ + data = { + "number_type": "MOBILE", + "size": 10, + "region_code": "US", + "capabilities": ["SMS", "VOICE"], + "extra_field": "Extra Value" + } + response = ListAvailableNumbersRequest(**data) + + # Assert unknown fields + assert response.extra_field == "Extra Value" + + +def test_list_available_numbers_expects_extra_capability(): + """ + Expects unrecognized value to be added. + """ + data = { + "number_type": "MOBILE", + "size": 10, + "region_code": "US", + "capabilities": ["SMS", "VOICE", "EXTRA"], + "extra_field": "Extra Value" + } + response = ListAvailableNumbersRequest(**data) + + # Assert extra fields + assert response.capabilities == ["SMS", "VOICE", "EXTRA"] diff --git a/tests/unit/domains/numbers/v1/models/internal/test_list_available_numbers_response_model.py b/tests/unit/domains/numbers/v1/models/internal/test_list_available_numbers_response_model.py new file mode 100644 index 00000000..5dbb7644 --- /dev/null +++ b/tests/unit/domains/numbers/v1/models/internal/test_list_available_numbers_response_model.py @@ -0,0 +1,51 @@ +import pytest +from sinch.domains.numbers.models.v1.internal import ListAvailableNumbersResponse + + +@pytest.fixture +def test_data(): + return { + "availableNumbers": [ + { + "phoneNumber": "+12025550134", + "regionCode": "US", + "type": "MOBILE", + "capability": [ + "SMS", + "VOICE" + ], + "setupPrice": { + "currencyCode": "USD", + "amount": "2.00" + }, + "monthlyPrice": { + "currencyCode": "USD", + "amount": "2.00" + }, + "paymentIntervalMonths": 0, + "supportingDocumentationRequired": True + } + ] + } + + +def test_list_available_numbers_response_expects_correct_mapping(test_data): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + response = ListAvailableNumbersResponse(**test_data) + # Verify content property for pagination compatibility + assert hasattr(response, "content") + assert response.content == response.available_numbers + + number = response.content[0] + assert number.phone_number == "+12025550134" + assert number.region_code == "US" + assert number.type == "MOBILE" + assert number.capability == ["SMS", "VOICE"] + assert number.setup_price.currency_code == "USD" + assert number.setup_price.amount == 2.00 + assert number.monthly_price.currency_code == "USD" + assert number.monthly_price.amount == 2.00 + assert number.payment_interval_months == 0 + assert number.supporting_documentation_required is True diff --git a/tests/unit/domains/numbers/v1/models/internal/test_list_available_regions_request_model.py b/tests/unit/domains/numbers/v1/models/internal/test_list_available_regions_request_model.py new file mode 100644 index 00000000..6d437bf3 --- /dev/null +++ b/tests/unit/domains/numbers/v1/models/internal/test_list_available_regions_request_model.py @@ -0,0 +1,33 @@ +from sinch.domains.numbers.models.v1.internal import ListAvailableRegionsRequest + + +def test_list_available_regions_request_expects_parsed_input(): + """ + Test that the model correctly parses input with valid number types. + """ + data = { + "types": ["MOBILE", "LOCAL", "TOLL_FREE"] + } + + request = ListAvailableRegionsRequest(**data) + assert request.types == ["MOBILE", "LOCAL", "TOLL_FREE"] + + +def test_list_available_regions_request_expects_optional_fields_handled(): + """ + Test that optional fields are properly handled when not provided. + """ + data = {} + request = ListAvailableRegionsRequest(**data) + assert request.types is None + + +def test_list_available_regions_request_expects_validation_for_extra_type(): + """ + Test that validation errors are raised for invalid number types. + """ + data = { + "types": ["EXTRA_TYPE"] + } + request = ListAvailableRegionsRequest(**data) + assert request.types == ["EXTRA_TYPE"] diff --git a/tests/unit/domains/numbers/v1/models/internal/test_list_available_regions_response_model.py b/tests/unit/domains/numbers/v1/models/internal/test_list_available_regions_response_model.py new file mode 100644 index 00000000..a769ff55 --- /dev/null +++ b/tests/unit/domains/numbers/v1/models/internal/test_list_available_regions_response_model.py @@ -0,0 +1,48 @@ +import pytest +from sinch.domains.numbers.models.v1.internal import ListAvailableRegionsResponse + + +@pytest.fixture +def test_data(): + return { + "availableRegions": [ + { + "regionCode": "CA", + "regionName": "Canada", + "types": ["MOBILE", "LOCAL", "TOLL_FREE"] + }, + { + "regionCode": "SE", + "regionName": "Sweden", + "types": ["MOBILE", "LOCAL"] + } + ] + } + + +def test_list_available_regions_response_expects_correct_mapping(test_data): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + response = ListAvailableRegionsResponse(**test_data) + assert hasattr(response, "content") + assert response.content == response.available_regions + + region = response.available_regions[0] + assert region.region_code == "CA" + assert region.region_name == "Canada" + assert len(region.types) == 3 + + region = response.available_regions[1] + assert region.region_code == "SE" + assert region.region_name == "Sweden" + assert len(region.types) == 2 + + +def test_list_available_regions_response_expects_empty_list_handled(): + """ + Expects empty list to be handled correctly. + """ + response = ListAvailableRegionsResponse(available_regions=[]) + assert response.available_regions == [] + assert response.content == [] diff --git a/tests/unit/domains/numbers/v1/models/internal/test_number_request_model.py b/tests/unit/domains/numbers/v1/models/internal/test_number_request_model.py new file mode 100644 index 00000000..9e82b238 --- /dev/null +++ b/tests/unit/domains/numbers/v1/models/internal/test_number_request_model.py @@ -0,0 +1,46 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.numbers.models.v1.internal import NumberRequest + + +def test_check_number_availability_request_expects_accepts_snake_case_input(): + """ + Test that the model accepts snake_case input when allow_population_by_field_name is True. + """ + request = NumberRequest(phone_number="+1234567890") + + assert request.phone_number == "+1234567890" + + +def test_check_number_availability_request_expects_accepts_camel_case_input(): + """ + Test that the model accepts snake_case input when allow_population_by_field_name is True. + """ + request = NumberRequest(phoneNumber="+1234567890") + + assert request.phone_number == "+1234567890" + + +def test_check_number_availability_request_expects_alias_mapping_correct(): + """ + Test that the model correctly handles alias mappings for phoneNumber. + """ + request = NumberRequest(phoneNumber="+1234567890") + + assert request.model_dump(by_alias=True)["phoneNumber"] == "+1234567890" + assert request.model_dump(by_alias=False)["phone_number"] == "+1234567890" + + +def test_search_number_request_expects_validation_error_for_missing_field(): + """ + Test that the model raises a ValidationError when a required field is missing. + """ + data = {} + + with pytest.raises(ValidationError) as excinfo: + NumberRequest(**data) + + error_message = str(excinfo.value) + + assert "Field required" in error_message or "field required" in error_message + assert "phoneNumber" in error_message diff --git a/tests/unit/domains/numbers/v1/models/internal/test_rent_any_number_request_model.py b/tests/unit/domains/numbers/v1/models/internal/test_rent_any_number_request_model.py new file mode 100644 index 00000000..e615c2fd --- /dev/null +++ b/tests/unit/domains/numbers/v1/models/internal/test_rent_any_number_request_model.py @@ -0,0 +1,65 @@ +from sinch.domains.numbers.models.v1.internal import RentAnyNumberRequest + + +def test_rent_any_number_request_expects_valid_data(): + """ + Test that RentAnyNumberRequest correctly parses valid data. + """ + data = { + "numberPattern": { + "pattern": "string", + "searchPattern": "START" + }, + "regionCode": "string", + "type": "MOBILE", + "capabilities": ["SMS"], + "smsConfiguration": { + "servicePlanId": "string", + "campaignId": "string" + }, + "voiceConfiguration": { + "type": "RTC", + "appId": "string" + }, + "callbackUrl": "https://www.your-callback-server.com/callback" + } + + request = RentAnyNumberRequest(**data) + + assert request.number_pattern == { + "pattern": "string", + "searchPattern": "START" + } + assert request.region_code == "string" + assert request.number_type == "MOBILE" + assert request.capabilities == ["SMS"] + assert request.sms_configuration == { + "servicePlanId": "string", + "campaignId": "string" + } + assert request.voice_configuration == { + "type": "RTC", + "appId": "string" + } + assert request.event_destination_target == "https://www.your-callback-server.com/callback" + + +def test_rent_any_number_request_expects_missing_optional_fields(): + """ + Test that RentAnyNumberRequest handles missing optional fields correctly. + """ + data = { + "regionCode": "string", + "type": "MOBILE" + } + + request = RentAnyNumberRequest(**data) + + assert request.region_code == "string" + assert request.number_type == "MOBILE" + + assert request.number_pattern is None + assert request.capabilities is None + assert request.sms_configuration is None + assert request.voice_configuration is None + assert request.event_destination_target is None diff --git a/tests/unit/domains/numbers/v1/models/internal/test_rent_number_request_model.py b/tests/unit/domains/numbers/v1/models/internal/test_rent_number_request_model.py new file mode 100644 index 00000000..6a3d6567 --- /dev/null +++ b/tests/unit/domains/numbers/v1/models/internal/test_rent_number_request_model.py @@ -0,0 +1,94 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.numbers.models.v1.internal import RentNumberRequest + + +def test_rent_number_request_expects_snake_case_input(): + """ + Test that the model correctly handles snake_case input. + """ + data = { + "phone_number": "+1234567890", + "sms_configuration": {"service_plan_id": "YOUR_SMS_servicePlanId"}, + "voice_configuration": { + "app_id": "YOUR_voice_appID", + "type": "RTC" + }, + "callback_url": "https://example.com/callback" + } + + # Instantiate the model + request = RentNumberRequest(**data) + + # Assert the field values + assert request.phone_number == "+1234567890" + assert request.sms_configuration == {"service_plan_id": "YOUR_SMS_servicePlanId"} + assert request.voice_configuration == { + "app_id": "YOUR_voice_appID", + "type": "RTC" + } + assert request.callback_url == "https://example.com/callback" + + +def test_rent_number_request_expects_mixed_case_input(): + """ + Test that the model correctly handles mixed camelCase and snake_case input. + """ + data = { + "phone_number": "+1234567890", + "smsConfiguration": {"servicePlanId": "YOUR_SMS_servicePlanId"}, + "voice_configuration": { + "appId": "YOUR_voice_appID", + "type": "RTC" + }, + "callback_url": "https://example.com/callback" + } + request = RentNumberRequest(**data) + + # Assert fields are populated correctly + assert request.phone_number == "+1234567890" + assert request.sms_configuration == {"servicePlanId": "YOUR_SMS_servicePlanId"} + assert request.voice_configuration == { + "appId": "YOUR_voice_appID", + "type": "RTC" + } + assert request.callback_url == "https://example.com/callback" + + +def test_rent_number_request_expects_validation_error_for_missing_field(): + """ + Test that the model raises a validation error for missing required fields. + """ + data = { + "sms_configuration": {"servicePlanId": "YOUR_SMS_servicePlanId"}, + "voice_configuration": { + "appId": "YOUR_voice_appID", + "type": "RTC" + }, + "callback_url": "https://example.com/callback" + } + with pytest.raises(ValidationError) as exc_info: + RentNumberRequest(**data) + + # Assert the error mentions the missing phone_number field + assert "phone_number" in str(exc_info.value) or "phoneNumber" in str(exc_info.value) + + +def test_rent_number_request_expects_optional_param_none(): + """ + Test that the model correctly handles snake_case input. + """ + data = { + "phone_number": "+1234567890", + "sms_configuration": {"service_plan_id": "YOUR_SMS_servicePlanId"}, + "callback_url": "https://example.com/callback" + } + + # Instantiate the model + request = RentNumberRequest(**data) + + # Assert the field values + assert request.phone_number == "+1234567890" + assert request.sms_configuration == {"service_plan_id": "YOUR_SMS_servicePlanId"} + assert request.voice_configuration is None + assert request.callback_url == "https://example.com/callback" diff --git a/tests/unit/domains/numbers/v1/models/internal/test_update_active_numbers_request_model.py b/tests/unit/domains/numbers/v1/models/internal/test_update_active_numbers_request_model.py new file mode 100644 index 00000000..4b7c0db3 --- /dev/null +++ b/tests/unit/domains/numbers/v1/models/internal/test_update_active_numbers_request_model.py @@ -0,0 +1,74 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.numbers.models.v1.internal import UpdateNumberConfigurationRequest + + +def test_update_number_configuration_request_valid_expects_parsed_response(): + """ + Test that the model correctly handles request. + """ + data = { + "phoneNumber": "+1234567890", + "displayName": "Test Number", + "smsConfiguration": { + "servicePlanId": "string", + "campaignId": "YOUR_campaignId_from_TCR" + }, + "voiceConfiguration": { + "type": "RTC", + "appId": "YOUR_Voice_appId" + }, + "callbackUrl": "https://www.your-callback-server.com/callback" + } + request = UpdateNumberConfigurationRequest(**data) + assert request.phone_number == "+1234567890" + assert request.display_name == "Test Number" + assert request.sms_configuration == { + "servicePlanId": "string", + "campaignId": "YOUR_campaignId_from_TCR" + } + assert request.voice_configuration == { + "type": "RTC", + "appId": "YOUR_Voice_appId" + } + assert request.event_destination_target == "https://www.your-callback-server.com/callback" + + +def test_update_number_configuration_request_missing_phone_number_expects_error(): + """ + Test that the model raises a validation error for missing required fields. + """ + data = { + "displayName": "Test Number", + "callbackUrl": "https://www.your-callback-server.com/callback" + } + with pytest.raises(ValidationError): + UpdateNumberConfigurationRequest(**data) + + +def test_update_number_configuration_request_invalid_phone_number(): + """ + Test that the model raises a validation error for invalid phone number type. + """ + data = { + "phoneNumber": 1234567890, + "displayName": "Test Number", + "callbackUrl": "https://www.your-callback-server.com/callback" + } + with pytest.raises(ValidationError): + UpdateNumberConfigurationRequest(**data) + + +def test_update_number_configuration_request_optional_fields(): + """ + Test that optional fields are handled correctly. + """ + data = { + "phoneNumber": "+1234567890" + } + request = UpdateNumberConfigurationRequest(**data) + assert request.phone_number == "+1234567890" + assert request.display_name is None + assert request.sms_configuration is None + assert request.voice_configuration is None + assert request.event_destination_target is None diff --git a/tests/unit/domains/numbers/v1/models/internal/test_update_event_destination_request_model.py b/tests/unit/domains/numbers/v1/models/internal/test_update_event_destination_request_model.py new file mode 100644 index 00000000..4d1b2820 --- /dev/null +++ b/tests/unit/domains/numbers/v1/models/internal/test_update_event_destination_request_model.py @@ -0,0 +1,45 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.numbers.models.v1.internal import UpdateEventDestinationRequest + + +def test_update_numbers_callback_config_request_expects_parsed_input(): + """ + Test that the model correctly handles valid input. + """ + data = { + "hmacSecret": "test-secret-key" + } + request = UpdateEventDestinationRequest(**data) + assert request.hmac_secret == "test-secret-key" + + +def test_update_numbers_callback_request_expects_validation_for_extra_type(): + """ + Test that validation errors are raised for invalid number types. + """ + data = { + "extra": "Extra Value" + } + request = UpdateEventDestinationRequest(**data) + assert request.extra == "Extra Value" + + +def test_update_numbers_callback_config_request_expects_optional_field_handled(): + """ + Test that hmac_secret is optional and can be None. + """ + data = {} + request = UpdateEventDestinationRequest(**data) + assert request.hmac_secret is None + + +def test_update_numbers_callback_config_request_expects_validation_error(): + """ + Test that the model raises a validation error for invalid hmac_secret type. + """ + data = { + "hmacSecret": 12345 + } + with pytest.raises(ValidationError): + UpdateEventDestinationRequest(**data) diff --git a/tests/unit/domains/numbers/v1/models/response/test_active_number_model.py b/tests/unit/domains/numbers/v1/models/response/test_active_number_model.py new file mode 100644 index 00000000..1429fb44 --- /dev/null +++ b/tests/unit/domains/numbers/v1/models/response/test_active_number_model.py @@ -0,0 +1,107 @@ +from datetime import datetime, timezone +import pytest +from sinch.domains.numbers.models.v1.response import ActiveNumber + + +@pytest.fixture +def test_data(): + return { + "phoneNumber": "+12025550134", + "displayName": "string", + "regionCode": "US", + "type": "MOBILE", + "capability": ["SMS"], + "money": {"currencyCode": "USD", "amount": "2.00"}, + "paymentIntervalMonths": 0, + "nextChargeDate": "2025-01-22T13:19:31.095Z", + "expireAt": "2025-02-04T13:15:31.095Z", + "smsConfiguration": { + "servicePlanId": "string", + "campaignId": "string", + "scheduledProvisioning": { + "servicePlanId": "string", + "campaignId": "string", + "status": "PROVISIONING_STATUS_UNSPECIFIED", + "lastUpdatedTime": "2025-01-24T13:19:31.095Z", + "errorCodes": ["ERROR_CODE_UNSPECIFIED"], + }, + }, + "voiceConfiguration": { + "type": "EST", + "lastUpdatedTime": "2025-01-25T18:19:31.095Z", + "scheduledVoiceProvisioning": { + "type": "EST", + "lastUpdatedTime": "2025-01-26T18:19:31.095Z", + "status": "PROVISIONING_STATUS_UNSPECIFIED", + "trunkId": "string", + }, + "appId": "string", + }, + "callbackUrl": "https://www.your-callback-server.com/callback", + "extraField": "Extra content", + "extraDict": {"key": "value"} + } + + +def assert_sms_configuration(sms_config): + """ + Assert sms_configuration fields. + """ + assert sms_config.service_plan_id == "string" + assert sms_config.campaign_id == "string" + scheduled_provisioning = sms_config.scheduled_provisioning + assert scheduled_provisioning.service_plan_id == "string" + assert scheduled_provisioning.campaign_id == "string" + assert scheduled_provisioning.status == "PROVISIONING_STATUS_UNSPECIFIED" + expected_last_updated_time = ( + datetime(2025, 1, 24, 13, 19, 31, 95000, tzinfo=timezone.utc)) + assert scheduled_provisioning.last_updated_time == expected_last_updated_time + assert scheduled_provisioning.error_codes == ["ERROR_CODE_UNSPECIFIED"] + + +def assert_voice_configuration(voice_config): + """ + Assert voice_configuration fields. + """ + assert voice_config.type == "EST" + expected_last_updated_time = ( + datetime(2025, 1, 25, 18, 19, 31, 95000, tzinfo=timezone.utc)) + assert voice_config.last_updated_time == expected_last_updated_time + assert voice_config.app_id == "string" + scheduled_voice_provisioning = voice_config.scheduled_voice_provisioning + assert scheduled_voice_provisioning.type == "EST" + expected_last_updated_time = ( + datetime(2025, 1, 26, 18, 19, 31, 95000, tzinfo=timezone.utc)) + assert scheduled_voice_provisioning.last_updated_time == expected_last_updated_time + assert scheduled_voice_provisioning.status == "PROVISIONING_STATUS_UNSPECIFIED" + assert scheduled_voice_provisioning.trunk_id == "string" + + +def test_active_number_response_expects_all_fields_mapped_correctly(test_data): + """ + Expects all fields to map correctly from camelCase input, + converts nested keys to snake_case, and handles dynamic fields + """ + + response = ActiveNumber(**test_data) + + assert response.phone_number == "+12025550134" + assert response.display_name == "string" + assert response.region_code == "US" + assert response.type == "MOBILE" + assert response.capability == ["SMS"] + assert response.money.currency_code == "USD" + assert response.payment_interval_months == 0 + expected_next_charge_data = ( + datetime(2025, 1, 22, 13, 19, 31, 95000, tzinfo=timezone.utc)) + assert response.next_charge_date == expected_next_charge_data + expected_expire_at = ( + datetime(2025, 2, 4, 13, 15, 31, 95000, tzinfo=timezone.utc)) + assert response.expire_at == expected_expire_at + assert response.event_destination_target == "https://www.your-callback-server.com/callback" + + assert_sms_configuration(response.sms_configuration) + assert_voice_configuration(response.voice_configuration) + + assert response.extra_field == "Extra content" + assert response.extra_dict == {"key": "value"} diff --git a/tests/unit/domains/numbers/v1/models/response/test_available_number_model.py b/tests/unit/domains/numbers/v1/models/response/test_available_number_model.py new file mode 100644 index 00000000..8011092b --- /dev/null +++ b/tests/unit/domains/numbers/v1/models/response/test_available_number_model.py @@ -0,0 +1,85 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.numbers.models.v1.response import AvailableNumber + + +def test_available_number_expects_valid_data(): + """ + Expects AvailableNumber to be created with valid data. + """ + data = { + "phoneNumber": "+1234567890", + "regionCode": "US", + "type": "MOBILE", + "capability": ["SMS", "VOICE"], + "setupPrice": {"amount": "10.00", "currencyCode": "USD"}, + "monthlyPrice": {"amount": "5.00", "currencyCode": "USD"}, + "paymentIntervalMonths": 1, + "supportingDocumentationRequired": True + } + + response = AvailableNumber(**data) + + assert response.phone_number == "+1234567890" + assert response.region_code == "US" + assert response.type == "MOBILE" + assert response.capability == ["SMS", "VOICE"] + assert response.setup_price.amount == 10.00 + assert response.setup_price.currency_code == "USD" + assert response.monthly_price.amount == 5.00 + assert response.monthly_price.currency_code == "USD" + assert response.payment_interval_months == 1 + assert response.supporting_documentation_required is True + + +def test_available_number_missing_optional_fields_expects_valid_data(): + """ + Verifies AvailableNumber can be created with missing optional fields, + and doesn't include them in the response. + """ + data = { + "phoneNumber": "+1234567890", + "regionCode": "US", + "type": "MOBILE", + "capability": ["SMS", "VOICE"], + "setupPrice": {"amount": "10.00", "currencyCode": "USD"}, + "monthlyPrice": {"amount": "5.00", "currencyCode": "USD"} + } + + response = AvailableNumber(**data) + + assert response.payment_interval_months is None + assert response.supporting_documentation_required is None + + +def test_available_number_expects_parsed_new_type(): + """ + Test AvailableNumber with invalid data. + """ + data = { + "phoneNumber": "+1234567890", + "regionCode": "US", + "type": "NEW_TYPE", + "capability": ["SMS", "VOICE"], + "setupPrice": {"amount": "10.00", "currencyCode": "USD"}, + "monthlyPrice": {"amount": "5.00", "currencyCode": "USD"} + } + + response = AvailableNumber(**data) + assert response.type == "NEW_TYPE" + + +def test_available_number_expects_validation_error_for_missing_required_fields(): + """ + Check if validation fails when required fields are missing. + """ + data = { + "phoneNumber": "+1234567890", + "regionCode": "US", + "capability": ["SMS", "VOICE"], + "setupPrice": {"amount": "10.00", "currencyCode": "USD"}, + "monthlyPrice": {"amount": "5.00", "currencyCode": "USD"} + } + + with pytest.raises(ValidationError): + AvailableNumber.model_validate(data, strict=True) diff --git a/tests/unit/domains/numbers/v1/models/response/test_available_region_model.py b/tests/unit/domains/numbers/v1/models/response/test_available_region_model.py new file mode 100644 index 00000000..50918116 --- /dev/null +++ b/tests/unit/domains/numbers/v1/models/response/test_available_region_model.py @@ -0,0 +1,50 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.numbers.models.v1.response import AvailableRegion + + +@pytest.fixture +def test_data(): + return { + "regionCode": "US", + "regionName": "United States", + "types": ["MOBILE", "LOCAL", "TOLL_FREE"] + } + + +def test_available_region_expects_all_fields_mapped_correctly(test_data): + """ + Expects all fields to map correctly from camelCase input, + and handles type conversions properly + """ + response = AvailableRegion(**test_data) + + assert response.region_code == "US" + assert response.region_name == "United States" + assert len(response.types) == 3 + + +def test_available_region_expects_empty_types_list(): + """ + Expects no error when types list is empty + """ + empty_types_list = { + "regionCode": "US", + "regionName": "United States", + "types": [] + } + response = AvailableRegion(**empty_types_list) + assert len(response.types) == 0 + + +def test_available_region_expects_validation_error_on_non_string_fields(): + """ + Expects validation error when StrictStr fields receive non-string values + """ + invalid_data = { + "regionCode": 123, + "regionName": "United States", + "types": ["MOBILE"] + } + with pytest.raises(ValidationError): + AvailableRegion(**invalid_data) diff --git a/tests/unit/domains/numbers/v1/models/response/test_event_destination_response_model.py b/tests/unit/domains/numbers/v1/models/response/test_event_destination_response_model.py new file mode 100644 index 00000000..f568e549 --- /dev/null +++ b/tests/unit/domains/numbers/v1/models/response/test_event_destination_response_model.py @@ -0,0 +1,25 @@ +import pytest +from sinch.domains.numbers.models.v1.response import EventDestinationResponse + + +@pytest.fixture +def test_data(): + return { + "projectId": "project-test-id", + "hmacSecret": "secret-key-456", + "extraField": "Extra content", + "extraDict": {"key": "value"} + } + + +def test_numbers_callback_config_response_all_fields(test_data): + """ + Expects all fields to map correctly from camelCase input + and handle extra fields appropriately + """ + response = EventDestinationResponse(**test_data) + + assert response.project_id == "project-test-id" + assert response.hmac_secret == "secret-key-456" + assert response.extra_field == "Extra content" + assert response.extra_dict == {"key": "value"} diff --git a/tests/unit/domains/numbers/v1/models/shared/test_numbers.py b/tests/unit/domains/numbers/v1/models/shared/test_numbers.py new file mode 100644 index 00000000..1a0c5d63 --- /dev/null +++ b/tests/unit/domains/numbers/v1/models/shared/test_numbers.py @@ -0,0 +1,165 @@ +from datetime import datetime, timezone +from pydantic import TypeAdapter +from sinch.domains.numbers.models.v1.shared import ScheduledSmsProvisioning, SmsConfiguration +from sinch.domains.numbers.models.v1.types import VoiceConfiguration + + +def test_scheduled_provisioning_sms_configuration_valid_expects_parsed_data(): + """ + Test a valid instance of ScheduledProvisioningSmsConfiguration + """ + data = { + "servicePlanId": "test_plan", + "campaignId": "test_campaign", + "status": "ACTIVE", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "errorCodes": ["ERROR_CODE_1"] + } + config = ScheduledSmsProvisioning.model_validate(data) + + assert config.service_plan_id == "test_plan" + assert config.campaign_id == "test_campaign" + assert config.status == "ACTIVE" + expected_last_updated_time = ( + datetime(2025, 1, 24, 9, 32, 27, 437000, tzinfo=timezone.utc)) + assert config.last_updated_time == expected_last_updated_time + assert config.error_codes == ["ERROR_CODE_1"] + + +def test_scheduled_provisioning_sms_configuration_optional_fields_expects_parsed_data(): + """ + Test missing optional fields in ScheduledProvisioningSmsConfiguration + """ + data = { + "servicePlanId": "test_plan" + } + config = ScheduledSmsProvisioning.model_validate(data) + + assert config.service_plan_id == "test_plan" + assert config.campaign_id is None + assert config.status is None + assert config.last_updated_time is None + assert config.error_codes is None + + +def test_sms_configuration_valid_expects_parsed_data(): + """ + Test a valid instance of SmsConfiguration + """ + data = { + "servicePlanId": "test_plan", + "campaignId": "test_campaign", + "scheduledProvisioning": { + "servicePlanId": "test_plan", + "status": "ACTIVE" + } + } + config = SmsConfiguration.model_validate(data) + + assert config.service_plan_id == "test_plan" + assert config.campaign_id == "test_campaign" + assert config.scheduled_provisioning is not None + assert config.scheduled_provisioning.service_plan_id == "test_plan" + assert config.scheduled_provisioning.status == "ACTIVE" + + +def test_voice_configuration_rtc_valid_expects_parsed_data(): + """ + Test a valid RTC voice configuration + """ + data = { + "type": "RTC", + "appId": "test_app", + "trunkId": "", + "serviceId": "", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "scheduledVoiceProvisioning": { + "type": "EST", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "status": "WAITING", + "appId": "", + "trunkId": "test_app_est", + "serviceId": "" + } + } + + voice_configuration_adapter = TypeAdapter(VoiceConfiguration) + config = voice_configuration_adapter.validate_python(data) + + assert config.type == "RTC" + assert config.app_id == "test_app" + assert (config.last_updated_time == + datetime(2025, 1, 24, 9, 32, 27, 437000, + tzinfo=timezone.utc)) + assert config.scheduled_voice_provisioning is not None + assert config.scheduled_voice_provisioning.type == "EST" + assert config.scheduled_voice_provisioning.status == "WAITING" + assert config.scheduled_voice_provisioning.trunk_id == "test_app_est" + + +def test_voice_configuration_est_valid_expects_parsed_data(): + """ + Test a valid EST voice configuration + """ + data = { + "type": "EST", + "appId": "", + "trunkId": "test_trunk", + "serviceId": "", + "lastUpdatedTime": "2025-02-25T09:32:27.437Z", + "scheduledVoiceProvisioning": { + "type": "EST", + "lastUpdatedTime": "2025-02-25T09:32:27.437Z", + "status": "ACTIVE", + "appId": "", + "trunkId": "test_trunk", + "serviceId": "" + } + } + + voice_configuration_adapter = TypeAdapter(VoiceConfiguration) + config = voice_configuration_adapter.validate_python(data) + + assert config.type == "EST" + assert config.trunk_id == "test_trunk" + assert (config.last_updated_time == + datetime(2025, 2, 25, 9, 32, 27, 437000, + tzinfo=timezone.utc)) + assert config.scheduled_voice_provisioning is not None + assert config.scheduled_voice_provisioning.type == "EST" + assert config.scheduled_voice_provisioning.trunk_id == "test_trunk" + assert config.scheduled_voice_provisioning.status == "ACTIVE" + + +def test_voice_configuration_fax_valid_expects_parsed_data(): + """ + Test a valid FAX voice configuration + """ + data = { + "type": "FAX", + "appId": "", + "trunkId": "", + "serviceId": "test_service", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "scheduledVoiceProvisioning": { + "type": "FAX", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "status": "ACTIVE", + "appId": "", + "trunkId": "", + "serviceId": "test_service" + } + } + + voice_configuration_adapter = TypeAdapter(VoiceConfiguration) + config = voice_configuration_adapter.validate_python(data) + + assert config.type == "FAX" + assert config.service_id == "test_service" + assert (config.last_updated_time == + datetime(2025, 1, 24, 9, 32, 27, 437000, + tzinfo=timezone.utc)) + assert config.scheduled_voice_provisioning is not None + assert config.scheduled_voice_provisioning.type == "FAX" + assert config.scheduled_voice_provisioning.status == "ACTIVE" + assert config.scheduled_voice_provisioning.service_id == "test_service" diff --git a/tests/unit/domains/numbers/v1/sinch_events/events/test_number_sinch_event_model.py b/tests/unit/domains/numbers/v1/sinch_events/events/test_number_sinch_event_model.py new file mode 100644 index 00000000..775ac95b --- /dev/null +++ b/tests/unit/domains/numbers/v1/sinch_events/events/test_number_sinch_event_model.py @@ -0,0 +1,79 @@ +import pytest +from datetime import datetime, timezone +from pydantic import ValidationError +from sinch.domains.numbers.sinch_events.v1.events import NumberSinchEvent + + +@pytest.fixture +def valid_data(): + return { + "eventId": "event-123", + "timestamp": "2025-04-08T09:38:04.854087+00:00", + "projectId": "project-456", + "resourceId": "+1234567890", + "resourceType": "ACTIVE_NUMBER", + "eventType": "PROVISIONING_TO_VOICE_PLATFORM", + "status": "SUCCEEDED", + "failureCode": None, + "internalFailureCode": None, + "extraField": "extra_value" + } + + +@pytest.fixture +def invalid_data(): + return { + "eventId": 123, + "timestamp": "invalid-timestamp", + "projectId": "project-456", + "resourceId": "+1234567890" + } + + +def test_number_sinch_event_response_expects_parsed_data(valid_data): + """ + Expects all fields to map correctly from camelCase input + and handle valid data appropriately. + """ + response = NumberSinchEvent(**valid_data) + + assert response.event_id == "event-123" + assert response.timestamp == datetime( + 2025, 4, 8, 9, 38, 4, 854087, tzinfo=timezone.utc + ) + assert response.project_id == "project-456" + assert response.resource_id == "+1234567890" + assert response.resource_type == "ACTIVE_NUMBER" + assert response.event_type == "PROVISIONING_TO_VOICE_PLATFORM" + assert response.status == "SUCCEEDED" + assert response.failure_code is None + assert response.internal_failure_code is None + assert response.extra_field == "extra_value" + + +def test_number_sinch_event_response_missing_optional_fields_expects_parsed_data(): + """ + Expects the model to handle missing optional fields. + """ + data = { + "eventId": "event-123", + "projectId": "project-456" + } + response = NumberSinchEvent(**data) + + assert response.event_id == "event-123" + assert response.project_id == "project-456" + assert response.timestamp is None + assert response.resource_id is None + assert response.resource_type is None + assert response.event_type is None + assert response.status is None + assert response.failure_code is None + + +def test_number_sinch_event_response_invalid_data_expects_validation_error(invalid_data): + """ + Expects the model to raise a validation error for invalid data. + """ + with pytest.raises(ValidationError): + NumberSinchEvent(**invalid_data) diff --git a/tests/unit/domains/numbers/v1/sinch_events/test_number_sinch_event.py b/tests/unit/domains/numbers/v1/sinch_events/test_number_sinch_event.py new file mode 100644 index 00000000..af02fae2 --- /dev/null +++ b/tests/unit/domains/numbers/v1/sinch_events/test_number_sinch_event.py @@ -0,0 +1,82 @@ +from datetime import datetime, timezone +import pytest +from sinch.domains.numbers.sinch_events.v1 import SinchEvents +from sinch.domains.numbers.sinch_events.v1.events import NumberSinchEvent + + +@pytest.fixture +def string_to_sign(): + return ( + '{"eventId":"01jr7stexp0znky34pj07dwp41","timestamp":"2025-04-07T09:38:04.85408760",' + '"projectId":"project-id","resourceId":"+1234567890",' + '"resourceType":"ACTIVE_NUMBER","eventType":"PROVISIONING_TO_VOICE_PLATFORM",' + '"status":"SUCCEEDED","failureCode":null,"internalFailureCode":null}' + ) + + +@pytest.fixture +def sinch_events(): + return SinchEvents('my-callback-secret') + + +@pytest.fixture +def base_payload_parse_event(): + return { + "eventId": "01jr7stexp0znky34pj07dwp41", + "projectId": "project-id", + "resourceId": "+1234567890", + "resourceType": "ACTIVE_NUMBER", + "eventType": "PROVISIONING_TO_VOICE_PLATFORM", + "status": "SUCCEEDED", + "failureCode": None, + "internalFailureCode": None, + "timestamp": "2025-04-07T09:38:04.854087603" + } + + +def test_valid_signature_header_expects_successful_validation(sinch_events, string_to_sign): + headers = { + "X-Sinch-Signature": "8e58baa351ffa5e0d7eaef3c739d0d7aa6093da3" + } + response = sinch_events.validate_authentication_header(headers, string_to_sign) + assert response is True + + +@pytest.mark.parametrize( + "test_name, timestamp_str", + [ + ( + "parse_without_timezone_suffix", "2025-04-06T08:45:27.565347" + ), + ( + "parse_with_zulu_timezone_suffix", "2025-04-06T08:45:27.565347Z" + ), + ( + "parse_with_extra_digits", "2025-04-06T08:45:27.56534760" + ) + ] +) +def test_parse_event_expects_timestamp_as_utc(sinch_events, test_name, timestamp_str): + payload = {"timestamp": timestamp_str} + parsed = sinch_events.parse_event(payload) + expected = datetime( + 2025, 4, 6, 8, 45, 27, 565347, tzinfo=timezone.utc + ) + assert parsed.timestamp == expected + + +def test_parse_event_expects_parsed_response(sinch_events, base_payload_parse_event): + response = sinch_events.parse_event(base_payload_parse_event) + assert isinstance(response, NumberSinchEvent) + assert response.event_id == "01jr7stexp0znky34pj07dwp41" + assert response.project_id == "project-id" + assert response.resource_id == "+1234567890" + assert response.resource_type == "ACTIVE_NUMBER" + assert response.event_type == "PROVISIONING_TO_VOICE_PLATFORM" + assert response.status == "SUCCEEDED" + assert response.failure_code is None + assert response.internal_failure_code is None + expected_timestamp = datetime( + 2025, 4, 7, 9, 38, 4, 854087, tzinfo=timezone.utc + ) + assert response.timestamp == expected_timestamp diff --git a/tests/unit/domains/numbers/v1/test_active_numbers.py b/tests/unit/domains/numbers/v1/test_active_numbers.py new file mode 100644 index 00000000..d87ac199 --- /dev/null +++ b/tests/unit/domains/numbers/v1/test_active_numbers.py @@ -0,0 +1,121 @@ +from sinch.core.pagination import TokenBasedPaginator +from sinch.domains.numbers.api.v1 import ActiveNumbers +from sinch.domains.numbers.api.v1.internal import ( + ListActiveNumbersEndpoint, GetNumberConfigurationEndpoint, UpdateNumberConfigurationEndpoint, + ReleaseNumberFromProjectEndpoint +) +from sinch.domains.numbers.models.v1.internal import ( + ListActiveNumbersRequest, ListActiveNumbersResponse, NumberRequest, UpdateNumberConfigurationRequest +) +from sinch.domains.numbers.models.v1.response import ActiveNumber + + +def test_list_active_numbers_expects_valid_request(mock_sinch_client_numbers, mocker): + """ + Test that the ActiveNumbers.list() method sends the correct request + and handles the response properly. + """ + mock_response = ListActiveNumbersResponse(activeNumbers=[]) + mock_sinch_client_numbers.configuration.transport.request.return_value = mock_response + + # Spy on the ActiveNumbersEndpoint to capture calls + spy_endpoint = mocker.spy(ListActiveNumbersEndpoint, "__init__") + + active_numbers = ActiveNumbers(mock_sinch_client_numbers) + response = active_numbers.list( + region_code="US", + number_type="LOCAL", + capabilities=["SMS", "VOICE"], + page_size=10, + number_search_pattern="START" + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == ListActiveNumbersRequest( + region_code="US", + number_type="LOCAL", + page_size=10, + capabilities=["SMS", "VOICE"], + number_search_pattern="START", + ) + + assert isinstance(response, TokenBasedPaginator) + assert hasattr(response, 'has_next_page') + assert response.result == mock_response + mock_sinch_client_numbers.configuration.transport.request.assert_called_once() + + +def test_check_availability_expects_correct_request(mock_sinch_client_numbers, mocker): + """ + Test that the ActiveNumbers.get() method sends the correct request + and handles the response properly. + """ + mock_response = ActiveNumber.model_construct() + mock_sinch_client_numbers.configuration.transport.request.return_value = mock_response + + spy_endpoint = mocker.spy(GetNumberConfigurationEndpoint, "__init__") + + active_numbers = ActiveNumbers(mock_sinch_client_numbers) + response = active_numbers.get(phone_number="+1234567890") + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == NumberRequest(phone_number="+1234567890") + + assert response == mock_response + + +def test_release_active_numbers_expects_valid_request(mock_sinch_client_numbers, mocker): + """ + Test that the ActiveNumbers.update() method sends the correct request + and handles the response properly. + """ + mock_response = ActiveNumber.model_construct() + mock_sinch_client_numbers.configuration.transport.request.return_value = mock_response + + spy_endpoint = mocker.spy(ReleaseNumberFromProjectEndpoint, "__init__") + + active_numbers = ActiveNumbers(mock_sinch_client_numbers) + response = active_numbers.release( + phone_number="+1234567890", + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == NumberRequest( + phone_number="+1234567890", + ) + + assert response == mock_response + + +def test_update_active_numbers_expects_valid_request(mock_sinch_client_numbers, mocker): + """ + Test that the ActiveNumbers.update() method sends the correct request + and handles the response properly. + """ + mock_response = ActiveNumber.model_construct() + mock_sinch_client_numbers.configuration.transport.request.return_value = mock_response + + spy_endpoint = mocker.spy(UpdateNumberConfigurationEndpoint, "__init__") + + active_numbers = ActiveNumbers(mock_sinch_client_numbers) + response = active_numbers.update( + phone_number="+1234567890", + display_name="Test Display Name" + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == UpdateNumberConfigurationRequest( + phone_number="+1234567890", + display_name="Test Display Name" + ) + + assert response == mock_response diff --git a/tests/unit/domains/numbers/v1/test_available_numbers.py b/tests/unit/domains/numbers/v1/test_available_numbers.py new file mode 100644 index 00000000..775c282b --- /dev/null +++ b/tests/unit/domains/numbers/v1/test_available_numbers.py @@ -0,0 +1,92 @@ +from sinch.core.pagination import TokenBasedPaginator +from sinch.domains.numbers.api.v1 import AvailableNumbers +from sinch.domains.numbers.api.v1.internal import ( + AvailableNumbersEndpoint, RentNumberEndpoint, SearchForNumberEndpoint +) +from sinch.domains.numbers.models.v1.internal import ( + ListAvailableNumbersRequest, ListAvailableNumbersResponse, NumberRequest, RentNumberRequest +) +from sinch.domains.numbers.models.v1.response import ActiveNumber, AvailableNumber + + +def test_list_available_numbers_expects_valid_request(mock_sinch_client_numbers, mocker): + """ + Test that the AvailableNumbers.search_for_available_numbers method sends the correct request + and handles the response properly. + """ + mock_response = ListAvailableNumbersResponse(availableNumbers=[]) + mock_sinch_client_numbers.configuration.transport.request.return_value = mock_response + + # Spy on the AvailableNumbersEndpoint to capture calls + spy_endpoint = mocker.spy(AvailableNumbersEndpoint, "__init__") + + available_numbers = AvailableNumbers(mock_sinch_client_numbers) + response = available_numbers.search_for_available_numbers( + region_code="US", + number_type="LOCAL", + capabilities=["SMS", "VOICE"], + page_size=10, + number_search_pattern="START" + ) + + # Verify the endpoint's constructor was called with the correct arguments + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + # Validate the kwargs + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == ListAvailableNumbersRequest( + region_code="US", + number_type="LOCAL", + page_size=10, + capabilities=["SMS", "VOICE"], + number_search_pattern="START", + ) + + assert isinstance(response, TokenBasedPaginator) + assert hasattr(response, 'has_next_page') + assert response.result == mock_response + mock_sinch_client_numbers.configuration.transport.request.assert_called_once() + + +def test_rent_number_expects_correct_request(mock_sinch_client_numbers, mocker): + """ + Test that the AvailableNumbers.rent method sends the correct request + and handles the response properly. + """ + # Use construct to create a mock response without Pydantic validation + mock_response = ActiveNumber.model_construct() + mock_sinch_client_numbers.configuration.transport.request.return_value = mock_response + + spy_endpoint = mocker.spy(RentNumberEndpoint, "__init__") + + available_numbers = AvailableNumbers(mock_sinch_client_numbers) + response = available_numbers.rent(phone_number="+1234567890") + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == RentNumberRequest(phone_number="+1234567890") + + assert response == mock_response + + +def test_check_availability_expects_correct_request(mock_sinch_client_numbers, mocker): + """ + Test that the AvailableNumbers.check_availability method sends the correct request + and handles the response properly. + """ + mock_response = AvailableNumber.model_construct() + mock_sinch_client_numbers.configuration.transport.request.return_value = mock_response + + spy_endpoint = mocker.spy(SearchForNumberEndpoint, "__init__") + + available_numbers = AvailableNumbers(mock_sinch_client_numbers) + response = available_numbers.check_availability(phone_number="+1234567890") + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == NumberRequest(phone_number="+1234567890") + + assert response == mock_response diff --git a/tests/unit/domains/numbers/v1/test_available_regions.py b/tests/unit/domains/numbers/v1/test_available_regions.py new file mode 100644 index 00000000..2742322c --- /dev/null +++ b/tests/unit/domains/numbers/v1/test_available_regions.py @@ -0,0 +1,35 @@ +from sinch.core.pagination import TokenBasedPaginator +from sinch.domains.numbers.api.v1 import AvailableRegions +from sinch.domains.numbers.api.v1.internal import ListAvailableRegionsEndpoint +from sinch.domains.numbers.models.v1.internal import ( + ListAvailableRegionsRequest, ListAvailableRegionsResponse, +) + + +def test_list_available_regions_expects_valid_request(mock_sinch_client_numbers, mocker): + """ + Test that the AvailableRegions.list() method sends the correct request + and handles the response properly. + """ + mock_response = ListAvailableRegionsResponse(availableRegions=[]) + mock_sinch_client_numbers.configuration.transport.request.return_value = mock_response + + spy_endpoint = mocker.spy(ListAvailableRegionsEndpoint, "__init__") + + available_regions = AvailableRegions(mock_sinch_client_numbers) + response = available_regions.list( + types=["MOBILE", "LOCAL", "TOLL_FREE"] + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == ListAvailableRegionsRequest( + types=["MOBILE", "LOCAL", "TOLL_FREE"] + ) + + assert isinstance(response, TokenBasedPaginator) + assert hasattr(response, 'has_next_page') + assert response.result == mock_response + mock_sinch_client_numbers.configuration.transport.request.assert_called_once() diff --git a/tests/unit/domains/numbers/v1/test_event_destinations.py b/tests/unit/domains/numbers/v1/test_event_destinations.py new file mode 100644 index 00000000..a9d55ff9 --- /dev/null +++ b/tests/unit/domains/numbers/v1/test_event_destinations.py @@ -0,0 +1,68 @@ +import pytest +from sinch.domains.numbers.api.v1 import EventDestinations +from sinch.domains.numbers.api.v1.internal import ( + GetEventDestinationEndpoint, UpdateEventDestinationEndpoint +) +from sinch.domains.numbers.models.v1.internal import UpdateEventDestinationRequest +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest +from sinch.domains.numbers.models.v1.response import EventDestinationResponse + + +@pytest.mark.parametrize( + "test_name, config_kwargs, expected_request_data", + [ + ( + "without_extra_params", {}, None + ), + ( + "with_extra_params", {"kwargs": {"extra_param": "value"}}, + BaseModelConfigurationRequest(kwargs={"extra_param": "value"}) + ), + ], +) +def test_get_numbers_callback_config_expects_valid_request( + mock_sinch_client_numbers, mocker, test_name, config_kwargs, expected_request_data +): + """ + Test that the get() method sends the correct request + and handles the response properly with or without extra parameters. + """ + mock_response = EventDestinationResponse(project_id="test_project_id", hmac_secret="test_secret") + mock_sinch_client_numbers.configuration.transport.request.return_value = mock_response + spy_endpoint = mocker.spy(GetEventDestinationEndpoint, "__init__") + + event_destination = EventDestinations(mock_sinch_client_numbers) + response = event_destination.get(**config_kwargs) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + if expected_request_data: + assert kwargs["request_data"] == expected_request_data + + assert response == mock_response + mock_sinch_client_numbers.configuration.transport.request.assert_called_once() + + +def test_update_numbers_callback_config_expects_valid_request(mock_sinch_client_numbers, mocker): + """ + Test that the update() method sends the correct request + and handles the response properly. + """ + mock_response = EventDestinationResponse(project_id="test_project_id", hmac_secret="new_secret") + mock_sinch_client_numbers.configuration.transport.request.return_value = mock_response + + spy_endpoint = mocker.spy(UpdateEventDestinationEndpoint, "__init__") + + event_destination = EventDestinations(mock_sinch_client_numbers) + response = event_destination.update(hmac_secret="new_secret") + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == UpdateEventDestinationRequest(hmac_secret="new_secret") + + assert response == mock_response + mock_sinch_client_numbers.configuration.transport.request.assert_called_once() diff --git a/tests/unit/domains/sms/v1/endpoints/batches/test_cancel_batch_message_endpoint.py b/tests/unit/domains/sms/v1/endpoints/batches/test_cancel_batch_message_endpoint.py new file mode 100644 index 00000000..3fe94ced --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/batches/test_cancel_batch_message_endpoint.py @@ -0,0 +1,141 @@ +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.internal import CancelBatchMessageEndpoint +from sinch.domains.sms.models.v1.internal import BatchIdRequest +from sinch.domains.sms.models.v1.shared.text_response import TextResponse +from sinch.domains.sms.api.v1.exceptions import SmsException +from datetime import datetime, timezone + + +@pytest.fixture +def request_data(): + return BatchIdRequest(batch_id="01FC66621XXXXX119Z8PMV1QPQ") + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": ["12017777777"], + "from": "12015555555", + "canceled": True, + "body": "SMS body message", + "type": "mt_text", + "created_at": "2024-06-06T09:22:14.304Z", + "modified_at": "2024-06-06T09:22:48.054Z", + "delivery_report": "full", + "send_at": "2024-06-06T09:25:00Z", + "expire_at": "2024-06-09T09:25:00Z", + "feedback_enabled": True, + "flash_message": False, + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def mock_error_response(): + return HTTPResponse( + status_code=404, + body={ + "code": 404, + "text": "Batch not found", + "status": "NotFound", + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return CancelBatchMessageEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/batches/01FC66621XXXXX119Z8PMV1QPQ" + ) + + +def test_build_url_with_different_batch_id(mock_sinch_client_sms): + """Test that the URL is built correctly with different batch_id.""" + request_data = BatchIdRequest(batch_id="01W4FFL35P4NC4K35SMSBATCH1") + endpoint = CancelBatchMessageEndpoint("test_project_id", request_data) + + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/batches/01W4FFL35P4NC4K35SMSBATCH1" + ) + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + parsed_response = endpoint.handle_response(mock_response) + + assert isinstance(parsed_response, TextResponse) + assert parsed_response.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed_response.to == ["12017777777"] + assert parsed_response.from_ == "12015555555" + assert parsed_response.canceled is True + assert parsed_response.body == "SMS body message" + assert parsed_response.type == "mt_text" + assert parsed_response.delivery_report == "full" + assert parsed_response.feedback_enabled is True + assert parsed_response.flash_message is False + + expected_created_at = datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ) + expected_modified_at = datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ) + expected_send_at = datetime(2024, 6, 6, 9, 25, 0, tzinfo=timezone.utc) + expected_expire_at = datetime(2024, 6, 9, 9, 25, 0, tzinfo=timezone.utc) + + assert parsed_response.created_at == expected_created_at + assert parsed_response.modified_at == expected_modified_at + assert parsed_response.send_at == expected_send_at + assert parsed_response.expire_at == expected_expire_at + + +def test_handle_response_expects_sms_exception_on_error( + endpoint, mock_error_response +): + """ + Test that SmsException is raised when server returns an error. + """ + with pytest.raises(SmsException) as exc_info: + endpoint.handle_response(mock_error_response) + + assert exc_info.value.is_from_server is True + assert exc_info.value.http_response.status_code == 404 + + +def test_handle_response_expects_canceled_batch(endpoint): + """ + Test that a canceled batch response is correctly parsed. + """ + canceled_response = HTTPResponse( + status_code=200, + body={ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": ["12017777777"], + "from": "12015555555", + "canceled": True, + "body": "SMS body message", + "type": "mt_text", + "created_at": "2024-06-06T09:22:14.304Z", + "modified_at": "2024-06-06T09:22:48.054Z", + }, + headers={"Content-Type": "application/json"}, + ) + + parsed_response = endpoint.handle_response(canceled_response) + assert parsed_response.canceled is True + assert parsed_response.id == "01FC66621XXXXX119Z8PMV1QPQ" diff --git a/tests/unit/domains/sms/v1/endpoints/batches/test_dry_run_batches_endpoint.py b/tests/unit/domains/sms/v1/endpoints/batches/test_dry_run_batches_endpoint.py new file mode 100644 index 00000000..736704ce --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/batches/test_dry_run_batches_endpoint.py @@ -0,0 +1,243 @@ +import json +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.internal import DryRunEndpoint +from sinch.domains.sms.models.v1.internal.dry_run_request import ( + DryRunTextRequest, + DryRunBinaryRequest, + DryRunMediaRequest, +) +from sinch.domains.sms.models.v1.response.dry_run_response import ( + DryRunResponse, +) +from sinch.domains.sms.models.v1.shared import ( + DryRunPerRecipientDetails, + MediaBody, +) + + +@pytest.fixture +def text_request_data(): + return DryRunTextRequest( + to=["+46701234567", "+46709876543"], + from_="+46701111111", + body="Your verification code is 123456", + ) + + +@pytest.fixture +def binary_request_data(): + return DryRunBinaryRequest( + to=["+46701234567"], + from_="+46701111111", + body="SGVsbG8gV29ybGQh", + udh="06050423F423F4", + ) + + +@pytest.fixture +def media_request_data(): + return DryRunMediaRequest( + to=["+46701234567"], + from_="+46701111111", + body=MediaBody( + url="https://capybara.com/image.jpg", + message="Check out this image!", + subject="Image", + ), + ) + + +@pytest.fixture +def mock_dry_run_response(): + return DryRunResponse( + number_of_recipients=2, + number_of_messages=1, + per_recipient=[ + DryRunPerRecipientDetails( + recipient="+46701234567", + body="Your order #12345 has been shipped", + number_of_parts=1, + encoding="text", + ), + DryRunPerRecipientDetails( + recipient="+46709876543", + body="Reminder: Your appointment is tomorrow at 2 PM", + number_of_parts=1, + encoding="text", + ), + ], + ) + + +@pytest.fixture +def mock_dry_run_response_without_per_recipient(): + return DryRunResponse( + number_of_recipients=2, + number_of_messages=1, + per_recipient=None, + ) + + +@pytest.fixture +def mock_http_response_for_dry_run(): + return HTTPResponse( + status_code=200, + body={ + "number_of_recipients": 2, + "number_of_messages": 1, + "per_recipient": [ + { + "recipient": "+46701234567", + "body": "Your order #12345 has been shipped", + "number_of_parts": 1, + "encoding": "text", + }, + { + "recipient": "+46709876543", + "body": "Reminder: Your appointment is tomorrow at 2 PM", + "number_of_parts": 1, + "encoding": "text", + }, + ], + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def mock_http_response_without_per_recipient(): + return HTTPResponse( + status_code=200, + body={ + "number_of_recipients": 2, + "number_of_messages": 1, + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(text_request_data): + return DryRunEndpoint("test_project_id", text_request_data) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_sms): + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/batches/dry_run" + ) + + +def test_build_query_params_expects_per_recipient_and_number_of_recipients( + text_request_data, +): + """Test that query parameters are extracted correctly.""" + text_request_data.per_recipient = True + text_request_data.number_of_recipients = 100 + + endpoint = DryRunEndpoint("test_project_id", text_request_data) + query_params = endpoint.build_query_params() + + assert query_params == {"per_recipient": True, "number_of_recipients": 100} + + +def test_build_query_params_expects_excludes_none_values(text_request_data): + """Test that None values are excluded from query parameters.""" + text_request_data.per_recipient = None + text_request_data.number_of_recipients = None + + endpoint = DryRunEndpoint("test_project_id", text_request_data) + query_params = endpoint.build_query_params() + + assert query_params == {} + + +def test_request_body_expects_excludes_query_params(text_request_data): + """Test that query params are excluded from request body.""" + text_request_data.per_recipient = True + text_request_data.number_of_recipients = 100 + + endpoint = DryRunEndpoint("test_project_id", text_request_data) + body = json.loads(endpoint.request_body()) + + assert "per_recipient" not in body + assert "number_of_recipients" not in body + assert "to" in body + assert "from" in body + assert "body" in body + assert body["type"] == "mt_text" + + +def test_request_body_expects_binary_request_data(binary_request_data): + """Test that binary request body contains correct fields.""" + binary_request_data.per_recipient = False + binary_request_data.number_of_recipients = 100 + + endpoint = DryRunEndpoint("test_project_id", binary_request_data) + body = json.loads(endpoint.request_body()) + + assert "per_recipient" not in body + assert "number_of_recipients" not in body + assert "to" in body + assert "from" in body + assert "body" in body + assert "udh" in body + assert body["type"] == "mt_binary" + assert body["udh"] == "06050423F423F4" + assert body["body"] == "SGVsbG8gV29ybGQh" + + +def test_request_body_expects_media_request_data(media_request_data): + """Test that media request body contains correct fields.""" + media_request_data.per_recipient = True + media_request_data.number_of_recipients = 50 + + endpoint = DryRunEndpoint("test_project_id", media_request_data) + body = json.loads(endpoint.request_body()) + + assert "per_recipient" not in body + assert "number_of_recipients" not in body + assert "to" in body + assert "from" in body + assert "body" in body + assert body["type"] == "mt_media" + assert "url" in body["body"] + assert "message" in body["body"] + assert "subject" in body["body"] + assert body["body"]["url"] == "https://capybara.com/image.jpg" + assert body["body"]["message"] == "Check out this image!" + assert body["body"]["subject"] == "Image" + + +def test_handle_response_expects_correct_mapping( + endpoint, mock_http_response_for_dry_run +): + """Test that response is correctly mapped to DryRunResponse.""" + parsed_response = endpoint.handle_response(mock_http_response_for_dry_run) + + assert isinstance(parsed_response, DryRunResponse) + assert parsed_response.number_of_recipients == 2 + assert parsed_response.number_of_messages == 1 + assert parsed_response.per_recipient is not None + assert len(parsed_response.per_recipient) == 2 + assert parsed_response.per_recipient[0].recipient == "+46701234567" + assert ( + parsed_response.per_recipient[0].body + == "Your order #12345 has been shipped" + ) + assert parsed_response.per_recipient[0].number_of_parts == 1 + + +def test_handle_response_expects_response_without_per_recipient( + endpoint, mock_http_response_without_per_recipient +): + """Test that response without per_recipient is handled correctly.""" + parsed_response = endpoint.handle_response( + mock_http_response_without_per_recipient + ) + + assert isinstance(parsed_response, DryRunResponse) + assert parsed_response.number_of_recipients == 2 + assert parsed_response.number_of_messages == 1 + assert parsed_response.per_recipient is None diff --git a/tests/unit/domains/sms/v1/endpoints/batches/test_get_batch_message_endpoint.py b/tests/unit/domains/sms/v1/endpoints/batches/test_get_batch_message_endpoint.py new file mode 100644 index 00000000..157b55f0 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/batches/test_get_batch_message_endpoint.py @@ -0,0 +1,76 @@ +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.internal import GetBatchMessageEndpoint +from sinch.domains.sms.models.v1.internal import BatchIdRequest +from sinch.domains.sms.models.v1.shared.text_response import TextResponse +from datetime import datetime, timezone + + +@pytest.fixture +def request_data(): + return BatchIdRequest(batch_id="01FC66621XXXXX119Z8PMV1QPQ") + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": ["+46701234567"], + "from": "+46701111111", + "canceled": False, + "body": "Your verification code is 123456", + "type": "mt_text", + "created_at": "2024-06-06T09:22:14.304Z", + "modified_at": "2024-06-06T09:22:48.054Z", + "delivery_report": "full", + "send_at": "2024-06-06T09:25:00Z", + "expire_at": "2024-06-09T09:25:00Z", + "feedback_enabled": True, + "flash_message": False, + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return GetBatchMessageEndpoint("test_project_id", request_data) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/batches/01FC66621XXXXX119Z8PMV1QPQ" + ) + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """Test that the response is handled and mapped to the appropriate fields correctly.""" + parsed_response = endpoint.handle_response(mock_response) + + assert isinstance(parsed_response, TextResponse) + assert parsed_response.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed_response.to == ["+46701234567"] + assert parsed_response.from_ == "+46701111111" + assert parsed_response.canceled is False + assert parsed_response.body == "Your verification code is 123456" + assert parsed_response.type == "mt_text" + assert parsed_response.delivery_report == "full" + assert parsed_response.feedback_enabled is True + assert parsed_response.flash_message is False + + assert parsed_response.created_at == datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ) + assert parsed_response.modified_at == datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ) + assert parsed_response.send_at == datetime( + 2024, 6, 6, 9, 25, 0, tzinfo=timezone.utc + ) + assert parsed_response.expire_at == datetime( + 2024, 6, 9, 9, 25, 0, tzinfo=timezone.utc + ) diff --git a/tests/unit/domains/sms/v1/endpoints/batches/test_list_batches_endpoint.py b/tests/unit/domains/sms/v1/endpoints/batches/test_list_batches_endpoint.py new file mode 100644 index 00000000..f983429c --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/batches/test_list_batches_endpoint.py @@ -0,0 +1,146 @@ +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.internal import ListBatchesEndpoint +from sinch.domains.sms.models.v1.internal import ListBatchesRequest +from sinch.domains.sms.models.v1.response.list_batches_response import ( + ListBatchesResponse, +) +from datetime import datetime, timezone + + +@pytest.fixture +def request_data(): + return ListBatchesRequest( + page=0, + page_size=10, + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "count": 2, + "page": 0, + "page_size": 10, + "batches": [ + { + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": ["+46701234567"], + "from": "+46701111111", + "canceled": False, + "body": "Your verification code is 123456", + "type": "mt_text", + "created_at": "2024-06-06T09:22:14.304Z", + "modified_at": "2024-06-06T09:22:48.054Z", + }, + { + "id": "01W4FFL35P4NC4K35SMSBATCH1", + "to": ["+46709876543"], + "from": "+46701111111", + "canceled": False, + "body": "Your order #12345 has been shipped", + "type": "mt_text", + "created_at": "2024-06-07T10:15:30.123Z", + "modified_at": "2024-06-07T10:15:35.456Z", + }, + ], + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return ListBatchesEndpoint("test_project_id", request_data) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/batches" + ) + + +def test_build_query_params_expects_all_params(): + """Test that query parameters are extracted correctly.""" + request_data = ListBatchesRequest( + page=1, + page_size=20, + start_date=datetime(2024, 6, 1, tzinfo=timezone.utc), + end_date=datetime(2024, 6, 30, tzinfo=timezone.utc), + from_=["+46701111111", "+46702222222"], + client_reference="test_ref_123", + ) + + endpoint = ListBatchesEndpoint("test_project_id", request_data) + query_params = endpoint.build_query_params() + + assert query_params["page"] == 1 + assert query_params["page_size"] == 20 + assert query_params["from"] == ["+46701111111", "+46702222222"] + assert query_params["client_reference"] == "test_ref_123" + assert "start_date" in query_params + assert "end_date" in query_params + + +def test_build_query_params_expects_excludes_none_values(request_data): + """Test that None values are excluded from query parameters.""" + endpoint = ListBatchesEndpoint("test_project_id", request_data) + query_params = endpoint.build_query_params() + + assert "start_date" not in query_params + assert "end_date" not in query_params + assert "from" not in query_params + assert "client_reference" not in query_params + assert query_params["page"] == 0 + assert query_params["page_size"] == 10 + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """Test that the response is handled and mapped to the appropriate fields correctly.""" + parsed_response = endpoint.handle_response(mock_response) + + assert isinstance(parsed_response, ListBatchesResponse) + assert parsed_response.count == 2 + assert parsed_response.page == 0 + assert parsed_response.page_size == 10 + assert parsed_response.batches is not None + assert len(parsed_response.batches) == 2 + + first_batch = parsed_response.batches[0] + assert first_batch.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert first_batch.to == ["+46701234567"] + assert first_batch.from_ == "+46701111111" + assert first_batch.body == "Your verification code is 123456" + assert first_batch.type == "mt_text" + + second_batch = parsed_response.batches[1] + assert second_batch.id == "01W4FFL35P4NC4K35SMSBATCH1" + assert second_batch.to == ["+46709876543"] + assert second_batch.body == "Your order #12345 has been shipped" + + +def test_handle_response_expects_empty_batches_list(): + """Test that response with empty batches list is handled correctly.""" + request_data = ListBatchesRequest(page=0, page_size=10) + endpoint = ListBatchesEndpoint("test_project_id", request_data) + + empty_response = HTTPResponse( + status_code=200, + body={ + "count": 0, + "page": 0, + "page_size": 10, + "batches": [], + }, + headers={"Content-Type": "application/json"}, + ) + + parsed_response = endpoint.handle_response(empty_response) + assert isinstance(parsed_response, ListBatchesResponse) + assert parsed_response.count == 0 + assert parsed_response.batches == [] + assert len(parsed_response.content) == 0 diff --git a/tests/unit/domains/sms/v1/endpoints/batches/test_replace_batches_endpoint.py b/tests/unit/domains/sms/v1/endpoints/batches/test_replace_batches_endpoint.py new file mode 100644 index 00000000..d796a2f6 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/batches/test_replace_batches_endpoint.py @@ -0,0 +1,168 @@ +import json +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.internal import ReplaceBatchEndpoint +from sinch.domains.sms.models.v1.internal.replace_batch_request import ( + ReplaceTextRequest, + ReplaceBinaryRequest, + ReplaceMediaRequest, +) +from sinch.domains.sms.models.v1.shared.text_response import TextResponse +from sinch.domains.sms.models.v1.shared import MediaBody +from datetime import datetime, timezone + + +@pytest.fixture +def text_request_data(): + return ReplaceTextRequest( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567", "+46709876543"], + from_="+46701111111", + body="Your verification code is 123456", + ) + + +@pytest.fixture +def binary_request_data(): + return ReplaceBinaryRequest( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567"], + from_="+46701111111", + body="SGVsbG8gV29ybGQh", + udh="06050423F423F4", + ) + + +@pytest.fixture +def media_request_data(): + return ReplaceMediaRequest( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567"], + from_="+46701111111", + body=MediaBody( + url="https://capybara.com/image.jpg", + message="Check out this image!", + subject="Image", + ), + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": ["+46701234567", "+46709876543"], + "from": "+46701111111", + "canceled": False, + "body": "Your verification code is 123456", + "type": "mt_text", + "created_at": "2024-06-06T09:22:14.304Z", + "modified_at": "2024-06-06T09:22:48.054Z", + "delivery_report": "full", + "send_at": "2024-06-06T09:25:00Z", + "expire_at": "2024-06-09T09:25:00Z", + "feedback_enabled": True, + "flash_message": False, + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(text_request_data): + return ReplaceBatchEndpoint("test_project_id", text_request_data) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/batches/01FC66621XXXXX119Z8PMV1QPQ" + ) + + +def test_request_body_expects_excludes_batch_id(text_request_data): + """Test that the request body is correct for a text request.""" + endpoint = ReplaceBatchEndpoint("test_project_id", text_request_data) + body = json.loads(endpoint.request_body()) + + assert "batch_id" not in body + assert "to" in body + assert "from" in body + assert "body" in body + assert body["type"] == "mt_text" + + +def test_request_body_expects_text_request_data(text_request_data): + """Test that the request body is correct for a text request.""" + endpoint = ReplaceBatchEndpoint("test_project_id", text_request_data) + body = json.loads(endpoint.request_body()) + + assert body["to"] == ["+46701234567", "+46709876543"] + assert body["from"] == "+46701111111" + assert body["body"] == "Your verification code is 123456" + assert body["type"] == "mt_text" + + +def test_request_body_expects_binary_request_data(binary_request_data): + """Test that the request body is correct for a binary request.""" + endpoint = ReplaceBatchEndpoint("test_project_id", binary_request_data) + body = json.loads(endpoint.request_body()) + + assert "batch_id" not in body + assert "to" in body + assert "from" in body + assert "body" in body + assert "udh" in body + assert body["type"] == "mt_binary" + assert body["udh"] == "06050423F423F4" + assert body["body"] == "SGVsbG8gV29ybGQh" + + +def test_request_body_expects_media_request_data(media_request_data): + """Test that the request body is correct for a media request.""" + endpoint = ReplaceBatchEndpoint("test_project_id", media_request_data) + body = json.loads(endpoint.request_body()) + + assert "batch_id" not in body + assert "to" in body + assert "from" in body + assert "body" in body + assert body["type"] == "mt_media" + assert "url" in body["body"] + assert "message" in body["body"] + assert "subject" in body["body"] + assert body["body"]["url"] == "https://capybara.com/image.jpg" + assert body["body"]["message"] == "Check out this image!" + assert body["body"]["subject"] == "Image" + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """Test that the response is handled and mapped to the appropriate fields correctly.""" + parsed_response = endpoint.handle_response(mock_response) + + assert isinstance(parsed_response, TextResponse) + assert parsed_response.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed_response.to == ["+46701234567", "+46709876543"] + assert parsed_response.from_ == "+46701111111" + assert parsed_response.canceled is False + assert parsed_response.body == "Your verification code is 123456" + assert parsed_response.type == "mt_text" + assert parsed_response.delivery_report == "full" + assert parsed_response.feedback_enabled is True + assert parsed_response.flash_message is False + + assert parsed_response.created_at == datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ) + assert parsed_response.modified_at == datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ) + assert parsed_response.send_at == datetime( + 2024, 6, 6, 9, 25, 0, tzinfo=timezone.utc + ) + assert parsed_response.expire_at == datetime( + 2024, 6, 9, 9, 25, 0, tzinfo=timezone.utc + ) diff --git a/tests/unit/domains/sms/v1/endpoints/batches/test_send_batches_endpoint.py b/tests/unit/domains/sms/v1/endpoints/batches/test_send_batches_endpoint.py new file mode 100644 index 00000000..41aee1f7 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/batches/test_send_batches_endpoint.py @@ -0,0 +1,167 @@ +import json +import pytest +from datetime import datetime, timezone +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.internal import SendSMSEndpoint +from sinch.domains.sms.models.v1.shared.text_request import TextRequest +from sinch.domains.sms.models.v1.shared.binary_request import BinaryRequest +from sinch.domains.sms.models.v1.shared.media_request import MediaRequest +from sinch.domains.sms.models.v1.shared.text_response import TextResponse +from sinch.domains.sms.models.v1.shared import MediaBody + + +@pytest.fixture +def text_request_data(): + return TextRequest( + to=["+46701234567", "+46709876543"], + from_="+46701111111", + body="Your verification code is 123456", + ) + + +@pytest.fixture +def binary_request_data(): + return BinaryRequest( + to=["+46701234567"], + from_="+46701111111", + body="SGVsbG8gV29ybGQh", + udh="06050423F423F4", + ) + + +@pytest.fixture +def media_request_data(): + return MediaRequest( + to=["+46701234567"], + from_="+46701111111", + body=MediaBody( + url="https://capybara.com/image.jpg", + message="Check out this image!", + subject="Image", + ), + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=201, + body={ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": ["+46701234567", "+46709876543"], + "from": "+46701111111", + "canceled": False, + "body": "Your verification code is 123456", + "type": "mt_text", + "created_at": "2024-06-06T09:22:14.304Z", + "modified_at": "2024-06-06T09:22:14.304Z", + "delivery_report": "full", + "send_at": "2024-06-06T09:25:00Z", + "expire_at": "2024-06-09T09:25:00Z", + "feedback_enabled": True, + "flash_message": False, + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(text_request_data): + return SendSMSEndpoint("test_project_id", text_request_data) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/batches" + ) + + +def test_request_body_expects_text_request_data(text_request_data): + """Test that text request body contains correct fields.""" + endpoint = SendSMSEndpoint("test_project_id", text_request_data) + body = json.loads(endpoint.request_body()) + + assert "to" in body + assert "from" in body + assert "body" in body + assert body["type"] == "mt_text" + assert body["to"] == ["+46701234567", "+46709876543"] + assert body["from"] == "+46701111111" + assert body["body"] == "Your verification code is 123456" + + +def test_request_body_uses_callback_url_alias_for_event_destination_target(): + """Ensure event_destination_target is serialized as backend callback_url.""" + request = TextRequest( + to=["+46701234567"], + from_="+46701111111", + body="Hello", + event_destination_target="https://example.com/callback", + ) + endpoint = SendSMSEndpoint("test_project_id", request) + body = json.loads(endpoint.request_body()) + + assert body["callback_url"] == "https://example.com/callback" + assert "event_destination_target" not in body + + +def test_request_body_expects_binary_request_data(binary_request_data): + """Test that binary request body contains correct fields.""" + endpoint = SendSMSEndpoint("test_project_id", binary_request_data) + body = json.loads(endpoint.request_body()) + + assert "to" in body + assert "from" in body + assert "body" in body + assert "udh" in body + assert body["type"] == "mt_binary" + assert body["udh"] == "06050423F423F4" + assert body["body"] == "SGVsbG8gV29ybGQh" + + +def test_request_body_expects_media_request_data(media_request_data): + """Test that media request body contains correct fields.""" + endpoint = SendSMSEndpoint("test_project_id", media_request_data) + body = json.loads(endpoint.request_body()) + + assert "to" in body + assert "from" in body + assert "body" in body + assert body["type"] == "mt_media" + assert "url" in body["body"] + assert "message" in body["body"] + assert "subject" in body["body"] + assert body["body"]["url"] == "https://capybara.com/image.jpg" + assert body["body"]["message"] == "Check out this image!" + assert body["body"]["subject"] == "Image" + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """Test that the response is handled and mapped to the appropriate fields correctly.""" + parsed_response = endpoint.handle_response(mock_response) + + assert isinstance(parsed_response, TextResponse) + assert parsed_response.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed_response.to == ["+46701234567", "+46709876543"] + assert parsed_response.from_ == "+46701111111" + assert parsed_response.canceled is False + assert parsed_response.body == "Your verification code is 123456" + assert parsed_response.type == "mt_text" + assert parsed_response.delivery_report == "full" + assert parsed_response.feedback_enabled is True + assert parsed_response.flash_message is False + + assert parsed_response.created_at == datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ) + assert parsed_response.modified_at == datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ) + assert parsed_response.send_at == datetime( + 2024, 6, 6, 9, 25, 0, tzinfo=timezone.utc + ) + assert parsed_response.expire_at == datetime( + 2024, 6, 9, 9, 25, 0, tzinfo=timezone.utc + ) diff --git a/tests/unit/domains/sms/v1/endpoints/batches/test_send_delivery_feedback_endpoint.py b/tests/unit/domains/sms/v1/endpoints/batches/test_send_delivery_feedback_endpoint.py new file mode 100644 index 00000000..90d7f4a4 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/batches/test_send_delivery_feedback_endpoint.py @@ -0,0 +1,77 @@ +import json +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.internal import DeliveryFeedbackEndpoint +from sinch.domains.sms.models.v1.internal import DeliveryFeedbackRequest + + +@pytest.fixture +def request_data(): + return DeliveryFeedbackRequest( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + recipients=["+46701234567", "+46709876543"], + ) + + +@pytest.fixture +def endpoint(request_data): + return DeliveryFeedbackEndpoint("test_project_id", request_data) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/batches/01FC66621XXXXX119Z8PMV1QPQ/delivery_feedback" + ) + + +def test_request_body_expects_correct_data(request_data): + """Test that the request body contains correct fields.""" + endpoint = DeliveryFeedbackEndpoint("test_project_id", request_data) + body = json.loads(endpoint.request_body()) + + assert "batch_id" not in body + assert "recipients" in body + assert body["recipients"] == ["+46701234567", "+46709876543"] + + +def test_request_body_expects_single_recipient(): + """Test that the request body works with a single recipient.""" + request_data = DeliveryFeedbackRequest( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + recipients=["+46701234567"], + ) + endpoint = DeliveryFeedbackEndpoint("test_project_id", request_data) + body = json.loads(endpoint.request_body()) + + assert body["recipients"] == ["+46701234567"] + + +def test_request_body_expects_empty_recipients(): + """Test that the request body works with empty recipients list.""" + request_data = DeliveryFeedbackRequest( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + recipients=[], + ) + endpoint = DeliveryFeedbackEndpoint("test_project_id", request_data) + body = json.loads(endpoint.request_body()) + + assert body["recipients"] == [] + + +def test_handle_response_expects_success_with_empty_body(): + """Test that response with 202 status code and empty body is handled correctly.""" + request_data = DeliveryFeedbackRequest( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + recipients=["+46701234567"], + ) + endpoint = DeliveryFeedbackEndpoint("test_project_id", request_data) + + empty_response = HTTPResponse( + status_code=202, + headers={}, + ) + + result = endpoint.handle_response(empty_response) + assert result is None diff --git a/tests/unit/domains/sms/v1/endpoints/batches/test_update_batches_endpoint.py b/tests/unit/domains/sms/v1/endpoints/batches/test_update_batches_endpoint.py new file mode 100644 index 00000000..0164b159 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/batches/test_update_batches_endpoint.py @@ -0,0 +1,174 @@ +import json +import pytest +from datetime import datetime, timezone +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.internal import UpdateBatchMessageEndpoint +from sinch.domains.sms.models.v1.internal.update_batch_message_request import ( + UpdateTextRequestWithBatchId, + UpdateBinaryRequestWithBatchId, + UpdateMediaRequestWithBatchId, +) +from sinch.domains.sms.models.v1.shared.text_response import TextResponse +from sinch.domains.sms.models.v1.shared import MediaBody + + +@pytest.fixture +def text_request_data(): + return UpdateTextRequestWithBatchId( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + body="Updated verification code: 789012", + ) + + +@pytest.fixture +def binary_request_data(): + return UpdateBinaryRequestWithBatchId( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + udh="06050423F423F4", + body="VXBkYXRlZCBiaW5hcnkgZGF0YQ==", + ) + + +@pytest.fixture +def media_request_data(): + return UpdateMediaRequestWithBatchId( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + body=MediaBody( + url="https://capybara.com/updated-image.jpg", + message="Updated image message!", + subject="Updated Image", + ), + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": ["+46701234567"], + "from": "+46701111111", + "canceled": False, + "body": "Updated verification code: 789012", + "type": "mt_text", + "created_at": "2024-06-06T09:22:14.304Z", + "modified_at": "2024-06-06T10:30:00.123Z", + "delivery_report": "full", + "send_at": "2024-06-06T09:25:00Z", + "expire_at": "2024-06-09T09:25:00Z", + "feedback_enabled": True, + "flash_message": False, + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(text_request_data): + return UpdateBatchMessageEndpoint("test_project_id", text_request_data) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/batches/01FC66621XXXXX119Z8PMV1QPQ" + ) + + +def test_request_body_expects_excludes_batch_id(text_request_data): + """Test that batch_id is excluded from request body.""" + endpoint = UpdateBatchMessageEndpoint("test_project_id", text_request_data) + body = json.loads(endpoint.request_body()) + + assert "batch_id" not in body + assert "body" in body + assert body["body"] == "Updated verification code: 789012" + + +def test_request_body_expects_text_request_data(text_request_data): + """Test that text request body contains correct fields.""" + text_request_data.from_ = "+46702222222" + text_request_data.to_add = ["+46709999999"] + text_request_data.to_remove = ["+46708888888"] + + endpoint = UpdateBatchMessageEndpoint("test_project_id", text_request_data) + body = json.loads(endpoint.request_body()) + + assert "batch_id" not in body + assert body["from"] == "+46702222222" + assert body["to_add"] == ["+46709999999"] + assert body["to_remove"] == ["+46708888888"] + assert body["body"] == "Updated verification code: 789012" + + +def test_request_body_expects_binary_request_data(binary_request_data): + """Test that binary request body contains correct fields.""" + binary_request_data.from_ = "+46702222222" + binary_request_data.to_add = ["+46709999999"] + + endpoint = UpdateBatchMessageEndpoint( + "test_project_id", binary_request_data + ) + body = json.loads(endpoint.request_body()) + + assert "batch_id" not in body + assert "udh" in body + assert body["type"] == "mt_binary" + assert body["udh"] == "06050423F423F4" + assert body["body"] == "VXBkYXRlZCBiaW5hcnkgZGF0YQ==" + assert body["from"] == "+46702222222" + assert body["to_add"] == ["+46709999999"] + + +def test_request_body_expects_media_request_data(media_request_data): + """Test that media request body contains correct fields.""" + media_request_data.from_ = "+46702222222" + media_request_data.to_add = ["+46709999999"] + + endpoint = UpdateBatchMessageEndpoint( + "test_project_id", media_request_data + ) + body = json.loads(endpoint.request_body()) + + assert "batch_id" not in body + assert "body" in body + assert body["type"] == "mt_media" + assert "url" in body["body"] + assert "message" in body["body"] + assert "subject" in body["body"] + assert body["body"]["url"] == "https://capybara.com/updated-image.jpg" + assert body["body"]["message"] == "Updated image message!" + assert body["body"]["subject"] == "Updated Image" + assert body["from"] == "+46702222222" + assert body["to_add"] == ["+46709999999"] + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """Test that the response is handled and mapped to the appropriate fields correctly.""" + parsed_response = endpoint.handle_response(mock_response) + + assert isinstance(parsed_response, TextResponse) + assert parsed_response.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed_response.to == ["+46701234567"] + assert parsed_response.from_ == "+46701111111" + assert parsed_response.canceled is False + assert parsed_response.body == "Updated verification code: 789012" + assert parsed_response.type == "mt_text" + assert parsed_response.delivery_report == "full" + assert parsed_response.feedback_enabled is True + assert parsed_response.flash_message is False + + assert parsed_response.created_at == datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ) + assert parsed_response.modified_at == datetime( + 2024, 6, 6, 10, 30, 0, 123000, tzinfo=timezone.utc + ) + assert parsed_response.send_at == datetime( + 2024, 6, 6, 9, 25, 0, tzinfo=timezone.utc + ) + assert parsed_response.expire_at == datetime( + 2024, 6, 9, 9, 25, 0, tzinfo=timezone.utc + ) diff --git a/tests/unit/domains/sms/v1/endpoints/delivery_reports/test_get_batch_delivery_report_endpoint.py b/tests/unit/domains/sms/v1/endpoints/delivery_reports/test_get_batch_delivery_report_endpoint.py new file mode 100644 index 00000000..255845a7 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/delivery_reports/test_get_batch_delivery_report_endpoint.py @@ -0,0 +1,114 @@ +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.internal import GetBatchDeliveryReportEndpoint +from sinch.domains.sms.models.v1.internal import GetBatchDeliveryReportRequest +from sinch.domains.sms.models.v1.response import BatchDeliveryReport +from sinch.domains.sms.models.v1.shared import MessageDeliveryStatus + + +@pytest.fixture +def request_data(): + return GetBatchDeliveryReportRequest( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + type="summary", + status=["DELIVERED"], + code=[400], + client_reference="test_client_ref", + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "client_reference": "test_client_ref", + "statuses": [ + { + "code": 400, + "count": 5, + "recipients": ["+1234567890", "+0987654321"], + "status": "DELIVERED", + }, + { + "code": 401, + "count": 2, + "recipients": ["+5555555555"], + "status": "FAILED", + }, + ], + "total_message_count": 7, + "type": "summary", + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return GetBatchDeliveryReportEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_sms): + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/batches/01FC66621XXXXX119Z8PMV1QPQ/delivery_report" + ) + + +def test_build_query_params(endpoint): + query_params = endpoint.build_query_params() + expected_params = { + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "type": "summary", + "status": "DELIVERED", + "code": "400", + "client_reference": "test_client_ref", + } + assert query_params == expected_params + + +def test_build_query_params_with_multiple_status_and_code(): + """Test that multiple status and code values are converted to comma-separated strings""" + request_data = GetBatchDeliveryReportRequest( + batch_id="01W4FFL35P4NC4K35SMSBATCH1", + status=["DELIVERED", "FAILED", "QUEUED"], + code=[400, 401, 402], + ) + endpoint = GetBatchDeliveryReportEndpoint("test_project_id", request_data) + query_params = endpoint.build_query_params() + expected_params = { + "batch_id": "01W4FFL35P4NC4K35SMSBATCH1", + "status": "DELIVERED,FAILED,QUEUED", + "code": "400,401,402", + } + assert query_params == expected_params + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + parsed_response = endpoint.handle_response(mock_response) + assert isinstance(parsed_response, BatchDeliveryReport) + assert parsed_response.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed_response.client_reference == "test_client_ref" + assert parsed_response.total_message_count == 7 + assert parsed_response.type == "summary" + + assert len(parsed_response.statuses) == 2 + + first_status = parsed_response.statuses[0] + assert isinstance(first_status, MessageDeliveryStatus) + assert first_status.code == 400 + assert first_status.count == 5 + assert first_status.status == "DELIVERED" + assert first_status.recipients == ["+1234567890", "+0987654321"] + + second_status = parsed_response.statuses[1] + assert isinstance(second_status, MessageDeliveryStatus) + assert second_status.code == 401 + assert second_status.count == 2 + assert second_status.status == "FAILED" + assert second_status.recipients == ["+5555555555"] diff --git a/tests/unit/domains/sms/v1/endpoints/delivery_reports/test_get_recipient_delivery_report_endpoint.py b/tests/unit/domains/sms/v1/endpoints/delivery_reports/test_get_recipient_delivery_report_endpoint.py new file mode 100644 index 00000000..92544fa7 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/delivery_reports/test_get_recipient_delivery_report_endpoint.py @@ -0,0 +1,75 @@ +import pytest +from datetime import datetime, timezone +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.internal import ( + GetRecipientDeliveryReportEndpoint, +) +from sinch.domains.sms.models.v1.internal import ( + GetRecipientDeliveryReportRequest, +) +from sinch.domains.sms.models.v1.response import RecipientDeliveryReport + + +@pytest.fixture +def request_data(): + return GetRecipientDeliveryReportRequest( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", recipient_msisdn="+1234567890" + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "applied_originator": "+1234567890", + "at": "2025-01-15T10:30:45.123Z", + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "client_reference": "test_client_ref", + "code": 400, + "encoding": "GSM7", + "number_of_message_parts": 1, + "operator": "35000", + "operator_status_at": "2025-01-15T10:30:50.456Z", + "recipient": "+1234567890", + "status": "DELIVERED", + "type": "recipient_delivery_report_sms", + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return GetRecipientDeliveryReportEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_sms): + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/batches/01FC66621XXXXX119Z8PMV1QPQ/delivery_report/+1234567890" + ) + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + parsed_response = endpoint.handle_response(mock_response) + assert isinstance(parsed_response, RecipientDeliveryReport) + assert parsed_response.applied_originator == "+1234567890" + assert parsed_response.at == ( + datetime(2025, 1, 15, 10, 30, 45, 123000, tzinfo=timezone.utc) + ) + assert parsed_response.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed_response.client_reference == "test_client_ref" + assert parsed_response.code == 400 + assert parsed_response.encoding == "GSM7" + assert parsed_response.number_of_message_parts == 1 + assert parsed_response.operator == "35000" + assert parsed_response.operator_status_at == ( + datetime(2025, 1, 15, 10, 30, 50, 456000, tzinfo=timezone.utc) + ) + assert parsed_response.recipient == "+1234567890" + assert parsed_response.status == "DELIVERED" + assert parsed_response.type == "recipient_delivery_report_sms" diff --git a/tests/unit/domains/sms/v1/models/base/test_base_model_configuration.py b/tests/unit/domains/sms/v1/models/base/test_base_model_configuration.py new file mode 100644 index 00000000..2ead9c16 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/base/test_base_model_configuration.py @@ -0,0 +1,59 @@ +from sinch.core.models.utils import model_dump_for_query_params +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +def test_model_dump_for_query_params_expects_simple_fields(): + """ + Test that simple fields are returned as-is. + """ + + class TestModel(BaseModelConfigurationRequest): + batch_id: str + status: str = None + + model = TestModel( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", status="delivered" + ) + result = model_dump_for_query_params(model) + + assert result["batch_id"] == "01FC66621XXXXX119Z8PMV1QPQ" + assert result["status"] == "delivered" + + +def test_model_dump_for_query_params_expects_list_to_comma_separated_string(): + """ + Test that lists are converted to comma-separated strings. + """ + + class TestModel(BaseModelConfigurationRequest): + status: list[str] = None + code: list[int] = None + + model = TestModel(status=["Delivered", "Failed"], code=[15, 0]) + result = model_dump_for_query_params(model) + + assert result["status"] == "Delivered,Failed" + assert result["code"] == "15,0" + + +def test_model_dump_for_query_params_expects_empty_values_filtered(): + """ + Test that empty strings and empty lists are filtered out. + """ + + class TestModel(BaseModelConfigurationRequest): + batch_id: str + status: str = "" + code: list[int] = [] + + model = TestModel( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", status="", code=[] + ) + result = model_dump_for_query_params(model) + + assert "batch_id" in result + assert result["batch_id"] == "01FC66621XXXXX119Z8PMV1QPQ" + assert "status" not in result + assert "code" not in result diff --git a/tests/unit/domains/sms/v1/models/internal/test_batch_id_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_batch_id_request_model.py new file mode 100644 index 00000000..34a75ed4 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_batch_id_request_model.py @@ -0,0 +1,33 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.sms.models.v1.internal import BatchIdRequest + + +def test_batch_id_request_expects_valid_batch_id(): + """Test that the model correctly parses a valid batch_id.""" + batch_id = "01FC66621XXXXX119Z8PMV1QPQ" + request = BatchIdRequest(batch_id=batch_id) + + assert request.batch_id == batch_id + + +def test_batch_id_request_expects_batch_id_as_string(): + """Test that batch_id must be a string.""" + with pytest.raises(ValidationError): + BatchIdRequest(batch_id=12345) + + with pytest.raises(ValidationError): + BatchIdRequest(batch_id=None) + + +def test_batch_id_request_expects_model_dump(): + """Test that model_dump correctly serializes the request.""" + batch_id = "01W4FFL35P4NC4K35SMSBATCH1" + request = BatchIdRequest(batch_id=batch_id) + + dumped = request.model_dump(by_alias=True) + # batch_id field doesn't have an alias, so it stays as snake_case + assert dumped["batch_id"] == batch_id + + dumped_no_alias = request.model_dump(by_alias=False) + assert dumped_no_alias["batch_id"] == batch_id diff --git a/tests/unit/domains/sms/v1/models/internal/test_delivery_feedback_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_delivery_feedback_request_model.py new file mode 100644 index 00000000..0e782ca8 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_delivery_feedback_request_model.py @@ -0,0 +1,91 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.sms.models.v1.internal import DeliveryFeedbackRequest + + +@pytest.fixture +def sample_delivery_feedback_request_data(): + return { + "batch_id": "01W4FFL35P4NC4K35SMSBATCH3", + "recipients": ["+46876543210", "+46987654321"], + } + + +def test_delivery_feedback_request_expects_valid_inputs( + sample_delivery_feedback_request_data, +): + """Test DeliveryFeedbackRequest with valid inputs.""" + request = DeliveryFeedbackRequest(**sample_delivery_feedback_request_data) + assert ( + request.batch_id == sample_delivery_feedback_request_data["batch_id"] + ) + assert ( + request.recipients + == sample_delivery_feedback_request_data["recipients"] + ) + + +def test_delivery_feedback_request_expects_single_recipient(): + """Test DeliveryFeedbackRequest with a single recipient.""" + request = DeliveryFeedbackRequest( + batch_id="01W4FFL35P4NC4K35SMSBATCH3", + recipients=["+46876543210"], + ) + assert request.batch_id == "01W4FFL35P4NC4K35SMSBATCH3" + assert len(request.recipients) == 1 + assert request.recipients[0] == "+46876543210" + + +def test_delivery_feedback_request_expects_empty_recipients_list(): + """Test DeliveryFeedbackRequest with empty recipients list.""" + request = DeliveryFeedbackRequest( + batch_id="01W4FFL35P4NC4K35SMSBATCH3", + recipients=[], + ) + assert request.batch_id == "01W4FFL35P4NC4K35SMSBATCH3" + assert request.recipients == [] + + +@pytest.mark.parametrize( + "missing_field", + ["batch_id", "recipients"], +) +def test_delivery_feedback_request_expects_required_fields( + sample_delivery_feedback_request_data, missing_field +): + """Test that DeliveryFeedbackRequest requires batch_id and recipients fields.""" + data = sample_delivery_feedback_request_data.copy() + data.pop(missing_field) + with pytest.raises(ValidationError) as exc_info: + DeliveryFeedbackRequest(**data) + assert missing_field in str(exc_info.value) + + +@pytest.mark.parametrize( + "invalid_batch_id", + [12345, None], +) +def test_delivery_feedback_request_expects_batch_id_must_be_string( + invalid_batch_id, +): + """Test that batch_id must be a string.""" + with pytest.raises(ValidationError): + DeliveryFeedbackRequest( + batch_id=invalid_batch_id, + recipients=["+46876543210"], + ) + + +@pytest.mark.parametrize( + "invalid_recipients", + ["+46876543210", [123, 456], ["+46876543210", None]], +) +def test_delivery_feedback_request_expects_recipients_must_be_list_of_strings( + invalid_recipients, +): + """Test that recipients must be a list of strings.""" + with pytest.raises(ValidationError): + DeliveryFeedbackRequest( + batch_id="01W4FFL35P4NC4K35SMSBATCH3", + recipients=invalid_recipients, + ) diff --git a/tests/unit/domains/sms/v1/models/internal/test_dry_run_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_dry_run_request_model.py new file mode 100644 index 00000000..a3560f01 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_dry_run_request_model.py @@ -0,0 +1,356 @@ +import pytest +from pydantic import ValidationError +from datetime import datetime, timezone +from sinch.domains.sms.models.v1.internal.dry_run_request import ( + DryRunTextRequest, + DryRunBinaryRequest, + DryRunMediaRequest, + DryRunRequest, +) +from sinch.domains.sms.models.v1.shared import MediaBody + + +@pytest.fixture +def sample_text_request_data(): + return { + "to": ["+12017777777", "+12018888888"], + "from_": "+12015555555", + "body": "Hello World!", + } + + +@pytest.fixture +def sample_binary_request_data(): + return { + "to": ["+12017777777"], + "from_": "+12015555555", + "body": "SGVsbG8gV29ybGQh", + "udh": "06050423F423F4", + } + + +@pytest.fixture +def sample_media_request_data(): + return { + "to": ["+12017777777"], + "from_": "+12015555555", + "body": MediaBody( + url="https://capybara.com/image.jpg", + message="Check out this image!", + subject="Image", + ), + } + + +class TestDryRunMixin: + """Tests for DryRunMixin fields (per_recipient and number_of_recipients).""" + + def test_dry_run_mixin_expects_per_recipient_defaults_and_values( + self, sample_text_request_data + ): + """Test per_recipient defaults to False and can be set to True/False.""" + # Default value + request = DryRunTextRequest(**sample_text_request_data) + assert request.per_recipient is None + + request = DryRunTextRequest( + **sample_text_request_data, per_recipient=True + ) + assert request.per_recipient is True + + request = DryRunTextRequest( + **sample_text_request_data, per_recipient=False + ) + assert request.per_recipient is False + + def test_dry_run_mixin_expects_number_of_recipients_defaults_to_none( + self, sample_text_request_data + ): + """Test that number_of_recipients defaults to None.""" + request = DryRunTextRequest(**sample_text_request_data) + assert request.number_of_recipients is None + + @pytest.mark.parametrize( + "number_of_recipients", + [0, 100, 1000], + ) + def test_dry_run_mixin_expects_valid_number_of_recipients( + self, sample_text_request_data, number_of_recipients + ): + """Test that number_of_recipients accepts valid values (0-1000).""" + request = DryRunTextRequest( + **sample_text_request_data, + number_of_recipients=number_of_recipients, + ) + assert request.number_of_recipients == number_of_recipients + + def test_dry_run_mixin_expects_number_of_recipients_not_string( + self, sample_text_request_data + ): + """Test that number_of_recipients must be an integer.""" + with pytest.raises(ValidationError): + DryRunTextRequest( + **sample_text_request_data, number_of_recipients="100" + ) + + +class TestDryRunTextRequest: + """Tests for DryRunTextRequest model.""" + + def test_dry_run_text_request_expects_valid_inputs_and_all_fields( + self, sample_text_request_data + ): + """Test DryRunTextRequest with valid inputs and all optional fields.""" + request = DryRunTextRequest(**sample_text_request_data) + assert request.to == sample_text_request_data["to"] + assert request.from_ == sample_text_request_data["from_"] + assert request.body == sample_text_request_data["body"] + assert request.type == "mt_text" + assert request.per_recipient is None + assert request.number_of_recipients is None + + send_at = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + expire_at = datetime(2025, 1, 2, 12, 0, 0, tzinfo=timezone.utc) + + request = DryRunTextRequest( + **sample_text_request_data, + per_recipient=True, + number_of_recipients=50, + delivery_report="summary", + send_at=send_at, + expire_at=expire_at, + event_destination_target="https://capybara.com/callback", + client_reference="test-ref", + feedback_enabled=True, + flash_message=False, + max_number_of_message_parts=3, + truncate_concat=True, + from_ton=1, + from_npi=1, + ) + + assert request.per_recipient is True + assert request.number_of_recipients == 50 + assert request.delivery_report == "summary" + assert request.send_at == send_at + assert request.expire_at == expire_at + assert request.event_destination_target == "https://capybara.com/callback" + assert request.client_reference == "test-ref" + assert request.feedback_enabled is True + assert request.flash_message is False + assert request.max_number_of_message_parts == 3 + assert request.truncate_concat is True + assert request.from_ton == 1 + assert request.from_npi == 1 + + def test_dry_run_text_request_expects_required_fields_and_inheritance( + self, sample_text_request_data + ): + """Test required fields validation and inheritance from TextRequest.""" + with pytest.raises(ValidationError) as exc_info: + DryRunTextRequest() + assert "to" in str(exc_info.value) or "body" in str(exc_info.value) + + request = DryRunTextRequest(**sample_text_request_data) + # Verify TextRequest fields + assert hasattr(request, "to") + assert hasattr(request, "from_") + assert hasattr(request, "body") + assert hasattr(request, "type") + assert hasattr(request, "delivery_report") + assert hasattr(request, "send_at") + assert hasattr(request, "expire_at") + # Verify DryRunMixin fields + assert hasattr(request, "per_recipient") + assert hasattr(request, "number_of_recipients") + + +class TestDryRunBinaryRequest: + """Tests for DryRunBinaryRequest model.""" + + def test_dry_run_binary_request_expects_valid_inputs_and_all_fields( + self, sample_binary_request_data + ): + """Test DryRunBinaryRequest with valid inputs and all optional fields.""" + request = DryRunBinaryRequest(**sample_binary_request_data) + assert request.to == sample_binary_request_data["to"] + assert request.from_ == sample_binary_request_data["from_"] + assert request.body == sample_binary_request_data["body"] + assert request.udh == sample_binary_request_data["udh"] + assert request.type == "mt_binary" + assert request.per_recipient is None + assert request.number_of_recipients is None + + send_at = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + expire_at = datetime(2025, 1, 2, 12, 0, 0, tzinfo=timezone.utc) + + request = DryRunBinaryRequest( + **sample_binary_request_data, + per_recipient=True, + number_of_recipients=25, + delivery_report="full", + send_at=send_at, + expire_at=expire_at, + event_destination_target="https://capybara.com/callback", + client_reference="binary-ref", + feedback_enabled=False, + from_ton=0, + from_npi=1, + ) + + assert request.per_recipient is True + assert request.number_of_recipients == 25 + assert request.delivery_report == "full" + assert request.send_at == send_at + assert request.expire_at == expire_at + assert request.event_destination_target == "https://capybara.com/callback" + assert request.client_reference == "binary-ref" + assert request.feedback_enabled is False + assert request.from_ton == 0 + assert request.from_npi == 1 + + def test_dry_run_binary_request_expects_required_fields_and_inheritance( + self, sample_binary_request_data + ): + """Test required fields validation and inheritance from BinaryRequest.""" + with pytest.raises(ValidationError) as exc_info: + DryRunBinaryRequest() + error_str = str(exc_info.value) + assert "to" in error_str or "body" in error_str or "udh" in error_str + + request = DryRunBinaryRequest(**sample_binary_request_data) + assert hasattr(request, "to") + assert hasattr(request, "from_") + assert hasattr(request, "body") + assert hasattr(request, "udh") + assert hasattr(request, "type") + assert hasattr(request, "delivery_report") + + assert hasattr(request, "per_recipient") + assert hasattr(request, "number_of_recipients") + + +class TestDryRunMediaRequest: + """Tests for DryRunMediaRequest model.""" + + def test_dry_run_media_request_expects_valid_inputs_and_all_fields( + self, sample_media_request_data + ): + """Test DryRunMediaRequest with valid inputs and all optional fields.""" + request = DryRunMediaRequest(**sample_media_request_data) + assert request.to == sample_media_request_data["to"] + assert request.from_ == sample_media_request_data["from_"] + assert isinstance(request.body, MediaBody) + assert request.body.url == sample_media_request_data["body"].url + assert request.type == "mt_media" + assert request.per_recipient is None + assert request.number_of_recipients is None + + send_at = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + expire_at = datetime(2025, 1, 2, 12, 0, 0, tzinfo=timezone.utc) + + request = DryRunMediaRequest( + **sample_media_request_data, + per_recipient=True, + number_of_recipients=75, + delivery_report="summary", + send_at=send_at, + expire_at=expire_at, + event_destination_target="https://capybara.com/callback", + client_reference="media-ref", + feedback_enabled=True, + ) + + assert request.per_recipient is True + assert request.number_of_recipients == 75 + assert request.delivery_report == "summary" + assert request.send_at == send_at + assert request.expire_at == expire_at + assert request.event_destination_target == "https://capybara.com/callback" + assert request.client_reference == "media-ref" + assert request.feedback_enabled is True + + def test_dry_run_media_request_expects_required_fields_and_inheritance( + self, sample_media_request_data + ): + """Test required fields validation and inheritance from MediaRequest.""" + with pytest.raises(ValidationError) as exc_info: + DryRunMediaRequest() + assert "to" in str(exc_info.value) or "body" in str(exc_info.value) + + request = DryRunMediaRequest(**sample_media_request_data) + assert hasattr(request, "to") + assert hasattr(request, "from_") + assert hasattr(request, "body") + assert hasattr(request, "type") + assert hasattr(request, "delivery_report") + + assert hasattr(request, "per_recipient") + assert hasattr(request, "number_of_recipients") + + +class TestDryRunRequestUnion: + """Tests for DryRunRequest Union type.""" + + def test_dry_run_request_union_expects_accepts_text_request_object( + self, sample_text_request_data + ): + """Test that DryRunRequest Union accepts DryRunTextRequest object.""" + from pydantic import TypeAdapter + + text_request = DryRunTextRequest(**sample_text_request_data) + adapter = TypeAdapter(DryRunRequest) + validated = adapter.validate_python(text_request.model_dump()) + assert isinstance(validated, DryRunTextRequest) + + def test_dry_run_request_union_expects_accepts_binary_request_object( + self, sample_binary_request_data + ): + """Test that DryRunRequest Union accepts DryRunBinaryRequest object.""" + from pydantic import TypeAdapter + + binary_request = DryRunBinaryRequest(**sample_binary_request_data) + adapter = TypeAdapter(DryRunRequest) + validated = adapter.validate_python(binary_request.model_dump()) + assert isinstance(validated, DryRunBinaryRequest) + + def test_dry_run_request_union_expects_accepts_media_request_object( + self, sample_media_request_data + ): + """Test that DryRunRequest Union accepts DryRunMediaRequest object.""" + from pydantic import TypeAdapter + + media_request = DryRunMediaRequest(**sample_media_request_data) + adapter = TypeAdapter(DryRunRequest) + validated = adapter.validate_python(media_request.model_dump()) + assert isinstance(validated, DryRunMediaRequest) + + def test_dry_run_request_union_expects_accepts_dict_inputs( + self, + sample_text_request_data, + sample_binary_request_data, + sample_media_request_data, + ): + """Test that DryRunRequest Union accepts dict input for all types.""" + from pydantic import TypeAdapter + + adapter = TypeAdapter(DryRunRequest) + + validated = adapter.validate_python(sample_text_request_data) + assert isinstance(validated, DryRunTextRequest) + + validated = adapter.validate_python(sample_binary_request_data) + assert isinstance(validated, DryRunBinaryRequest) + + media_data = sample_media_request_data.copy() + media_data["body"] = media_data["body"].model_dump() + validated = adapter.validate_python(media_data) + assert isinstance(validated, DryRunMediaRequest) + + def test_dry_run_request_union_expects_rejects_invalid_dict(self): + """Test that DryRunRequest Union rejects invalid dict.""" + from pydantic import TypeAdapter, ValidationError + + adapter = TypeAdapter(DryRunRequest) + with pytest.raises(ValidationError): + adapter.validate_python({"invalid": "data"}) diff --git a/tests/unit/domains/sms/v1/models/internal/test_get_batch_delivery_report_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_get_batch_delivery_report_request_model.py new file mode 100644 index 00000000..1c6e3503 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_get_batch_delivery_report_request_model.py @@ -0,0 +1,104 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.sms.models.v1.internal import GetBatchDeliveryReportRequest + + +@pytest.mark.parametrize( + "batch_id, report_type, status, code, expected_report_type", + [ + ( + "batch123", + "summary", + ["Queued", "Dispatched"], + [400, 401], + "summary", + ), + ("batch456", "full", ["Dispatched", "Delivered"], [401, 400], "full"), + ("batch789", None, ["Failed", "Cancelled"], [402, 403], None), + ], +) +def test_get_batch_delivery_report_request_expects_valid_input( + batch_id, report_type, status, code, expected_report_type +): + """ + Test that the model correctly parses valid inputs. + """ + data = { + "batch_id": batch_id, + "type": report_type, + "status": status, + "code": code, + } + + # Remove None values + data = {k: v for k, v in data.items() if v is not None} + + request = GetBatchDeliveryReportRequest(**data) + + assert request.batch_id == batch_id + assert request.type == expected_report_type + assert request.status == status + assert request.code == code + + +def test_get_batch_delivery_report_request_expects_status_list(): + """ + Test that the model correctly handles status list input. + """ + data = { + "batch_id": "batch123", + "status": ["QUEUED", "DELIVERED", "FAILED"], + } + + request = GetBatchDeliveryReportRequest(**data) + + assert request.batch_id == "batch123" + assert request.status == ["QUEUED", "DELIVERED", "FAILED"] + assert request.type is None + assert request.code is None + + +def test_get_batch_delivery_report_request_expects_code_list(): + """ + Test that the model correctly handles code list input. + """ + data = {"batch_id": "batch123", "code": [400, 401, 402]} + + request = GetBatchDeliveryReportRequest(**data) + + assert request.batch_id == "batch123" + assert request.code == [400, 401, 402] + assert request.type is None + assert request.status is None + + +def test_get_batch_delivery_report_request_expects_validation_error_for_missing_batch_id(): + """ + Test that missing required batch_id field raises a ValidationError. + """ + data = {"type": "summary"} + + with pytest.raises(ValidationError) as exc_info: + GetBatchDeliveryReportRequest(**data) + + assert "batch_id" in str(exc_info.value) + + +def test_get_batch_delivery_report_request_expects_delivery_report_type_validation(): + """ + Test that the model correctly handles DeliveryReportType enum values. + """ + # Test with valid enum values + valid_types = ["summary", "full"] + + for report_type in valid_types: + data = {"batch_id": "batch123", "type": report_type} + + request = GetBatchDeliveryReportRequest(**data) + assert request.type == report_type + assert request.batch_id == "batch123" + + data = {"batch_id": "batch123", "type": "custom_type"} + + request = GetBatchDeliveryReportRequest(**data) + assert request.type == "custom_type" diff --git a/tests/unit/domains/sms/v1/models/internal/test_get_recipient_delivery_report_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_get_recipient_delivery_report_request_model.py new file mode 100644 index 00000000..2dc2b720 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_get_recipient_delivery_report_request_model.py @@ -0,0 +1,67 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.sms.models.v1.internal import ( + GetRecipientDeliveryReportRequest, +) + + +@pytest.mark.parametrize( + "batch_id, recipient_msisdn", + [ + ("01FC66621XXXXX119Z8PMV1QPQ", "+44231235674"), + ("batch123", "+15551234567"), + ("test-batch-456", "+1234567890"), + ], +) +def test_get_recipient_delivery_report_request_expects_valid_inputs( + batch_id, recipient_msisdn +): + """ + Test that the model correctly parses valid inputs. + """ + data = {"batch_id": batch_id, "recipient_msisdn": recipient_msisdn} + + request = GetRecipientDeliveryReportRequest(**data) + + assert request.batch_id == batch_id + assert request.recipient_msisdn == recipient_msisdn + + +def test_get_recipient_delivery_report_request_expects_validation_error_for_missing_batch_id(): + """ + Test that missing batch_id raises a ValidationError. + """ + data = {"recipient_msisdn": "+44231235674"} + + with pytest.raises(ValidationError) as exc_info: + GetRecipientDeliveryReportRequest(**data) + + assert "batch_id" in str(exc_info.value) + + +def test_get_recipient_delivery_report_request_expects_validation_error_for_missing_recipient_msisdn(): + """ + Test that missing recipient_msisdn raises a ValidationError. + """ + data = {"batch_id": "01FC66621XXXXX119Z8PMV1QPQ"} + + with pytest.raises(ValidationError) as exc_info: + GetRecipientDeliveryReportRequest(**data) + + assert "recipient_msisdn" in str(exc_info.value) + + +def test_get_recipient_delivery_report_request_with_additional_kwargs(): + """ + Test that additional kwargs are handled properly. + """ + data = { + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "recipient_msisdn": "+44231235674", + "extra_field": "extra_value", + } + + request = GetRecipientDeliveryReportRequest(**data) + + assert request.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert request.recipient_msisdn == "+44231235674" diff --git a/tests/unit/domains/sms/v1/models/internal/test_list_batches_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_list_batches_request_model.py new file mode 100644 index 00000000..b419c0b7 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_list_batches_request_model.py @@ -0,0 +1,75 @@ +from datetime import datetime, timezone +import pytest +from pydantic import ValidationError +from sinch.domains.sms.models.v1.internal import ListBatchesRequest + + +def test_list_batches_request_expects_defaults(): + """Test that the model correctly sets default values.""" + model = ListBatchesRequest() + assert model.page is None + assert model.page_size is None + assert model.start_date is None + assert model.end_date is None + assert model.from_ is None + assert model.client_reference is None + + +def test_list_batches_request_expects_parsed_input(): + """Test that the model correctly parses input with all parameters.""" + start = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + end = datetime(2025, 1, 8, 12, 0, 0, tzinfo=timezone.utc) + + model = ListBatchesRequest( + page=1, + page_size=50, + start_date=start, + end_date=end, + from_=["+46701234567", "+46709876543"], + client_reference="my-client-ref", + ) + + assert model.page == 1 + assert model.page_size == 50 + assert model.start_date == start + assert model.end_date == end + assert model.from_ == ["+46701234567", "+46709876543"] + assert model.client_reference == "my-client-ref" + + +def test_list_batches_request_expects_from_alias(): + """Test that the 'from' alias works correctly.""" + model = ListBatchesRequest(from_=["+46701234567"]) + + assert model.from_ == ["+46701234567"] + + # Check that model_dump with by_alias=True uses "from" + dumped = model.model_dump(exclude_none=True, by_alias=True) + assert "from" in dumped + assert dumped["from"] == ["+46701234567"] + assert "from_" not in dumped + + # Check that model_dump with by_alias=False uses "from_" + dumped_no_alias = model.model_dump(exclude_none=True, by_alias=False) + assert "from_" in dumped_no_alias + assert dumped_no_alias["from_"] == ["+46701234567"] + assert "from" not in dumped_no_alias + + +def test_list_batches_request_expects_partial_input(): + """Test that the model works with partial input.""" + model = ListBatchesRequest(page=2, page_size=20) + + assert model.page == 2 + assert model.page_size == 20 + assert model.start_date is None + assert model.end_date is None + assert model.from_ is None + assert model.client_reference is None + + +def test_list_batches_request_expects_empty_from_list(): + """Test that from_ can be an empty list.""" + model = ListBatchesRequest(from_=[]) + + assert model.from_ == [] diff --git a/tests/unit/domains/sms/v1/models/internal/test_list_delivery_reports_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_list_delivery_reports_request_model.py new file mode 100644 index 00000000..d6a19289 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_list_delivery_reports_request_model.py @@ -0,0 +1,40 @@ +from datetime import datetime, timezone +import pytest +from pydantic import ValidationError +from sinch.domains.sms.models.v1.internal import ListDeliveryReportsRequest + + +def test_list_delivery_reports_request_expects_defaults(): + """Test that the model correctly sets default values.""" + model = ListDeliveryReportsRequest() + assert model.page is None + assert model.page_size is None + assert model.start_date is None + assert model.end_date is None + assert model.status is None + assert model.code is None + assert model.client_reference is None + + +def test_list_delivery_reports_request_expects_parsed_input(): + """Test that the model correctly parses input with all parameters.""" + start = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + end = datetime(2025, 1, 8, 12, 0, 0, tzinfo=timezone.utc) + + model = ListDeliveryReportsRequest( + page=1, + page_size=50, + start_date=start, + end_date=end, + status=["DELIVERED", "FAILED"], + code=[401, 402], + client_reference="my-client-ref", + ) + + assert model.page == 1 + assert model.page_size == 50 + assert model.start_date == start + assert model.end_date == end + assert model.status == ["DELIVERED", "FAILED"] + assert model.code == [401, 402] + assert model.client_reference == "my-client-ref" diff --git a/tests/unit/domains/sms/v1/models/internal/test_list_delivery_reports_response_model.py b/tests/unit/domains/sms/v1/models/internal/test_list_delivery_reports_response_model.py new file mode 100644 index 00000000..bdea44e5 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_list_delivery_reports_response_model.py @@ -0,0 +1,113 @@ +from datetime import datetime, timezone +import pytest +from sinch.domains.sms.models.v1.internal import ListDeliveryReportsResponse + + +@pytest.fixture +def test_data(): + return { + "count": 2, + "page": 0, + "page_size": 2, + "delivery_reports": [ + { + "at": "2025-01-19T16:45:31.935Z", + "batch_id": "01K7YNS82JMYGAKAATHFP0QTB5", + "code": 401, + "operator_status_at": "2025-01-19T16:45:00Z", + "recipient": "34683607594", + "status": "Delivered", + "type": "recipient_delivery_report_sms", + }, + { + "at": "2025-01-19T16:40:26.855Z", + "batch_id": "01K7YNFY30DS2KKVQZVBFANHMR", + "code": 402, + "operator_status_at": "2025-01-19T16:40:00Z", + "recipient": "34683607595", + "status": "Dispatched", + "type": "recipient_delivery_report_sms", + }, + ], + } + + +def assert_delivery_report_fields( + delivery_report, + expected_batch_id, + expected_recipient, + expected_code, + expected_status, +): + """Helper function to assert delivery report fields.""" + assert delivery_report.batch_id == expected_batch_id + assert delivery_report.recipient == expected_recipient + assert delivery_report.code == expected_code + assert delivery_report.status == expected_status + assert delivery_report.type == "recipient_delivery_report_sms" + + +def test_list_delivery_reports_response_empty_content_expects_empty_list(): + """Test that empty delivery reports list returns empty content.""" + model = ListDeliveryReportsResponse( + count=0, page=0, page_size=30, delivery_reports=None + ) + assert model.count == 0 + assert model.page == 0 + assert model.page_size == 30 + assert model.content == [] + + +def test_list_delivery_reports_response_expects_correct_mapping(test_data): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + response = ListDeliveryReportsResponse(**test_data) + assert hasattr(response, "content") + assert response.content == response.delivery_reports + + # Test top-level fields + assert response.count == 2 + assert response.page == 0 + assert response.page_size == 2 + + # Test content property + content = response.content + assert isinstance(content, list) + assert len(content) == 2 + + # Test first delivery report + first_report = content[0] + expected_first_at = datetime( + 2025, 1, 19, 16, 45, 31, 935000, tzinfo=timezone.utc + ) + expected_first_operator_at = datetime( + 2025, 1, 19, 16, 45, 0, tzinfo=timezone.utc + ) + assert first_report.at == expected_first_at + assert first_report.operator_status_at == expected_first_operator_at + assert_delivery_report_fields( + first_report, + "01K7YNS82JMYGAKAATHFP0QTB5", + "34683607594", + 401, + "Delivered", + ) + + # Test second delivery report + second_report = content[1] + expected_second_at = datetime( + 2025, 1, 19, 16, 40, 26, 855000, tzinfo=timezone.utc + ) + expected_second_operator_at = datetime( + 2025, 1, 19, 16, 40, 0, tzinfo=timezone.utc + ) + assert second_report.at == expected_second_at + assert second_report.operator_status_at == expected_second_operator_at + assert_delivery_report_fields( + second_report, + "01K7YNFY30DS2KKVQZVBFANHMR", + "34683607595", + 402, + "Dispatched", + ) diff --git a/tests/unit/domains/sms/v1/models/internal/test_replace_binary_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_replace_binary_request_model.py new file mode 100644 index 00000000..10eded9c --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_replace_binary_request_model.py @@ -0,0 +1,71 @@ +import pytest +from pydantic import ValidationError +from datetime import datetime, timezone +from sinch.domains.sms.models.v1.internal.replace_batch_request import ( + ReplaceBinaryRequest, +) + + +@pytest.fixture +def sample_replace_binary_request_data(): + return { + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": ["+46701234567", "+46709876543"], + "from_": "+46701111111", + "body": "SGVsbG8gV29ybGQh", + "udh": "06050423F423F4", + } + + +def test_replace_binary_request_expects_valid_inputs_and_all_fields( + sample_replace_binary_request_data, +): + """Test ReplaceBinaryRequest with valid inputs and all optional fields.""" + request = ReplaceBinaryRequest(**sample_replace_binary_request_data) + assert request.batch_id == sample_replace_binary_request_data["batch_id"] + assert request.to == sample_replace_binary_request_data["to"] + assert request.from_ == sample_replace_binary_request_data["from_"] + assert request.body == sample_replace_binary_request_data["body"] + assert request.udh == sample_replace_binary_request_data["udh"] + assert request.type == "mt_binary" + assert request.delivery_report is None + assert request.feedback_enabled is None + + send_at = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + expire_at = datetime(2025, 1, 2, 12, 0, 0, tzinfo=timezone.utc) + + request = ReplaceBinaryRequest( + **sample_replace_binary_request_data, + delivery_report="summary", + send_at=send_at, + expire_at=expire_at, + event_destination_target="https://capybara.com/callback", + client_reference="test-ref", + feedback_enabled=True, + from_ton=1, + from_npi=1, + ) + + assert request.delivery_report == "summary" + assert request.send_at == send_at + assert request.expire_at == expire_at + assert request.event_destination_target == "https://capybara.com/callback" + assert request.client_reference == "test-ref" + assert request.feedback_enabled is True + assert request.from_ton == 1 + assert request.from_npi == 1 + + +@pytest.mark.parametrize( + "missing_field", + ["batch_id", "to", "body", "udh"], +) +def test_replace_binary_request_expects_required_fields( + sample_replace_binary_request_data, missing_field +): + """Test that ReplaceBinaryRequest requires batch_id, to, body, and udh fields.""" + data = sample_replace_binary_request_data.copy() + data.pop(missing_field) + with pytest.raises(ValidationError) as exc_info: + ReplaceBinaryRequest(**data) + assert missing_field in str(exc_info.value) diff --git a/tests/unit/domains/sms/v1/models/internal/test_replace_media_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_replace_media_request_model.py new file mode 100644 index 00000000..6b8b30e7 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_replace_media_request_model.py @@ -0,0 +1,104 @@ +import pytest +from pydantic import ValidationError +from datetime import datetime, timezone +from sinch.domains.sms.models.v1.internal.replace_batch_request import ( + ReplaceMediaRequest, +) +from sinch.domains.sms.models.v1.shared import MediaBody + + +@pytest.fixture +def sample_replace_media_request_data(): + return { + "batch_id": "01W4FFL35P4NC4K35SMSBATCH2", + "to": ["+46876543210", "+46987654321"], + "from_": "+46800123456", + "body": MediaBody( + url="https://capybara.com/video.mp4", + message="Hi ${name}! Watch this amazing capybara video!", + subject="Capybara Video", + ), + } + + +def test_replace_media_request_expects_valid_inputs_and_all_fields( + sample_replace_media_request_data, +): + """Test ReplaceMediaRequest with valid inputs and all optional fields.""" + request = ReplaceMediaRequest(**sample_replace_media_request_data) + assert request.batch_id == sample_replace_media_request_data["batch_id"] + assert request.to == sample_replace_media_request_data["to"] + assert request.from_ == sample_replace_media_request_data["from_"] + assert isinstance(request.body, MediaBody) + assert request.body.url == "https://capybara.com/video.mp4" + assert ( + request.body.message + == "Hi ${name}! Watch this amazing capybara video!" + ) + assert request.body.subject == "Capybara Video" + assert request.type == "mt_media" + assert request.delivery_report is None + assert request.feedback_enabled is None + assert request.strict_validation is None + + send_at = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + expire_at = datetime(2025, 1, 2, 12, 0, 0, tzinfo=timezone.utc) + + request = ReplaceMediaRequest( + **sample_replace_media_request_data, + delivery_report="full", + send_at=send_at, + expire_at=expire_at, + event_destination_target="https://capybara.com/webhook", + client_reference="capybara-media-batch-123", + feedback_enabled=True, + strict_validation=True, + parameters={ + "name": {"+46876543210": "Alice", "default": "user"}, + "promo": {"+46876543210": "SUMMER2024"}, + }, + ) + + assert request.delivery_report == "full" + assert request.send_at == send_at + assert request.expire_at == expire_at + assert request.event_destination_target == "https://capybara.com/webhook" + assert request.client_reference == "capybara-media-batch-123" + assert request.feedback_enabled is True + assert request.strict_validation is True + assert request.parameters["name"]["+46876543210"] == "Alice" + assert request.parameters["promo"]["+46876543210"] == "SUMMER2024" + + +@pytest.mark.parametrize( + "missing_field", + ["batch_id", "to", "body"], +) +def test_replace_media_request_expects_required_fields( + sample_replace_media_request_data, missing_field +): + """Test that ReplaceMediaRequest requires batch_id, to, and body fields.""" + data = sample_replace_media_request_data.copy() + data.pop(missing_field) + with pytest.raises(ValidationError) as exc_info: + ReplaceMediaRequest(**data) + assert missing_field in str(exc_info.value) + + +def test_replace_media_request_expects_body_must_be_media_body( + sample_replace_media_request_data, +): + """Test that body must be a MediaBody instance or dict that can be converted.""" + data = sample_replace_media_request_data.copy() + data["body"] = "invalid" + with pytest.raises(ValidationError): + ReplaceMediaRequest(**data) + + data["body"] = None + with pytest.raises(ValidationError): + ReplaceMediaRequest(**data) + + data["body"] = {"url": "https://capybara.com/audio.mp3"} + request = ReplaceMediaRequest(**data) + assert isinstance(request.body, MediaBody) + assert request.body.url == "https://capybara.com/audio.mp3" diff --git a/tests/unit/domains/sms/v1/models/internal/test_update_binary_request_with_batch_id_model.py b/tests/unit/domains/sms/v1/models/internal/test_update_binary_request_with_batch_id_model.py new file mode 100644 index 00000000..90d95c38 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_update_binary_request_with_batch_id_model.py @@ -0,0 +1,91 @@ +import pytest +from datetime import datetime, timezone +from sinch.domains.sms.models.v1.internal.update_batch_message_request import ( + UpdateBinaryRequestWithBatchId, +) + + +@pytest.fixture +def sample_update_binary_request_data(): + return { + "batch_id": "01FC88843ZZZZZ331B0ROX3STQ", + "udh": "06050423F423F5", + "body": "VXBkYXRlZCBiaW5hcnkgY29udGVudA==", + } + + +def test_update_binary_request_expects_valid_inputs_and_all_fields( + sample_update_binary_request_data, +): + """Test UpdateBinaryRequestWithBatchId with valid inputs and all optional fields.""" + request = UpdateBinaryRequestWithBatchId( + **sample_update_binary_request_data + ) + assert request.batch_id == sample_update_binary_request_data["batch_id"] + assert request.udh == sample_update_binary_request_data["udh"] + assert request.body == sample_update_binary_request_data["body"] + assert request.type == "mt_binary" + + send_at = datetime(2025, 4, 10, 16, 45, 0, tzinfo=timezone.utc) + expire_at = datetime(2025, 4, 13, 16, 45, 0, tzinfo=timezone.utc) + + request = UpdateBinaryRequestWithBatchId( + **sample_update_binary_request_data, + from_="+46706666666", + to_add=["+46707777777", "+46708888888"], + to_remove=["+46709999999"], + delivery_report="full", + send_at=send_at, + expire_at=expire_at, + event_destination_target="https://capybara.com/binary-callback", + client_reference="binary-update-456", + feedback_enabled=True, + from_ton=3, + from_npi=4, + ) + + assert request.batch_id == sample_update_binary_request_data["batch_id"] + assert request.udh == sample_update_binary_request_data["udh"] + assert request.from_ == "+46706666666" + assert request.to_add == ["+46707777777", "+46708888888"] + assert request.to_remove == ["+46709999999"] + assert request.delivery_report == "full" + assert request.send_at == send_at + assert request.expire_at == expire_at + assert request.event_destination_target == "https://capybara.com/binary-callback" + assert request.client_reference == "binary-update-456" + assert request.feedback_enabled is True + assert request.from_ton == 3 + assert request.from_npi == 4 + + +def test_update_binary_request_expects_datetime_parsing( + sample_update_binary_request_data, +): + """Test datetime parsing for send_at and expire_at.""" + send_at_str = "2025-05-25T08:20:45.456Z" + expire_at_str = "2025-05-28T08:20:45.456Z" + + request = UpdateBinaryRequestWithBatchId( + **sample_update_binary_request_data, + send_at=send_at_str, + expire_at=expire_at_str, + ) + + assert isinstance(request.send_at, datetime) + assert isinstance(request.expire_at, datetime) + + +def test_update_binary_request_expects_minimal_input( + sample_update_binary_request_data, +): + """Test UpdateBinaryRequestWithBatchId with only required fields.""" + request = UpdateBinaryRequestWithBatchId( + batch_id=sample_update_binary_request_data["batch_id"], + udh=sample_update_binary_request_data["udh"], + ) + assert request.batch_id == sample_update_binary_request_data["batch_id"] + assert request.udh == sample_update_binary_request_data["udh"] + assert request.body is None + assert request.type == "mt_binary" + assert request.feedback_enabled is None diff --git a/tests/unit/domains/sms/v1/models/internal/test_update_media_request_with_batch_id_model.py b/tests/unit/domains/sms/v1/models/internal/test_update_media_request_with_batch_id_model.py new file mode 100644 index 00000000..06dda0de --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_update_media_request_with_batch_id_model.py @@ -0,0 +1,102 @@ +import pytest +from datetime import datetime, timezone +from sinch.domains.sms.models.v1.internal.update_batch_message_request import ( + UpdateMediaRequestWithBatchId, +) +from sinch.domains.sms.models.v1.shared import MediaBody + + +@pytest.fixture +def sample_update_media_request_data(): + return { + "batch_id": "01FC99954AAAAA442C1SPY4TUQ", + "body": MediaBody( + url="https://capybara.com/media/video.mp4", + message="Updated capybara video message", + subject="Capybara Video Update", + ), + } + + +def test_update_media_request_expects_valid_inputs_and_all_fields( + sample_update_media_request_data, +): + """Test UpdateMediaRequestWithBatchId with valid inputs and all optional fields.""" + request = UpdateMediaRequestWithBatchId(**sample_update_media_request_data) + assert request.batch_id == sample_update_media_request_data["batch_id"] + assert request.body.url == sample_update_media_request_data["body"].url + assert ( + request.body.message + == sample_update_media_request_data["body"].message + ) + assert ( + request.body.subject + == sample_update_media_request_data["body"].subject + ) + assert request.type == "mt_media" + + send_at = datetime(2025, 6, 5, 18, 0, 0, tzinfo=timezone.utc) + expire_at = datetime(2025, 6, 8, 18, 0, 0, tzinfo=timezone.utc) + + request = UpdateMediaRequestWithBatchId( + **sample_update_media_request_data, + from_="+46701010101", + to_add=["+46701111111", "+46701222222"], + to_remove=["+46701333333"], + delivery_report="none", + send_at=send_at, + expire_at=expire_at, + event_destination_target="https://capybara.com/media-callback", + client_reference="media-update-789", + feedback_enabled=True, + strict_validation=True, + parameters={ + "media": {"+46701111111": "video1", "default": "video2"}, + }, + ) + + assert request.batch_id == sample_update_media_request_data["batch_id"] + assert request.from_ == "+46701010101" + assert request.to_add == ["+46701111111", "+46701222222"] + assert request.to_remove == ["+46701333333"] + assert request.delivery_report == "none" + assert request.send_at == send_at + assert request.expire_at == expire_at + assert request.event_destination_target == "https://capybara.com/media-callback" + assert request.client_reference == "media-update-789" + assert request.feedback_enabled is True + assert request.strict_validation is True + assert request.parameters == { + "media": {"+46701111111": "video1", "default": "video2"}, + } + + +def test_update_media_request_expects_datetime_parsing( + sample_update_media_request_data, +): + """Test datetime parsing for send_at and expire_at.""" + send_at_str = "2025-07-15T12:30:15.789Z" + expire_at_str = "2025-07-18T12:30:15.789Z" + + request = UpdateMediaRequestWithBatchId( + **sample_update_media_request_data, + send_at=send_at_str, + expire_at=expire_at_str, + ) + + assert isinstance(request.send_at, datetime) + assert isinstance(request.expire_at, datetime) + + +def test_update_media_request_expects_minimal_input( + sample_update_media_request_data, +): + """Test UpdateMediaRequestWithBatchId with only required fields.""" + request = UpdateMediaRequestWithBatchId( + batch_id=sample_update_media_request_data["batch_id"], + ) + assert request.batch_id == sample_update_media_request_data["batch_id"] + assert request.body is None + assert request.type == "mt_media" + assert request.feedback_enabled is None + assert request.strict_validation is None diff --git a/tests/unit/domains/sms/v1/models/internal/test_update_text_request_with_batch_id_model.py b/tests/unit/domains/sms/v1/models/internal/test_update_text_request_with_batch_id_model.py new file mode 100644 index 00000000..23df2edb --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_update_text_request_with_batch_id_model.py @@ -0,0 +1,113 @@ +import pytest +from pydantic import ValidationError +from datetime import datetime, timezone +from sinch.domains.sms.models.v1.internal.update_batch_message_request import ( + UpdateTextRequestWithBatchId, +) + + +@pytest.fixture +def sample_update_text_request_data(): + return { + "batch_id": "01FC77732YYYYY220A9QNW2RSQ", + "body": "Updated message content here", + "from_": "+46702222222", + } + + +def test_update_text_request_expects_valid_inputs_and_all_fields( + sample_update_text_request_data, +): + """Test UpdateTextRequestWithBatchId with valid inputs and all optional fields.""" + request = UpdateTextRequestWithBatchId(**sample_update_text_request_data) + assert request.batch_id == sample_update_text_request_data["batch_id"] + assert request.body == sample_update_text_request_data["body"] + assert request.from_ == sample_update_text_request_data["from_"] + assert request.type == "mt_text" + + send_at = datetime(2025, 2, 15, 14, 30, 0, tzinfo=timezone.utc) + expire_at = datetime(2025, 2, 18, 14, 30, 0, tzinfo=timezone.utc) + + request = UpdateTextRequestWithBatchId( + **sample_update_text_request_data, + to_add=["+46703333333", "+46704444444"], + to_remove=["+46705555555"], + delivery_report="summary", + send_at=send_at, + expire_at=expire_at, + event_destination_target="https://capybara.com/webhook", + client_reference="update-ref-123", + feedback_enabled=True, + flash_message=True, + max_number_of_message_parts=5, + truncate_concat=False, + from_ton=2, + from_npi=3, + parameters={ + "code": {"+46703333333": "ABC123", "default": "XYZ789"}, + }, + ) + + assert request.batch_id == sample_update_text_request_data["batch_id"] + assert request.to_add == ["+46703333333", "+46704444444"] + assert request.to_remove == ["+46705555555"] + assert request.delivery_report == "summary" + assert request.send_at == send_at + assert request.expire_at == expire_at + assert request.event_destination_target == "https://capybara.com/webhook" + assert request.client_reference == "update-ref-123" + assert request.feedback_enabled is True + assert request.flash_message is True + assert request.max_number_of_message_parts == 5 + assert request.truncate_concat is False + assert request.from_ton == 2 + assert request.from_npi == 3 + assert request.parameters == { + "code": {"+46703333333": "ABC123", "default": "XYZ789"}, + } + + +@pytest.mark.parametrize( + "to_add_value", + ["+46701234567", [123, 456], ["+46701234567", None]], +) +def test_update_text_request_expects_to_add_must_be_list_of_strings( + sample_update_text_request_data, to_add_value +): + """Test that to_add must be a list of strings.""" + with pytest.raises(ValidationError): + UpdateTextRequestWithBatchId( + **sample_update_text_request_data, to_add=to_add_value + ) + + +def test_update_text_request_expects_datetime_parsing( + sample_update_text_request_data, +): + """Test datetime parsing for send_at and expire_at.""" + send_at_str = "2025-03-20T10:15:30.123Z" + expire_at_str = "2025-03-23T10:15:30.123Z" + + request = UpdateTextRequestWithBatchId( + **sample_update_text_request_data, + send_at=send_at_str, + expire_at=expire_at_str, + ) + + assert isinstance(request.send_at, datetime) + assert isinstance(request.expire_at, datetime) + + +def test_update_text_request_expects_minimal_input( + sample_update_text_request_data, +): + """Test UpdateTextRequestWithBatchId with only required fields.""" + request = UpdateTextRequestWithBatchId( + batch_id=sample_update_text_request_data["batch_id"] + ) + assert request.batch_id == sample_update_text_request_data["batch_id"] + assert request.body is None + assert request.from_ is None + assert request.type == "mt_text" + assert request.feedback_enabled is None + assert request.flash_message is None diff --git a/tests/unit/domains/sms/v1/models/response/test_batch_delivery_report_model.py b/tests/unit/domains/sms/v1/models/response/test_batch_delivery_report_model.py new file mode 100644 index 00000000..242c4d0a --- /dev/null +++ b/tests/unit/domains/sms/v1/models/response/test_batch_delivery_report_model.py @@ -0,0 +1,224 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.sms.models.v1.response.batch_delivery_report import ( + BatchDeliveryReport, +) + + +@pytest.fixture +def sample_message_delivery_status(): + """ + Sample MessageDeliveryStatus for testing. + """ + return { + "code": 401, + "count": 1, + "recipients": ["+1234567890"], + "status": "Dispatched", + } + + +@pytest.fixture +def sample_batch_delivery_report_data(sample_message_delivery_status): + """ + Sample BatchDeliveryReport data for testing. + """ + return { + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "client_reference": "my_client_reference", + "statuses": [sample_message_delivery_status], + "total_message_count": 1, + "type": "delivery_report_sms", + } + + +def test_batch_delivery_report_expects_valid_input( + sample_batch_delivery_report_data, +): + """ + Test that the model correctly parses valid input. + """ + report = BatchDeliveryReport(**sample_batch_delivery_report_data) + + assert report.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert report.client_reference == "my_client_reference" + assert report.total_message_count == 1 + assert report.type == "delivery_report_sms" + assert len(report.statuses) == 1 + + status = report.statuses[0] + assert status.code == 401 + assert status.count == 1 + assert status.recipients == ["+1234567890"] + assert status.status == "Dispatched" + + +def test_batch_delivery_report_expects_without_client_reference(): + """ + Test that the model works without optional client_reference. + """ + data = { + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "statuses": [ + { + "code": 401, + "count": 1, + "recipients": ["+44231235674"], + "status": "Dispatched", + } + ], + "total_message_count": 1, + "type": "delivery_report_sms", + } + + report = BatchDeliveryReport(**data) + + assert report.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert report.client_reference is None + assert report.total_message_count == 1 + assert report.type == "delivery_report_sms" + + +def test_batch_delivery_report_expects_with_multiple_statuses(): + """ + Test that the model works with multiple statuses. + """ + data = { + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "statuses": [ + { + "code": 401, + "count": 1, + "recipients": ["+44231235674"], + "status": "Dispatched", + }, + { + "code": 0, + "count": 1, + "recipients": ["+44231235675"], + "status": "Delivered", + }, + ], + "total_message_count": 2, + "type": "delivery_report_sms", + } + + report = BatchDeliveryReport(**data) + + assert report.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert report.total_message_count == 2 + assert len(report.statuses) == 2 + + # Check first status + assert report.statuses[0].code == 401 + assert report.statuses[0].status == "Dispatched" + + # Check second status + assert report.statuses[1].code == 0 + assert report.statuses[1].status == "Delivered" + + +def test_batch_delivery_report_expects_no_recipients(): + """ + Test that the model works when recipients are not provided. + """ + data = { + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "statuses": [{"code": 401, "count": 1, "status": "Dispatched"}], + "total_message_count": 1, + "type": "delivery_report_sms", + } + + report = BatchDeliveryReport(**data) + + assert report.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert report.total_message_count == 1 + assert len(report.statuses) == 1 + + status = report.statuses[0] + assert status.code == 401 + assert status.count == 1 + assert status.recipients is None + assert status.status == "Dispatched" + + +def test_batch_delivery_report_expects_validation_error_for_missing_batch_id(): + """ + Test that missing required batch_id field raises a ValidationError. + """ + data = { + "statuses": [{"code": 401, "count": 1, "status": "Dispatched"}], + "total_message_count": 1, + "type": "delivery_report_sms", + } + + with pytest.raises(ValidationError) as exc_info: + BatchDeliveryReport(**data) + + assert "batch_id" in str(exc_info.value) + + +def test_batch_delivery_report_expects_validation_error_for_missing_statuses(): + """ + Test that missing required statuses field raises a ValidationError. + """ + data = { + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "total_message_count": 1, + "type": "delivery_report_sms", + } + + with pytest.raises(ValidationError) as exc_info: + BatchDeliveryReport(**data) + + assert "statuses" in str(exc_info.value) + + +def test_batch_delivery_report_expects_validation_error_for_missing_total_message_count(): + """ + Test that missing required total_message_count field raises a ValidationError. + """ + data = { + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "statuses": [{"code": 401, "count": 1, "status": "Dispatched"}], + "type": "delivery_report_sms", + } + + with pytest.raises(ValidationError) as exc_info: + BatchDeliveryReport(**data) + + assert "total_message_count" in str(exc_info.value) + + +def test_batch_delivery_report_expects_validation_error_for_missing_type(): + """ + Test that missing required type field raises a ValidationError. + """ + data = { + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "statuses": [{"code": 401, "count": 1, "status": "Dispatched"}], + "total_message_count": 1, + } + + with pytest.raises(ValidationError) as exc_info: + BatchDeliveryReport(**data) + + assert "type" in str(exc_info.value) + + +def test_batch_delivery_report_expects_empty_statuses(): + """ + Test that empty statuses list is allowed. + """ + data = { + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "statuses": [], + "total_message_count": 1, + "type": "delivery_report_sms", + } + + report = BatchDeliveryReport(**data) + assert report.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert report.statuses == [] + assert report.total_message_count == 1 + assert report.type == "delivery_report_sms" diff --git a/tests/unit/domains/sms/v1/models/response/test_batch_response_model.py b/tests/unit/domains/sms/v1/models/response/test_batch_response_model.py new file mode 100644 index 00000000..7f188c46 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/response/test_batch_response_model.py @@ -0,0 +1,175 @@ +from datetime import datetime, timezone +import pytest +from pydantic import ValidationError, TypeAdapter +from sinch.domains.sms.models.v1.types import BatchResponse +from sinch.domains.sms.models.v1.shared.text_response import TextResponse +from sinch.domains.sms.models.v1.shared.binary_response import BinaryResponse +from sinch.domains.sms.models.v1.shared.media_response import MediaResponse + + +@pytest.fixture +def text_response_data(): + """Sample TextResponse data for testing.""" + return { + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": ["12017777777"], + "from": "12015555555", + "canceled": False, + "body": "Hello World!", + "type": "mt_text", + "created_at": "2024-01-15T14:30:22.123Z", + "modified_at": "2024-01-15T14:35:45.789Z", + "delivery_report": "full", + "send_at": "2024-01-15T15:00:00Z", + "expire_at": "2024-01-18T15:00:00Z", + "feedback_enabled": True, + "flash_message": False, + } + + +@pytest.fixture +def binary_response_data(): + """Sample BinaryResponse data for testing.""" + return { + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": ["12017777777"], + "from": "12015555555", + "canceled": False, + "body": "SGVsbG8gV29ybGQh", + "udh": "06050423F423F4", + "type": "mt_binary", + "created_at": "2024-03-20T08:15:33.456Z", + "modified_at": "2024-03-20T08:16:12.890Z", + } + + +@pytest.fixture +def media_response_data(): + """Sample MediaResponse data for testing.""" + return { + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": ["12017777777"], + "from": "12015555555", + "canceled": False, + "body": { + "url": "https://example.com/image.jpg", + "message": "Check out this image!", + }, + "type": "mt_media", + "created_at": "2024-11-10T16:45:10.234Z", + "modified_at": "2024-11-10T16:47:22.567Z", + } + + +def test_batch_response_expects_parses_all_response_types( + text_response_data, binary_response_data, media_response_data +): + """ + Test that BatchResponse correctly parses all three response types. + Verifies discriminator routes correctly based on type field. + """ + adapter = TypeAdapter(BatchResponse) + + text_response = adapter.validate_python(text_response_data) + assert isinstance(text_response, TextResponse) + assert text_response.type == "mt_text" + assert text_response.body == "Hello World!" + assert text_response.delivery_report == "full" + + binary_response = adapter.validate_python(binary_response_data) + assert isinstance(binary_response, BinaryResponse) + assert not isinstance(binary_response, TextResponse) + assert binary_response.type == "mt_binary" + assert binary_response.udh == "06050423F423F4" + + media_response = adapter.validate_python(media_response_data) + assert isinstance(media_response, MediaResponse) + assert media_response.type == "mt_media" + assert media_response.body.url == "https://example.com/image.jpg" + + +def test_batch_response_expects_text_response_variations(text_response_data): + """ + Test TextResponse variations: minimal fields, parameters, and datetime parsing. + """ + adapter = TypeAdapter(BatchResponse) + + minimal_data = {"type": "mt_text", "id": "01FC66621XXXXX119Z8PMV1QPQ"} + response = adapter.validate_python(minimal_data) + assert isinstance(response, TextResponse) + assert response.type == "mt_text" + assert response.canceled is None + + text_response_data["parameters"] = { + "name": {"+12017777777": "John", "default": "there"}, + "code": {"+12017777777": "HALLOWEEN20"}, + } + response = adapter.validate_python(text_response_data) + assert isinstance(response, TextResponse) + assert response.parameters["name"]["+12017777777"] == "John" + assert response.parameters["code"]["+12017777777"] == "HALLOWEEN20" + + expected_created_at = datetime( + 2024, 1, 15, 14, 30, 22, 123000, tzinfo=timezone.utc + ) + expected_modified_at = datetime( + 2024, 1, 15, 14, 35, 45, 789000, tzinfo=timezone.utc + ) + expected_send_at = datetime(2024, 1, 15, 15, 0, 0, tzinfo=timezone.utc) + expected_expire_at = datetime(2024, 1, 18, 15, 0, 0, tzinfo=timezone.utc) + assert response.created_at == expected_created_at + assert response.modified_at == expected_modified_at + assert response.send_at == expected_send_at + assert response.expire_at == expected_expire_at + + text_response_data["canceled"] = True + response = adapter.validate_python(text_response_data) + assert response.canceled is True + + +def test_batch_response_expects_discriminator_behavior(): + """ + Test discriminator behavior: routing by type field, handling invalid/missing types, + and working with extra fields. + """ + adapter = TypeAdapter(BatchResponse) + + # Discriminator routes by type field, not field presence + data_with_binary_fields_but_text_type = { + "type": "mt_text", + "id": "test123", + "body": "SGVsbG8gV29ybGQh", + "udh": "06050423F423F4", + } + response = adapter.validate_python(data_with_binary_fields_but_text_type) + assert isinstance(response, TextResponse) + assert response.type == "mt_text" + assert hasattr(response, "udh") + + # Invalid type should be rejected + with pytest.raises(ValidationError): + adapter.validate_python({"id": "test", "type": "invalid_type"}) + + # Missing type field should cause validation error + with pytest.raises(ValidationError): + adapter.validate_python({"id": "test", "body": "Hello"}) + + # Extra fields are allowed and don't affect routing + text_with_extra = { + "type": "mt_text", + "id": "test", + "body": "Hello", + "extra": "value", + } + response = adapter.validate_python(text_with_extra) + assert isinstance(response, TextResponse) + + binary_with_extra = { + "type": "mt_binary", + "id": "test", + "body": "SGVsbG8=", + "udh": "06050423F4", + "extra": "value", + } + response = adapter.validate_python(binary_with_extra) + assert isinstance(response, BinaryResponse) diff --git a/tests/unit/domains/sms/v1/models/response/test_dry_run_response_model.py b/tests/unit/domains/sms/v1/models/response/test_dry_run_response_model.py new file mode 100644 index 00000000..26fe523e --- /dev/null +++ b/tests/unit/domains/sms/v1/models/response/test_dry_run_response_model.py @@ -0,0 +1,75 @@ +import pytest +from sinch.domains.sms.models.v1.response.dry_run_response import ( + DryRunResponse, +) +from sinch.domains.sms.models.v1.shared import DryRunPerRecipientDetails + + +@pytest.fixture +def dry_run_response_data(): + """Sample DryRunResponse data with per_recipient details.""" + return { + "number_of_recipients": 2, + "number_of_messages": 1, + "per_recipient": [ + { + "recipient": "+46701234567", + "body": "Your order #12345 has been shipped", + "number_of_parts": 1, + "encoding": "text", + }, + { + "recipient": "+46709876543", + "body": "Reminder: Your appointment is tomorrow at 2 PM", + "number_of_parts": 1, + "encoding": "text", + }, + ], + } + + +@pytest.fixture +def dry_run_response_data_without_per_recipient(): + return { + "number_of_recipients": 5, + "number_of_messages": 3, + } + + +def test_dry_run_response_expects_valid_input_with_per_recipient( + dry_run_response_data, +): + """Test that DryRunResponse correctly parses data with per_recipient details.""" + response = DryRunResponse(**dry_run_response_data) + + assert response.number_of_recipients == 2 + assert response.number_of_messages == 1 + assert response.per_recipient is not None + assert len(response.per_recipient) == 2 + + first_recipient = response.per_recipient[0] + assert isinstance(first_recipient, DryRunPerRecipientDetails) + assert first_recipient.recipient == "+46701234567" + assert first_recipient.body == "Your order #12345 has been shipped" + assert first_recipient.number_of_parts == 1 + assert first_recipient.encoding == "text" + + second_recipient = response.per_recipient[1] + assert second_recipient.recipient == "+46709876543" + assert ( + second_recipient.body + == "Reminder: Your appointment is tomorrow at 2 PM" + ) + assert second_recipient.number_of_parts == 1 + assert second_recipient.encoding == "text" + + +def test_dry_run_response_expects_valid_input_without_per_recipient( + dry_run_response_data_without_per_recipient, +): + """Test that DryRunResponse correctly parses data without per_recipient details.""" + response = DryRunResponse(**dry_run_response_data_without_per_recipient) + + assert response.number_of_recipients == 5 + assert response.number_of_messages == 3 + assert response.per_recipient is None diff --git a/tests/unit/domains/sms/v1/models/response/test_recipient_delivery_report_model.py b/tests/unit/domains/sms/v1/models/response/test_recipient_delivery_report_model.py new file mode 100644 index 00000000..7cb9b6af --- /dev/null +++ b/tests/unit/domains/sms/v1/models/response/test_recipient_delivery_report_model.py @@ -0,0 +1,208 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.sms.models.v1.response.recipient_delivery_report import ( + RecipientDeliveryReport, +) +from tests.conftest import parse_iso_datetime + + +@pytest.fixture +def sample_recipient_delivery_report_data(): + """ + Sample data for RecipientDeliveryReport testing. + """ + return { + "at": parse_iso_datetime("2022-08-30T08:16:08.930Z"), + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "code": 401, + "recipient": "+44231235674", + "status": "Dispatched", + "type": "recipient_delivery_report_sms", + } + + +@pytest.mark.parametrize( + "status, code, report_type", + [ + ("Delivered", 401, "recipient_delivery_report_sms"), + ("Failed", 402, "recipient_delivery_report_sms"), + ("Queued", 400, "recipient_delivery_report_mms"), + ("Dispatched", 401, "recipient_delivery_report_mms"), + ], +) +def test_recipient_delivery_report_expects_valid_inputs( + status, code, report_type +): + """ + Test that the model correctly parses valid inputs with different statuses and codes. + """ + data = { + "at": parse_iso_datetime("2022-08-30T08:16:08.930Z"), + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "code": code, + "recipient": "+44231235674", + "status": status, + "type": report_type, + } + + report = RecipientDeliveryReport(**data) + + assert report.at == parse_iso_datetime("2022-08-30T08:16:08.930Z") + assert report.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert report.code == code + assert report.recipient == "+44231235674" + assert report.status == status + assert report.type == report_type + + +def test_recipient_delivery_report_expects_with_optional_fields( + sample_recipient_delivery_report_data, +): + """ + Test that the model works with all optional fields provided. + """ + data = sample_recipient_delivery_report_data.copy() + data.update( + { + "applied_originator": "My Originator", + "client_reference": "my_client_reference", + "encoding": "GSM", + "number_of_message_parts": 1, + "operator": "35000", + "operator_status_at": parse_iso_datetime("2019-08-24T14:15:22Z"), + } + ) + + report = RecipientDeliveryReport(**data) + + assert report.applied_originator == "My Originator" + assert report.client_reference == "my_client_reference" + assert report.encoding == "GSM" + assert report.number_of_message_parts == 1 + assert report.operator == "35000" + assert report.operator_status_at == parse_iso_datetime( + "2019-08-24T14:15:22Z" + ) + + +def test_recipient_delivery_report_expects_without_optional_fields( + sample_recipient_delivery_report_data, +): + """ + Test that the model works without optional fields. + """ + report = RecipientDeliveryReport(**sample_recipient_delivery_report_data) + + assert report.applied_originator is None + assert report.client_reference is None + assert report.encoding is None + assert report.number_of_message_parts is None + assert report.operator is None + assert report.operator_status_at is None + + +def test_recipient_delivery_report_expects_validation_error_for_missing_at(): + """ + Test that missing 'at' field raises a ValidationError. + """ + data = { + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "code": 401, + "recipient": "+44231235674", + "status": "Dispatched", + "type": "recipient_delivery_report_sms", + } + + with pytest.raises(ValidationError) as exc_info: + RecipientDeliveryReport(**data) + + assert "at" in str(exc_info.value) + + +def test_recipient_delivery_report_expects_validation_error_for_missing_batch_id(): + """ + Test that missing 'batch_id' field raises a ValidationError. + """ + data = { + "at": parse_iso_datetime("2022-08-30T08:16:08.930Z"), + "code": 401, + "recipient": "+44231235674", + "status": "Dispatched", + "type": "recipient_delivery_report_sms", + } + + with pytest.raises(ValidationError) as exc_info: + RecipientDeliveryReport(**data) + + assert "batch_id" in str(exc_info.value) + + +def test_recipient_delivery_report_expects_invalid_datetime_format(): + """ + Test that invalid datetime format raises a ValidationError. + """ + data = { + "at": "invalid-datetime", + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "code": 401, + "recipient": "+44231235674", + "status": "Dispatched", + "type": "recipient_delivery_report_sms", + } + + with pytest.raises(ValidationError) as exc_info: + RecipientDeliveryReport(**data) + + assert "at" in str(exc_info.value) + + +def test_recipient_delivery_report_expects_custom_encoding(): + """ + Test that the model accepts custom encoding values due to Union + StrictStr. + """ + data = { + "at": parse_iso_datetime("2022-08-30T08:16:08.930Z"), + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "code": 401, + "recipient": "+44231235674", + "status": "Dispatched", + "type": "recipient_delivery_report_sms", + "encoding": "CUSTOM_ENCODING", + } + + report = RecipientDeliveryReport(**data) + assert report.encoding == "CUSTOM_ENCODING" + + +def test_recipient_delivery_report_expects_custom_status(): + """ + Test that the model accepts custom status values due to Union + StrictStr. + """ + data = { + "at": parse_iso_datetime("2022-08-30T08:16:08.930Z"), + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "code": 401, + "recipient": "+44231235674", + "status": "CUSTOM_STATUS", + "type": "recipient_delivery_report_sms", + } + + report = RecipientDeliveryReport(**data) + assert report.status == "CUSTOM_STATUS" + + +def test_recipient_delivery_report_expects_custom_type(): + """ + Test that the model accepts custom type values due to Union + StrictStr. + """ + data = { + "at": parse_iso_datetime("2022-08-30T08:16:08.930Z"), + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "code": 401, + "recipient": "+44231235674", + "status": "Dispatched", + "type": "custom_delivery_report_type", + } + + report = RecipientDeliveryReport(**data) + assert report.type == "custom_delivery_report_type" diff --git a/tests/unit/domains/sms/v1/test_batches.py b/tests/unit/domains/sms/v1/test_batches.py new file mode 100644 index 00000000..a95c3d64 --- /dev/null +++ b/tests/unit/domains/sms/v1/test_batches.py @@ -0,0 +1,875 @@ +from datetime import datetime, timezone +from unittest.mock import MagicMock +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.core.pagination import SMSPaginator +from sinch.domains.sms.api.v1.batches_apis import Batches +from sinch.domains.sms.api.v1.exceptions import SmsException +from sinch.domains.sms.api.v1.internal import ( + CancelBatchMessageEndpoint, + DryRunEndpoint, + GetBatchMessageEndpoint, + ListBatchesEndpoint, + ReplaceBatchEndpoint, + SendSMSEndpoint, + DeliveryFeedbackEndpoint, + UpdateBatchMessageEndpoint, +) +from sinch.domains.sms.models.v1.internal.dry_run_request import ( + DryRunTextRequest, + DryRunBinaryRequest, + DryRunMediaRequest, +) +from sinch.domains.sms.models.v1.internal.replace_batch_request import ( + ReplaceTextRequest, + ReplaceBinaryRequest, + ReplaceMediaRequest, +) +from sinch.domains.sms.models.v1.internal.update_batch_message_request import ( + UpdateTextRequestWithBatchId, + UpdateBinaryRequestWithBatchId, + UpdateMediaRequestWithBatchId, +) +from sinch.domains.sms.models.v1.response.dry_run_response import ( + DryRunResponse, +) +from sinch.domains.sms.models.v1.response.list_batches_response import ( + ListBatchesResponse, +) +from sinch.domains.sms.models.v1.shared import ( + MediaBody, + DryRunPerRecipientDetails, + TextRequest, + BinaryRequest, + MediaRequest, +) +from sinch.domains.sms.models.v1.shared.text_response import TextResponse +from sinch.domains.sms.models.v1.shared.binary_response import BinaryResponse +from sinch.domains.sms.models.v1.shared.media_response import MediaResponse + + +@pytest.fixture +def mock_dry_run_response(): + """Sample DryRunResponse for testing.""" + return DryRunResponse( + number_of_recipients=2, + number_of_messages=1, + per_recipient=[ + DryRunPerRecipientDetails( + recipient="+46701234567", + body="Hello World!", + number_of_parts=1, + encoding="text", + ), + DryRunPerRecipientDetails( + recipient="+46709876543", + body="Hello World!", + number_of_parts=1, + encoding="text", + ), + ], + ) + + +@pytest.fixture +def mock_batch_response(): + """Sample BatchResponse (TextResponse) for testing.""" + return TextResponse( + id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567"], + from_="+46701111111", + canceled=False, + body="Test message", + type="mt_text", + created_at=datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ), + modified_at=datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ), + ) + + +def test_batches_cancel_expects_correct_request( + mock_sinch_client_sms, mock_batch_response, mocker +): + """Test that cancel sends the correct request and handles the response properly.""" + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_batch_response + ) + + spy_endpoint = mocker.spy(CancelBatchMessageEndpoint, "__init__") + + batches = Batches(mock_sinch_client_sms) + response = batches.cancel(batch_id="01FC66621XXXXX119Z8PMV1QPQ") + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + + assert isinstance(response, TextResponse) + assert response.id == "01FC66621XXXXX119Z8PMV1QPQ" + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_get_expects_correct_request( + mock_sinch_client_sms, mock_batch_response, mocker +): + """Test that get sends the correct request and handles the response properly.""" + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_batch_response + ) + + spy_endpoint = mocker.spy(GetBatchMessageEndpoint, "__init__") + + batches = Batches(mock_sinch_client_sms) + response = batches.get(batch_id="01FC66621XXXXX119Z8PMV1QPQ") + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + + assert isinstance(response, TextResponse) + assert response.id == "01FC66621XXXXX119Z8PMV1QPQ" + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_list_expects_correct_request(mock_sinch_client_sms, mocker): + """Test that list sends the correct request and returns a paginator.""" + mock_list_batches_response = ListBatchesResponse( + count=2, + page=0, + page_size=10, + batches=[], + ) + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_list_batches_response + ) + + spy_endpoint = mocker.spy(ListBatchesEndpoint, "__init__") + + batches = Batches(mock_sinch_client_sms) + paginator = batches.list( + page=0, + page_size=10, + start_date=datetime(2024, 6, 1, tzinfo=timezone.utc), + end_date=datetime(2024, 6, 30, tzinfo=timezone.utc), + from_=["+46701111111"], + client_reference="test-ref", + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].page == 0 + assert kwargs["request_data"].page_size == 10 + assert kwargs["request_data"].from_ == ["+46701111111"] + assert kwargs["request_data"].client_reference == "test-ref" + + assert isinstance(paginator, SMSPaginator) + assert paginator.result == mock_list_batches_response + + +def test_batches_send_sms_expects_correct_request( + mock_sinch_client_sms, mock_batch_response, mocker +): + """Test that send_sms sends the correct request and handles the response properly.""" + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_batch_response + ) + + spy_endpoint = mocker.spy(SendSMSEndpoint, "__init__") + + batches = Batches(mock_sinch_client_sms) + response = batches.send_sms( + to=["+46701234567", "+46709876543"], + from_="+46701111111", + body="Test message", + delivery_report="full", + send_at=datetime(2024, 6, 6, 9, 25, 0, tzinfo=timezone.utc), + expire_at=datetime(2024, 6, 10, 9, 25, 0, tzinfo=timezone.utc), + event_destination_target="https://example.com/callback", + client_reference="test-ref", + feedback_enabled=True, + flash_message=False, + max_number_of_message_parts=3, + truncate_concat=True, + from_ton=1, + from_npi=1, + parameters={"name": {"+46701234567": "John"}}, + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], TextRequest) + assert kwargs["request_data"].to == ["+46701234567", "+46709876543"] + assert kwargs["request_data"].from_ == "+46701111111" + assert kwargs["request_data"].body == "Test message" + assert kwargs["request_data"].delivery_report == "full" + assert kwargs["request_data"].feedback_enabled is True + assert kwargs["request_data"].flash_message is False + assert kwargs["request_data"].max_number_of_message_parts == 3 + assert kwargs["request_data"].truncate_concat is True + assert kwargs["request_data"].from_ton == 1 + assert kwargs["request_data"].from_npi == 1 + assert kwargs["request_data"].parameters == { + "name": {"+46701234567": "John"} + } + + assert isinstance(response, TextResponse) + assert response.id == "01FC66621XXXXX119Z8PMV1QPQ" + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_send_binary_expects_correct_request( + mock_sinch_client_sms, mock_batch_response, mocker +): + """Test that send_binary sends the correct request and handles the response properly.""" + mock_binary_response = BinaryResponse( + id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567"], + from_="+46701111111", + canceled=False, + body="SGVsbG8gV29ybGQh", + udh="06050423F423F4", + type="mt_binary", + created_at=datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ), + modified_at=datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ), + ) + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_binary_response + ) + + spy_endpoint = mocker.spy(SendSMSEndpoint, "__init__") + + batches = Batches(mock_sinch_client_sms) + response = batches.send_binary( + to=["+46701234567"], + from_="+46701111111", + body="SGVsbG8gV29ybGQh", + udh="06050423F423F4", + delivery_report="summary", + send_at=datetime(2024, 6, 6, 9, 25, 0, tzinfo=timezone.utc), + expire_at=datetime(2024, 6, 10, 9, 25, 0, tzinfo=timezone.utc), + event_destination_target="https://example.com/callback", + client_reference="test-ref", + feedback_enabled=True, + from_ton=1, + from_npi=1, + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], BinaryRequest) + assert kwargs["request_data"].to == ["+46701234567"] + assert kwargs["request_data"].from_ == "+46701111111" + assert kwargs["request_data"].body == "SGVsbG8gV29ybGQh" + assert kwargs["request_data"].udh == "06050423F423F4" + assert kwargs["request_data"].delivery_report == "summary" + assert kwargs["request_data"].feedback_enabled is True + assert kwargs["request_data"].from_ton == 1 + assert kwargs["request_data"].from_npi == 1 + + assert isinstance(response, BinaryResponse) + assert response.id == "01FC66621XXXXX119Z8PMV1QPQ" + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_send_mms_expects_correct_request( + mock_sinch_client_sms, mock_batch_response, mocker +): + """Test that send_mms sends the correct request and handles the response properly.""" + mock_media_response = MediaResponse( + id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567"], + from_="+46701111111", + canceled=False, + body=MediaBody( + url="https://capybara.com/image.jpg", + message="Check out this image!", + subject="Image", + ), + type="mt_media", + created_at=datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ), + modified_at=datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ), + ) + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_media_response + ) + + spy_endpoint = mocker.spy(SendSMSEndpoint, "__init__") + + media_body = MediaBody( + url="https://capybara.com/video.mp4", + message="Check out this video!", + subject="Video", + ) + + batches = Batches(mock_sinch_client_sms) + response = batches.send_mms( + to=["+46701234567"], + from_="+46701111111", + body=media_body, + delivery_report="full", + send_at=datetime(2024, 6, 6, 9, 25, 0, tzinfo=timezone.utc), + expire_at=datetime(2024, 6, 10, 9, 25, 0, tzinfo=timezone.utc), + event_destination_target="https://example.com/callback", + client_reference="test-ref", + feedback_enabled=True, + strict_validation=True, + parameters={"name": {"+46701234567": "John"}}, + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], MediaRequest) + assert kwargs["request_data"].to == ["+46701234567"] + assert kwargs["request_data"].from_ == "+46701111111" + assert isinstance(kwargs["request_data"].body, MediaBody) + assert kwargs["request_data"].body.url == "https://capybara.com/video.mp4" + assert kwargs["request_data"].body.message == "Check out this video!" + assert kwargs["request_data"].body.subject == "Video" + assert kwargs["request_data"].delivery_report == "full" + assert kwargs["request_data"].feedback_enabled is True + assert kwargs["request_data"].strict_validation is True + assert kwargs["request_data"].parameters == { + "name": {"+46701234567": "John"} + } + + assert isinstance(response, MediaResponse) + assert response.id == "01FC66621XXXXX119Z8PMV1QPQ" + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_dry_run_sms_expects_correct_request( + mock_sinch_client_sms, mock_dry_run_response, mocker +): + """Test that dry_run_sms sends the correct request and handles the response properly.""" + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_dry_run_response + ) + + spy_endpoint = mocker.spy(DryRunEndpoint, "__init__") + + batches = Batches(mock_sinch_client_sms) + response = batches.dry_run_sms( + to=["+46701234567"], + from_="+46701111111", + body="Hello World!", + per_recipient=True, + number_of_recipients=100, + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], DryRunTextRequest) + assert kwargs["request_data"].to == ["+46701234567"] + assert kwargs["request_data"].from_ == "+46701111111" + assert kwargs["request_data"].body == "Hello World!" + assert kwargs["request_data"].per_recipient is True + assert kwargs["request_data"].number_of_recipients == 100 + + assert isinstance(response, DryRunResponse) + assert response.number_of_recipients == 2 + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_dry_run_binary_expects_correct_request( + mock_sinch_client_sms, mock_dry_run_response, mocker +): + """Test that dry_run_binary sends the correct request and handles the response properly.""" + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_dry_run_response + ) + + spy_endpoint = mocker.spy(DryRunEndpoint, "__init__") + + batches = Batches(mock_sinch_client_sms) + response = batches.dry_run_binary( + to=["+46701234567"], + from_="+46701111111", + body="SGVsbG8gV29ybGQh", + udh="06050423F423F4", + per_recipient=False, + number_of_recipients=50, + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], DryRunBinaryRequest) + assert kwargs["request_data"].udh == "06050423F423F4" + assert kwargs["request_data"].per_recipient is False + assert kwargs["request_data"].number_of_recipients == 50 + + assert isinstance(response, DryRunResponse) + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_dry_run_mms_expects_correct_request( + mock_sinch_client_sms, mock_dry_run_response, mocker +): + """Test that dry_run_mms sends the correct request and handles the response properly.""" + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_dry_run_response + ) + + spy_endpoint = mocker.spy(DryRunEndpoint, "__init__") + + media_body = MediaBody( + url="https://capybara.com/image.jpg", + message="Check out this image!", + subject="Image", + ) + + batches = Batches(mock_sinch_client_sms) + response = batches.dry_run_mms( + to=["+46701234567"], + from_="+46701111111", + body=media_body, + per_recipient=True, + number_of_recipients=75, + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], DryRunMediaRequest) + assert isinstance(kwargs["request_data"].body, MediaBody) + assert kwargs["request_data"].body.url == "https://capybara.com/image.jpg" + assert kwargs["request_data"].per_recipient is True + assert kwargs["request_data"].number_of_recipients == 75 + + assert isinstance(response, DryRunResponse) + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_dry_run_with_request_object_expects_correct_request( + mock_sinch_client_sms, mock_dry_run_response, mocker +): + """Test that dry_run with DryRunRequest object sends the correct request.""" + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_dry_run_response + ) + + spy_endpoint = mocker.spy(DryRunEndpoint, "__init__") + + request = DryRunTextRequest( + to=["+46701234567"], + from_="+46701111111", + body="Hello World!", + per_recipient=True, + number_of_recipients=100, + ) + + batches = Batches(mock_sinch_client_sms) + response = batches.dry_run(request=request) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], DryRunTextRequest) + assert kwargs["request_data"].to == ["+46701234567"] + assert kwargs["request_data"].from_ == "+46701111111" + assert kwargs["request_data"].body == "Hello World!" + assert kwargs["request_data"].per_recipient is True + assert kwargs["request_data"].number_of_recipients == 100 + + assert isinstance(response, DryRunResponse) + assert response.number_of_recipients == 2 + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_replace_sms_expects_correct_request( + mock_sinch_client_sms, mock_batch_response, mocker +): + """Test that replace_sms sends the correct request and handles the response properly.""" + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_batch_response + ) + + spy_endpoint = mocker.spy(ReplaceBatchEndpoint, "__init__") + + batches = Batches(mock_sinch_client_sms) + response = batches.replace_sms( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567"], + from_="+46701111111", + body="Updated message", + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], ReplaceTextRequest) + assert kwargs["request_data"].batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert kwargs["request_data"].to == ["+46701234567"] + assert kwargs["request_data"].from_ == "+46701111111" + assert kwargs["request_data"].body == "Updated message" + + assert isinstance(response, TextResponse) + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_replace_binary_expects_correct_request( + mock_sinch_client_sms, mock_batch_response, mocker +): + """Test that replace_binary sends the correct request and handles the response properly.""" + mock_binary_response = BinaryResponse( + id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567"], + from_="+46701111111", + canceled=False, + body="SGVsbG8gV29ybGQh", + udh="06050423F423F4", + type="mt_binary", + created_at=datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ), + modified_at=datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ), + ) + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_binary_response + ) + + spy_endpoint = mocker.spy(ReplaceBatchEndpoint, "__init__") + + batches = Batches(mock_sinch_client_sms) + response = batches.replace_binary( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567"], + from_="+46701111111", + body="SGVsbG8gV29ybGQh", + udh="06050423F423F4", + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], ReplaceBinaryRequest) + assert kwargs["request_data"].batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert kwargs["request_data"].udh == "06050423F423F4" + + assert isinstance(response, BinaryResponse) + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_replace_mms_expects_correct_request( + mock_sinch_client_sms, mock_batch_response, mocker +): + """Test that replace_mms sends the correct request and handles the response properly.""" + mock_media_response = MediaResponse( + id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567"], + from_="+46701111111", + canceled=False, + body=MediaBody( + url="https://capybara.com/image.jpg", + message="Check out this image!", + subject="Image", + ), + type="mt_media", + created_at=datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ), + modified_at=datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ), + ) + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_media_response + ) + + spy_endpoint = mocker.spy(ReplaceBatchEndpoint, "__init__") + + media_body = MediaBody( + url="https://capybara.com/video.mp4", + message="Updated video message", + subject="Video Update", + ) + + batches = Batches(mock_sinch_client_sms) + response = batches.replace_mms( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567"], + from_="+46701111111", + body=media_body, + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], ReplaceMediaRequest) + assert kwargs["request_data"].batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert isinstance(kwargs["request_data"].body, MediaBody) + assert kwargs["request_data"].body.url == "https://capybara.com/video.mp4" + + assert isinstance(response, MediaResponse) + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_send_delivery_feedback_expects_correct_request( + mock_sinch_client_sms, mocker +): + """Test that send_delivery_feedback sends the correct request.""" + mock_sinch_client_sms.configuration.transport.request.return_value = None + + spy_endpoint = mocker.spy(DeliveryFeedbackEndpoint, "__init__") + + batches = Batches(mock_sinch_client_sms) + batches.send_delivery_feedback( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + recipients=["+46701234567", "+46709876543"], + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert kwargs["request_data"].recipients == [ + "+46701234567", + "+46709876543", + ] + + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_update_sms_expects_correct_request( + mock_sinch_client_sms, mock_batch_response, mocker +): + """Test that update_sms sends the correct request and handles the response properly.""" + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_batch_response + ) + + spy_endpoint = mocker.spy(UpdateBatchMessageEndpoint, "__init__") + + batches = Batches(mock_sinch_client_sms) + response = batches.update_sms( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + body="Updated body", + to_add=["+46709999999"], + to_remove=["+46708888888"], + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], UpdateTextRequestWithBatchId) + assert kwargs["request_data"].batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert kwargs["request_data"].body == "Updated body" + assert kwargs["request_data"].to_add == ["+46709999999"] + assert kwargs["request_data"].to_remove == ["+46708888888"] + + assert isinstance(response, TextResponse) + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_update_binary_expects_correct_request( + mock_sinch_client_sms, mock_batch_response, mocker +): + """Test that update_binary sends the correct request and handles the response properly.""" + mock_binary_response = BinaryResponse( + id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567"], + from_="+46701111111", + canceled=False, + body="VXBkYXRlZA==", + udh="06050423F423F5", + type="mt_binary", + created_at=datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ), + modified_at=datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ), + ) + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_binary_response + ) + + spy_endpoint = mocker.spy(UpdateBatchMessageEndpoint, "__init__") + + batches = Batches(mock_sinch_client_sms) + response = batches.update_binary( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + udh="06050423F423F5", + body="VXBkYXRlZA==", + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], UpdateBinaryRequestWithBatchId) + assert kwargs["request_data"].batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert kwargs["request_data"].udh == "06050423F423F5" + assert kwargs["request_data"].body == "VXBkYXRlZA==" + + assert isinstance(response, BinaryResponse) + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_update_mms_expects_correct_request( + mock_sinch_client_sms, mock_batch_response, mocker +): + """Test that update_mms sends the correct request and handles the response properly.""" + mock_media_response = MediaResponse( + id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567"], + from_="+46701111111", + canceled=False, + body=MediaBody( + url="https://capybara.com/updated.jpg", + message="Updated message", + subject="Updated", + ), + type="mt_media", + created_at=datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ), + modified_at=datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ), + ) + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_media_response + ) + + spy_endpoint = mocker.spy(UpdateBatchMessageEndpoint, "__init__") + + media_body = MediaBody( + url="https://capybara.com/new-image.jpg", + message="New image message", + subject="New Image", + ) + + batches = Batches(mock_sinch_client_sms) + response = batches.update_mms( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + body=media_body, + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], UpdateMediaRequestWithBatchId) + assert kwargs["request_data"].batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert isinstance(kwargs["request_data"].body, MediaBody) + assert ( + kwargs["request_data"].body.url == "https://capybara.com/new-image.jpg" + ) + + assert isinstance(response, MediaResponse) + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_send_expects_correct_request( + mock_sinch_client_sms, mock_batch_response, mocker +): + """Test that send with TextRequest sends the correct request and handles the response properly.""" + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_batch_response + ) + + spy_endpoint = mocker.spy(SendSMSEndpoint, "__init__") + + batches = Batches(mock_sinch_client_sms) + response = batches.send( + request={ + "to": ["+46701234567"], + "from_": "+46701111111", + "body": "Test message", + "type": "mt_text", + } + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].to == ["+46701234567"] + assert kwargs["request_data"].from_ == "+46701111111" + assert kwargs["request_data"].body == "Test message" + + assert isinstance(response, TextResponse) + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_expects_validation_recalculates_auth_method_when_credentials_change( + mock_sinch_client_sms, mocker +): + """Test that SMS requests validate authentication and recalculate auth method when credentials change after initialization.""" + config = mock_sinch_client_sms.configuration + + assert config.authentication_method == "project_auth" + + mock_response = TextResponse( + id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567"], + from_="+46701111111", + canceled=False, + body="Test message", + type="mt_text", + created_at=datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ), + modified_at=datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ), + ) + config.transport.request.return_value = mock_response + + # Initialize Batches service BEFORE changing the authentication method + batches = Batches(mock_sinch_client_sms) + + spy_endpoint = mocker.spy(GetBatchMessageEndpoint, "__init__") + + config.sms_api_token = "test_sms_token" + + assert config.authentication_method == "project_auth" + + # Make an SMS request. This should trigger validation and recalculate auth method + response = batches.get(batch_id="01FC66621XXXXX119Z8PMV1QPQ") + + assert config.authentication_method == "sms_auth" + + # Verify that project_id parameter contains the service_plan_id + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["project_id"] == config.service_plan_id + + assert isinstance(response, TextResponse) + assert response.id == "01FC66621XXXXX119Z8PMV1QPQ" diff --git a/tests/unit/domains/sms/v1/test_delivery_reports.py b/tests/unit/domains/sms/v1/test_delivery_reports.py new file mode 100644 index 00000000..cd81c722 --- /dev/null +++ b/tests/unit/domains/sms/v1/test_delivery_reports.py @@ -0,0 +1,216 @@ +from datetime import datetime, timezone +from unittest.mock import MagicMock +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.core.pagination import SMSPaginator +from sinch.domains.sms.api.v1 import DeliveryReports +from sinch.domains.sms.api.v1.exceptions import SmsException +from sinch.domains.sms.api.v1.internal import ( + GetBatchDeliveryReportEndpoint, + GetRecipientDeliveryReportEndpoint, + ListDeliveryReportsEndpoint, +) +from sinch.domains.sms.models.v1.internal import ( + GetBatchDeliveryReportRequest, + GetRecipientDeliveryReportRequest, + ListDeliveryReportsRequest, + ListDeliveryReportsResponse, +) +from sinch.domains.sms.models.v1.response import ( + BatchDeliveryReport, + RecipientDeliveryReport, +) +from sinch.domains.sms.models.v1.shared import MessageDeliveryStatus + + +def test_get_batch_delivery_report_expects_valid_request( + mock_sinch_client_sms, mocker +): + """ + Test that the DeliveryReports.get() method sends the correct request + and handles the response properly. + """ + mock_response = BatchDeliveryReport( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + statuses=[ + MessageDeliveryStatus(code=400, count=1, status="DELIVERED") + ], + total_message_count=1, + type="summary", + ) + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_response + ) + + # Spy on the GetBatchDeliveryReportEndpoint to capture calls + spy_endpoint = mocker.spy(GetBatchDeliveryReportEndpoint, "__init__") + + delivery_reports = DeliveryReports(mock_sinch_client_sms) + response = delivery_reports.get( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + report_type="summary", + status=["DELIVERED"], + code=[400], + client_reference="test_client_ref", + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == GetBatchDeliveryReportRequest( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + type="summary", + status=["DELIVERED"], + code=[400], + client_reference="test_client_ref", + ) + + assert isinstance(response, BatchDeliveryReport) + assert response.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_get_for_number_expects_correct_request(mock_sinch_client_sms, mocker): + """ + Test that the DeliveryReports.get_for_number() method sends the correct request + and handles the response properly. + """ + + mock_response = RecipientDeliveryReport( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + recipient="+1234567890", + code=400, + status="DELIVERED", + type="recipient_delivery_report_sms", + at=datetime(2025, 1, 15, 10, 30, 45, 123000, tzinfo=timezone.utc), + ) + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_response + ) + + spy_endpoint = mocker.spy(GetRecipientDeliveryReportEndpoint, "__init__") + + delivery_reports = DeliveryReports(mock_sinch_client_sms) + response = delivery_reports.get_for_number( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", recipient="+1234567890" + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == GetRecipientDeliveryReportRequest( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", recipient_msisdn="+1234567890" + ) + + assert isinstance(response, RecipientDeliveryReport) + assert response.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert response.recipient == "+1234567890" + + +def test_list_delivery_reports_expects_valid_request( + mock_sinch_client_sms, mocker +): + """ + Test that the DeliveryReports.list() method sends the correct request + and handles the response properly. + """ + + mock_response = ListDeliveryReportsResponse( + page=0, page_size=2, count=1, delivery_reports=[] + ) + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_response + ) + + spy_endpoint = mocker.spy(ListDeliveryReportsEndpoint, "__init__") + + delivery_reports = DeliveryReports(mock_sinch_client_sms) + response = delivery_reports.list( + page=0, + page_size=2, + start_date=datetime(2025, 1, 1, tzinfo=timezone.utc), + end_date=datetime(2025, 1, 31, tzinfo=timezone.utc), + status=["DELIVERED"], + code=[400], + client_reference="test_client_ref", + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == ListDeliveryReportsRequest( + page=0, + page_size=2, + start_date=datetime(2025, 1, 1, tzinfo=timezone.utc), + end_date=datetime(2025, 1, 31, tzinfo=timezone.utc), + status=["DELIVERED"], + code=[400], + client_reference="test_client_ref", + ) + + assert isinstance(response, SMSPaginator) + assert hasattr(response, "has_next_page") + assert response.result == mock_response + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_sms_endpoint_handle_response_raises_exception_on_error( + mock_sinch_client_sms, +): + """ + Test that SmsEndpoint.handle_response raises SmsException when status_code >= 400. + """ + + request_data = GetBatchDeliveryReportRequest( + batch_id="test_batch_id", type="summary" + ) + endpoint = GetBatchDeliveryReportEndpoint("test_project_id", request_data) + + error_response = HTTPResponse(status_code=400, body=1, headers={}) + + with pytest.raises(SmsException) as exc_info: + endpoint.handle_response(error_response) + + assert exc_info.value.args[0] == "Error 400" + assert exc_info.value.http_response == error_response + assert exc_info.value.is_from_server is True + assert exc_info.value.response_status_code == 400 + + +def test_delivery_reports_expects_validation_recalculates_auth_method_when_credentials_change( + mock_sinch_client_sms, +): + """ + Test that SMS requests validate authentication and recalculate auth method + when credentials change after initialization. + """ + config = mock_sinch_client_sms.configuration + + assert config.authentication_method == "project_auth" + + mock_response = BatchDeliveryReport( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + statuses=[ + MessageDeliveryStatus(code=400, count=1, status="DELIVERED") + ], + total_message_count=1, + type="summary", + ) + config.transport.request.return_value = mock_response + + # Change credentials to SMS auth (add sms_api_token, service_plan_id already exists in fixture) + config.sms_api_token = "test_sms_token" + + # Auth method should still be project_auth (not updated automatically) + assert config.authentication_method == "project_auth" + + # Make an SMS request. This should trigger validation and recalculate auth method + delivery_reports = DeliveryReports(mock_sinch_client_sms) + response = delivery_reports.get( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", report_type="summary" + ) + + assert config.authentication_method == "sms_auth" + assert isinstance(response, BatchDeliveryReport) + assert response.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" diff --git a/tests/unit/domains/voice/test_callout_conference.py b/tests/unit/domains/voice/test_callout_conference.py deleted file mode 100644 index 9ea2169c..00000000 --- a/tests/unit/domains/voice/test_callout_conference.py +++ /dev/null @@ -1,77 +0,0 @@ -import json -import pytest -from sinch.domains.voice.enums import CalloutMethod - -from sinch.domains.voice.endpoints.callouts.callout import CalloutEndpoint - -from sinch.domains.voice.models.callouts.requests import ConferenceVoiceCalloutRequest - - -@pytest.fixture -def request_data(): - return ConferenceVoiceCalloutRequest( - destination={ - "type": "number", - "endpoint": "+33612345678", - }, - cli="", - greeting='Welcome', - conferenceId="123456", - conferenceDtmfOptions={ - "mode": "forward", - "max_digits": 2, - "timeout_mills": 2500 - }, - dtmf="dtmf", - conference="conference", - maxDuration=10, - enableAce=True, - enableDice=True, - enablePie=True, - locale="locale", - mohClass="moh_class", - custom="custom", - domain="pstn" - ) - -@pytest.fixture -def endpoint(request_data): - return CalloutEndpoint(request_data, CalloutMethod.CONFERENCE.value) - -@pytest.fixture -def mock_response_body(): - expected_body = { - "method" : "conferenceCallout", - "conferenceCallout" : { - "destination" : { - "type" : "number", - "endpoint" : "+33612345678" - }, - "conferenceId" : "123456", - "cli" : "", - "conferenceDtmfOptions" : { - "mode" : "forward", - "timeoutMills" : 2500, - "maxDigits" : 2 - }, - "dtmf" : "dtmf", - "conference" : "conference", - "maxDuration" : 10, - "enableAce" : True, - "enableDice" : True, - "enablePie" : True, - "locale" : "locale", - "greeting" : "Welcome", - "mohClass" : "moh_class", - "custom" : "custom", - "domain" : "pstn" - } - } - return json.dumps(expected_body) - -def test_handle_response(endpoint, mock_response_body): - """ - Check if response is handled and mapped to the appropriate fields correctly. - """ - request_body = endpoint.request_body() - assert request_body == mock_response_body diff --git a/tests/unit/http_transport_tests.py b/tests/unit/http_transport_tests.py index 5e096c2d..bee82710 100644 --- a/tests/unit/http_transport_tests.py +++ b/tests/unit/http_transport_tests.py @@ -1,12 +1,11 @@ -import httpx import pytest -from unittest.mock import Mock, AsyncMock +from unittest.mock import Mock from sinch.core.enums import HTTPAuthentication from sinch.core.exceptions import ValidationException from sinch.core.models.http_request import HttpRequest from sinch.core.endpoint import HTTPEndpoint from sinch.core.models.http_response import HTTPResponse -from sinch.core.ports.http_transport import HTTPTransport, AsyncHTTPTransport +from sinch.core.ports.http_transport import HTTPTransport # Mock classes and fixtures @@ -14,16 +13,16 @@ class MockEndpoint(HTTPEndpoint): def __init__(self, auth_type): self.HTTP_AUTHENTICATION = auth_type self.HTTP_METHOD = "GET" - + def build_url(self, sinch): return "api.sinch.com/test" - + def get_url_without_origin(self, sinch): return "/test" - + def request_body(self): return {} - + def build_query_params(self): return {} @@ -38,30 +37,8 @@ def mock_sinch(): sinch.configuration.key_id = "test_key_id" sinch.configuration.key_secret = "test_key_secret" sinch.configuration.project_id = "test_project_id" - sinch.configuration.application_key = "test_app_key" - sinch.configuration.application_secret = "dGVzdF9hcHBfc2VjcmV0X2Jhc2U2NA==" - sinch.configuration.sms_api_token = "test_sms_token" - sinch.configuration.service_plan_id = "test_service_plan" - return sinch - -@pytest.fixture -def mock_sinch_async(): - sinch = Mock() - sinch.configuration = Mock() - sinch.configuration.key_id = "test_key_id" - sinch.configuration.key_secret = "test_key_secret" - sinch.configuration.project_id = "test_project_id" - sinch.configuration.application_key = "test_app_key" - sinch.configuration.application_secret = "dGVzdF9hcHBfc2VjcmV0X2Jhc2U2NA==" sinch.configuration.sms_api_token = "test_sms_token" sinch.configuration.service_plan_id = "test_service_plan" - - mock_auth = AsyncMock() - mock_token = Mock() - mock_token.access_token = "test_token" - mock_auth.get_auth_token.return_value = mock_token - sinch.authentication = mock_auth - return sinch @@ -77,43 +54,34 @@ def base_request(): auth=() ) + class MockHTTPTransport(HTTPTransport): def request(self, endpoint: HTTPEndpoint) -> HTTPResponse: # Simple mock implementation that just returns a dummy response return HTTPResponse(status_code=200, body={}, headers={}) -class MockAsyncHTTPTransport(AsyncHTTPTransport): - async def request(self, endpoint: HTTPEndpoint) -> HTTPResponse: - # Simple mock implementation that just returns a dummy response - return HTTPResponse(status_code=200, body={}, headers={}) # Synchronous Transport Tests class TestHTTPTransport: @pytest.mark.parametrize("auth_type", [ HTTPAuthentication.BASIC.value, HTTPAuthentication.OAUTH.value, - HTTPAuthentication.SIGNED.value, HTTPAuthentication.SMS_TOKEN.value ]) def test_authenticate(self, mock_sinch, base_request, auth_type): transport = MockHTTPTransport(mock_sinch) endpoint = MockEndpoint(auth_type) - + if auth_type == HTTPAuthentication.BASIC.value: result = transport.authenticate(endpoint, base_request) assert result.auth == ("test_key_id", "test_key_secret") - + elif auth_type == HTTPAuthentication.OAUTH.value: mock_sinch.authentication.get_auth_token.return_value.access_token = "test_token" result = transport.authenticate(endpoint, base_request) assert result.headers["Authorization"] == "Bearer test_token" assert result.headers["Content-Type"] == "application/json" - - elif auth_type == HTTPAuthentication.SIGNED.value: - result = transport.authenticate(endpoint, base_request) - assert "x-timestamp" in result.headers - assert "Authorization" in result.headers - + elif auth_type == HTTPAuthentication.SMS_TOKEN.value: result = transport.authenticate(endpoint, base_request) assert result.headers["Authorization"] == "Bearer test_sms_token" @@ -122,66 +90,14 @@ def test_authenticate(self, mock_sinch, base_request, auth_type): @pytest.mark.parametrize("auth_type,missing_creds", [ (HTTPAuthentication.BASIC.value, {"key_id": None}), (HTTPAuthentication.OAUTH.value, {"key_secret": None}), - (HTTPAuthentication.SIGNED.value, {"application_key": None}), (HTTPAuthentication.SMS_TOKEN.value, {"sms_api_token": None}) ]) def test_authenticate_missing_credentials(self, mock_sinch, base_request, auth_type, missing_creds): transport = MockHTTPTransport(mock_sinch) endpoint = MockEndpoint(auth_type) - - for cred, value in missing_creds.items(): - setattr(mock_sinch.configuration, cred, value) - - with pytest.raises(ValidationException): - transport.authenticate(endpoint, base_request) - -# Async Transport Tests -class TestAsyncHTTPTransport: - @pytest.mark.asyncio - @pytest.mark.parametrize("auth_type", [ - HTTPAuthentication.BASIC.value, - HTTPAuthentication.OAUTH.value, - HTTPAuthentication.SIGNED.value, - HTTPAuthentication.SMS_TOKEN.value - ]) - async def test_authenticate(self, mock_sinch_async, base_request, auth_type): - transport = MockAsyncHTTPTransport(mock_sinch_async) - endpoint = MockEndpoint(auth_type) - - if auth_type == HTTPAuthentication.BASIC.value: - result = await transport.authenticate(endpoint, base_request) - assert isinstance(result.auth, httpx.BasicAuth) - assert result.auth._auth_header == "Basic dGVzdF9rZXlfaWQ6dGVzdF9rZXlfc2VjcmV0" - - elif auth_type == HTTPAuthentication.OAUTH.value: - mock_sinch_async.authentication.get_auth_token.return_value.access_token = "test_token" - result = await transport.authenticate(endpoint, base_request) - assert result.headers["Authorization"] == "Bearer test_token" - assert result.headers["Content-Type"] == "application/json" - - elif auth_type == HTTPAuthentication.SIGNED.value: - result = await transport.authenticate(endpoint, base_request) - assert "x-timestamp" in result.headers - assert "Authorization" in result.headers - - elif auth_type == HTTPAuthentication.SMS_TOKEN.value: - result = await transport.authenticate(endpoint, base_request) - assert result.headers["Authorization"] == "Bearer test_sms_token" - assert result.headers["Content-Type"] == "application/json" - - @pytest.mark.asyncio - @pytest.mark.parametrize("auth_type,missing_creds", [ - (HTTPAuthentication.BASIC.value, {"key_id": None}), - (HTTPAuthentication.OAUTH.value, {"key_secret": None}), - (HTTPAuthentication.SIGNED.value, {"application_key": None}), - (HTTPAuthentication.SMS_TOKEN.value, {"sms_api_token": None}) - ]) - async def test_authenticate_missing_credentials(self, mock_sinch_async, base_request, auth_type, missing_creds): - transport = MockAsyncHTTPTransport(mock_sinch_async) - endpoint = MockEndpoint(auth_type) for cred, value in missing_creds.items(): - setattr(mock_sinch_async.configuration, cred, value) + setattr(mock_sinch.configuration, cred, value) with pytest.raises(ValidationException): - await transport.authenticate(endpoint, base_request) \ No newline at end of file + transport.authenticate(endpoint, base_request) diff --git a/tests/unit/test_check_snippet_coverage.py b/tests/unit/test_check_snippet_coverage.py new file mode 100644 index 00000000..602bacc1 --- /dev/null +++ b/tests/unit/test_check_snippet_coverage.py @@ -0,0 +1,147 @@ +import importlib.util +from pathlib import Path +from textwrap import dedent +import pytest + +ROOT = Path(__file__).parent.parent.parent +_SPEC = importlib.util.spec_from_file_location( + "check_snippet_coverage", + ROOT / "scripts" / "check_snippet_coverage.py", +) +_CHECK_SNIPPET_MOD = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(_CHECK_SNIPPET_MOD) +validate_snippet = _CHECK_SNIPPET_MOD.validate_snippet + + +@pytest.fixture +def temp_snippet_dir(tmp_path): + """Temporary directory for snippet files.""" + return tmp_path + + +def test_nonexistent_module_import_expects_failure_with_broken_import_message( + temp_snippet_dir, +): + """Test that importing a nonexistent module returns failure with broken import message.""" + path = temp_snippet_dir / "snippet.py" + path.write_text("from nonexistent_module_xyz import foo") + + success, error = validate_snippet(path) + + assert success is False + assert "Broken import" in error + assert "nonexistent_module_xyz" in error + + +def test_missing_name_import_from_sinch_expects_failure(temp_snippet_dir): + """Test that importing a missing name from sinch returns failure.""" + path = temp_snippet_dir / "snippet.py" + path.write_text("from sinch import NonExistentClass") + + success, error = validate_snippet(path) + + assert success is False + assert "Broken import" in error or "ImportError" in error + + +def test_nonexistent_sdk_method_expects_attribute_error(temp_snippet_dir): + """Test that calling a nonexistent SDK method returns failure with attribute error.""" + snippet = """ + from sinch import SinchClient + + sinch_client = SinchClient( + project_id="my-project-id", + key_id="my-key-id", + key_secret="my-key-secret", + sms_region="us", + ) + sinch_client.sms.batches.send_nonexistent_method( + to=["+1"], from_="+1", body="hi" + ) + """ + path = temp_snippet_dir / "snippet.py" + path.write_text(dedent(snippet)) + + success, error = validate_snippet(path) + + assert success is False + assert "Method/attribute does not exist" in error + assert "send_nonexistent_method" in error + + +def test_invalid_syntax_expects_syntax_error(temp_snippet_dir): + """Test that invalid Python syntax returns failure with syntax error.""" + path = temp_snippet_dir / "snippet.py" + path.write_text("def foo()\n return 42") + + success, error = validate_snippet(path) + + assert success is False + assert "Syntax error" in error + + +def test_snippet_without_api_call_expects_failure(temp_snippet_dir): + """Test that a snippet that does not make an API call returns failure.""" + snippet = """ + from sinch import SinchClient + + sinch_client = SinchClient( + project_id="my-project-id", + key_id="my-key-id", + key_secret="my-key-secret", + sms_region="us", + ) + print("no api call") + """ + path = temp_snippet_dir / "snippet.py" + path.write_text(dedent(snippet)) + + success, error = validate_snippet(path) + + assert success is False + assert "without making API call" in error + + +def test_invalid__args_expects_failure(temp_snippet_dir): + """Test that invalid arguments return failure (TypeError or similar).""" + snippet = """ + from sinch import SinchClient + + sinch_client = SinchClient( + project_id="my-project-id", + key_id="my-key-id", + key_secret="my-key-secret", + sms_region="us", + ) + sinch_client.sms.batches.send_sms( + to="not_a_list", from_="+1", body="hi" + ) + """ + path = temp_snippet_dir / "snippet.py" + path.write_text(dedent(snippet)) + + success, error = validate_snippet(path) + + assert success is False + assert "TypeError" in error or "AttributeError" in error or len(error) > 0 + + +def test_valid_snippet_expects_success(temp_snippet_dir): + """Test that a valid snippet (inline string) passes validation.""" + snippet = """ + from sinch import SinchClient + + sinch_client = SinchClient( + project_id="my-project-id", + key_id="my-key-id", + key_secret="my-key-secret", + sms_region="us", + ) + sinch_client.sms.batches.send_sms(to=["+1"], from_="+1", body="hi") + """ + path = temp_snippet_dir / "snippet.py" + path.write_text(dedent(snippet)) + + success, error = validate_snippet(path) + + assert success is True, f"Snippet failed: {error}" diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 24907c27..ce63624e 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -1,44 +1,67 @@ -from sinch import SinchClient, SinchClientAsync +import pytest +from sinch import SinchClient from sinch.core.clients.sinch_client_configuration import Configuration def test_sinch_client_initialization(): - sinch_client_sync = SinchClient( + """ Test that SinchClient can be initialized with or without parameters """ + sinch_client = SinchClient( key_id="test", key_secret="test_secret", project_id="test_project_id" ) - assert sinch_client_sync + assert sinch_client -def test_sinch_client_async_initialization(): - sinch_client_async = SinchClientAsync( - key_id="test", - key_secret="test_secret", - project_id="test_project_id" +def test_sinch_client_expects_to_be_initialized_with_sms(): + """ Test that SinchClient can be initialized with sms_region, service_plan_id and sms_api_token """ + sinch_client = SinchClient( + sms_region="us", + service_plan_id="test_service_plan", + sms_api_token="test_sms_token" ) - assert sinch_client_async - - -def test_sinch_client_has_all_business_domains(sinch_client_sync): - assert hasattr(sinch_client_sync, "authentication") - assert hasattr(sinch_client_sync, "sms") - assert hasattr(sinch_client_sync, "conversation") - assert hasattr(sinch_client_sync, "numbers") + assert sinch_client -def test_sinch_client_async_has_all_business_domains(sinch_client_async): - assert hasattr(sinch_client_async, "authentication") - assert hasattr(sinch_client_async, "sms") - assert hasattr(sinch_client_async, "conversation") - assert hasattr(sinch_client_async, "numbers") +def test_sinch_client_expects_all_attributes(): + """ Test that SinchClient has all attributes""" + sinch_client = SinchClient( + key_id="test_key_id", + key_secret="test_key_secret", + project_id="test_project_id" + ) + + assert hasattr(sinch_client, "authentication") + assert hasattr(sinch_client, "sms") + assert hasattr(sinch_client, "conversation") + assert hasattr(sinch_client, "numbers") + assert hasattr(sinch_client, "number_lookup") + assert hasattr(sinch_client, "configuration") + assert isinstance(sinch_client.configuration, Configuration) -def test_sinch_client_has_configuration_object(sinch_client_sync): - assert hasattr(sinch_client_sync, "configuration") - assert isinstance(sinch_client_sync.configuration, Configuration) +def test_sinch_client_expects_to_be_initialized_with_conversation_region(): + """ Test that SinchClient can be initialized with conversation_region """ + sinch_client = SinchClient( + key_id="test_key_id", + key_secret="test_key_secret", + project_id="test_project_id", + conversation_region="eu" + ) + assert sinch_client.configuration.conversation_region == "eu" + assert sinch_client.configuration.conversation_origin == "https://eu.conversation.api.sinch.com" -def test_sinch_client_async_has_configuration_object(sinch_client_async): - assert hasattr(sinch_client_async, "configuration") - assert isinstance(sinch_client_async.configuration, Configuration) +def test_sinch_client_expects_conversation_region_error_when_not_provided(): + """ Test that get_conversation_origin raises ValueError when SinchClient is initialized without conversation_region """ + sinch_client = SinchClient( + key_id="test_key_id", + key_secret="test_key_secret", + project_id="test_project_id" + ) + + assert sinch_client.configuration.conversation_region is None + assert sinch_client.configuration.conversation_origin is None + + with pytest.raises(ValueError, match="Conversation region is required"): + sinch_client.configuration.get_conversation_origin() diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index da5815b7..db790b67 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -1,59 +1,76 @@ +from logging import Logger, getLogger +import pytest from sinch.core.clients.sinch_client_configuration import Configuration from sinch.core.adapters.requests_http_transport import HTTPTransportRequests from sinch.core.token_manager import TokenManager -def test_configuration_initialization_happy_path(sinch_client_sync): +def test_configuration_happy_capy_expects_initialization(sinch_client_sync): + """ Test that Configuration can be initialized with all parameters """ client_configuration = Configuration( - key_id="Rodney", - key_secret="Mullen", - project_id="Is the King!", transport=HTTPTransportRequests(sinch_client_sync), - token_manager=TokenManager(sinch_client_sync) + token_manager=TokenManager(sinch_client_sync), + key_id="CapyKey", + key_secret="CapybaraWhisper", + project_id="CapybaraProjectX", + logger=getLogger("CapyTrace"), + connection_timeout=10, + service_plan_id="CappyPremiumPlan", + sms_api_token="HappyCappyToken", + sms_region="us", + conversation_region="eu", ) - assert client_configuration.key_id == "Rodney" - assert client_configuration.key_secret == "Mullen" - assert client_configuration.project_id == "Is the King!" + + assert client_configuration.key_id == "CapyKey" + assert client_configuration.key_secret == "CapybaraWhisper" + assert client_configuration.project_id == "CapybaraProjectX" + assert isinstance(client_configuration.logger, Logger) + assert client_configuration.service_plan_id == "CappyPremiumPlan" + assert client_configuration.sms_api_token == "HappyCappyToken" + assert client_configuration.sms_region == "us" + assert client_configuration.conversation_region == "eu" assert isinstance(client_configuration.transport, HTTPTransportRequests) assert isinstance(client_configuration.token_manager, TokenManager) def test_set_sms_region_property_and_check_that_sms_origin_was_updated(sinch_client_sync): sinch_client_sync.configuration.sms_region = "pl" - assert "zt.pl.sms.api.sinch.com" == sinch_client_sync.configuration.sms_origin + assert "https://zt.pl.sms.api.sinch.com" == sinch_client_sync.configuration.sms_origin -def test_set_sms_domain_property_and_check_that_sms_origin_was_updated(sinch_client_sync): +def test_configuration_expects_set_sms_domain_property_and_check_that_sms_origin_was_updated(sinch_client_sync): + sinch_client_sync.configuration.sms_region = "us" sinch_client_sync.configuration.sms_domain = "{}.monty.python" assert "us.monty.python" == sinch_client_sync.configuration.sms_origin def test_set_sms_region_with_service_plan_id_property_and_check_that_sms_origin_was_updated(sinch_client_sync): sinch_client_sync.configuration.sms_region_with_service_plan_id = "Herring" - assert sinch_client_sync.configuration.sms_origin_with_service_plan_id.startswith("Herring") + assert sinch_client_sync.configuration.sms_origin_with_service_plan_id == "https://Herring.sms.api.sinch.com" -def test_set_conversation_region_property_and_check_that_sms_origin_was_updated(sinch_client_sync): - sinch_client_sync.configuration.conversation_region = "My_brain_hurts" - assert "brain" in sinch_client_sync.configuration.conversation_origin - assert "hurts" in sinch_client_sync.configuration.conversation_origin +def test_set_conversation_region_property_expects_updated_conversation_origin(sinch_client_sync): + """ Test that setting the conversation region property updates the conversation origin """ + sinch_client_sync.configuration.conversation_region = "us" + assert sinch_client_sync.configuration.conversation_origin == "https://us.conversation.api.sinch.com" -def test_set_conversation_domain_property_and_check_that_sms_origin_was_updated(sinch_client_sync): - sinch_client_sync.configuration.conversation_domain= "My_brain_hurts" - assert "brain" in sinch_client_sync.configuration.conversation_origin - assert "hurts" in sinch_client_sync.configuration.conversation_origin +def test_set_conversation_domain_property_expects_updated_conversation_origin(sinch_client_sync): + """ Test that setting the conversation domain property updates the conversation origin """ + sinch_client_sync.configuration.conversation_region = "eu" + sinch_client_sync.configuration.conversation_domain = "https://{}.test.conversation.api.sinch.com" + assert sinch_client_sync.configuration.conversation_origin == "https://eu.test.conversation.api.sinch.com" -def test_if_logger_name_was_preserved_correctly(sinch_client_async): +def test_if_logger_name_was_preserved_correctly(sinch_client_sync): clever_monty_python_quote = "Its_just_a_flesh_wound" client_configuration = Configuration( + transport=HTTPTransportRequests(sinch_client_sync), + token_manager=TokenManager(sinch_client_sync), key_id="Do", key_secret="a", project_id="Kickflip!", logger_name=clever_monty_python_quote, - transport=HTTPTransportRequests(sinch_client_async), - token_manager=TokenManager(sinch_client_async) ) client_configuration.logger.name = clever_monty_python_quote assert client_configuration.logger.name == clever_monty_python_quote @@ -69,3 +86,139 @@ def test_set_templates_domain_property_and_check_that_templates_origin_was_updat sinch_client_sync.configuration.templates_domain = "Are_you_suggesting_that_coconuts_migrate?" assert "coconuts" in sinch_client_sync.configuration.templates_origin assert "migrate" in sinch_client_sync.configuration.templates_origin + + +def test_configuration_expects_authentication_method_determination_sms_auth_priority(sinch_client_sync): + """ Test that SMS authentication takes priority over project authentication """ + client_configuration = Configuration( + transport=HTTPTransportRequests(sinch_client_sync), + token_manager=TokenManager(sinch_client_sync), + service_plan_id="test_service_plan", + sms_api_token="test_sms_token", + project_id="test_project_id" + ) + + assert client_configuration.authentication_method == "sms_auth" + + +def test_configuration_expects_authentication_method_determination_project_auth_fallback(sinch_client_sync): + """ Test that project authentication is used when SMS auth parameters are not provided """ + client_configuration = Configuration( + transport=HTTPTransportRequests(sinch_client_sync), + token_manager=TokenManager(sinch_client_sync), + project_id="test_project_id", + key_id="test_key_id", + key_secret="test_key_secret" + ) + + assert client_configuration.authentication_method == "project_auth" + + +def test_configuration_expects_sms_authentication_method_setting_sms_auth(sinch_client_sync): + """ Test that SMS authentication method is set to SMS_TOKEN for SMS auth """ + client_configuration = Configuration( + transport=HTTPTransportRequests(sinch_client_sync), + token_manager=TokenManager(sinch_client_sync), + service_plan_id="test_service_plan", + sms_api_token="test_sms_token" + ) + + assert client_configuration.authentication_method == "sms_auth" + + +def test_configuration_expects_authentication_method_determination_insufficient_parameters(sinch_client_sync): + """ Test that insufficient authentication parameters raise an error when validated """ + client_configuration = Configuration( + transport=HTTPTransportRequests(sinch_client_sync), + token_manager=TokenManager(sinch_client_sync) + ) + + assert client_configuration.authentication_method is None + + with pytest.raises(ValueError, match="The project_id is required"): + client_configuration.validate_authentication_parameters() + + +def test_configuration_expects_authentication_method_determination_only_service_plan_id(sinch_client_sync): + """ Test that only service_plan_id without sms_api_token raises appropriate error """ + client_configuration = Configuration( + transport=HTTPTransportRequests(sinch_client_sync), + token_manager=TokenManager(sinch_client_sync), + service_plan_id="test_service_plan" + ) + + assert client_configuration.authentication_method is None + + with pytest.raises(ValueError, match="The sms_api_token is required when using service_plan_id"): + client_configuration.validate_authentication_parameters() + + +def test_configuration_expects_no_error_when_both_auth_methods_provided_with_complete_project_auth(sinch_client_sync): + """ + Test that when both service_plan_id and complete project auth are provided, + no error is raised even though sms_api_token is missing. + This ensures project auth takes precedence when fully configured. + """ + client_configuration = Configuration( + transport=HTTPTransportRequests(sinch_client_sync), + token_manager=TokenManager(sinch_client_sync), + service_plan_id="test_service_plan", # Incomplete SMS auth + project_id="test_project_id", # Complete project auth + key_id="test_key_id", + key_secret="test_key_secret" + ) + + # Should use project_auth as the authentication method + assert client_configuration.authentication_method == "project_auth" + + # Should not raise an error because complete project auth is provided + client_configuration.validate_authentication_parameters() + + +def test_configuration_expects_sms_origin_for_auth_sms_authentication(sinch_client_sync): + """ Test that SMS authentication returns sms_origin_with_service_plan_id """ + client_configuration = Configuration( + transport=HTTPTransportRequests(sinch_client_sync), + token_manager=TokenManager(sinch_client_sync), + service_plan_id="test_service_plan", + sms_api_token="test_sms_token", + sms_region="us" + ) + + expected_origin = client_configuration.sms_origin_with_service_plan_id + actual_origin = client_configuration.get_sms_origin_for_auth() + + assert actual_origin == expected_origin + assert actual_origin == "https://us.sms.api.sinch.com" + + +def test_configuration_expects_get_sms_origin_for_auth_project_authentication(sinch_client_sync): + """ Test that project authentication returns regular sms_origin """ + client_configuration = Configuration( + transport=HTTPTransportRequests(sinch_client_sync), + token_manager=TokenManager(sinch_client_sync), + project_id="test_project_id", + sms_region="eu" + ) + + expected_origin = client_configuration.sms_origin + actual_origin = client_configuration.get_sms_origin_for_auth() + + assert actual_origin == expected_origin + assert actual_origin == "https://zt.eu.sms.api.sinch.com" + + +def test_configuration_expects_get_conversation_origin_with_region(sinch_client_sync): + """ Test that get_conversation_origin returns the correct origin when region is set """ + client_configuration = Configuration( + transport=HTTPTransportRequests(sinch_client_sync), + token_manager=TokenManager(sinch_client_sync), + project_id="test_project_id", + conversation_region="us" + ) + + expected_origin = client_configuration.conversation_origin + actual_origin = client_configuration.get_conversation_origin() + + assert actual_origin == expected_origin + assert actual_origin == "https://us.conversation.api.sinch.com" diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py index f0a01ec4..58204bbd 100644 --- a/tests/unit/test_exceptions.py +++ b/tests/unit/test_exceptions.py @@ -1,5 +1,5 @@ from sinch.core.exceptions import ValidationException -from sinch.domains.numbers.exceptions import NumbersException +from sinch.domains.numbers.api.v1.exceptions import NumbersException from sinch.domains.conversation.exceptions import ConversationException from sinch.domains.sms.exceptions import SMSException from sinch.domains.authentication.exceptions import AuthenticationException diff --git a/tests/unit/test_pagination.py b/tests/unit/test_pagination.py index 1b126c5a..2dfa938b 100644 --- a/tests/unit/test_pagination.py +++ b/tests/unit/test_pagination.py @@ -1,249 +1,142 @@ -from unittest.mock import Mock, AsyncMock - +from unittest.mock import Mock +import pytest from sinch.core.pagination import ( - IntBasedPaginator, - AsyncIntBasedPaginator, - TokenBasedPaginator, - AsyncTokenBasedPaginator + SMSPaginator, + TokenBasedPaginator ) -def test_page_int_iterator_sync_using_manual_pagination( - first_int_based_pagination_response, - second_int_based_pagination_response, - third_int_based_pagination_response, - int_based_pagination_request_data -): - endpoint = Mock() - endpoint.request_data = int_based_pagination_request_data - sinch_client = Mock() - - sinch_client.configuration.transport.request.side_effect = [ - first_int_based_pagination_response, - second_int_based_pagination_response, - third_int_based_pagination_response - ] - int_based_paginator = IntBasedPaginator._initialize( - sinch=sinch_client, - endpoint=endpoint - ) - assert int_based_paginator - - page_counter = 0 - assert int_based_paginator.result.page == page_counter - - while int_based_paginator.has_next_page: - int_based_paginator = int_based_paginator.next_page() - page_counter += 1 - assert int_based_paginator.result.page == page_counter - - assert page_counter == 2 - - -def test_page_int_iterator_sync_using_auto_pagination( - first_int_based_pagination_response, - second_int_based_pagination_response, - third_int_based_pagination_response, - int_based_pagination_request_data +# Helper function to initialize SMS paginator +def initialize_sms_paginator(endpoint_mock, request_data, responses): + client = Mock() + + # Create a mock that returns different responses based on page number + def mock_request(endpoint): + page = endpoint.request_data.page + if page == 0: + return responses[0] + elif page == 1: + return responses[1] + else: + return responses[2] + + client.configuration.transport.request.side_effect = mock_request + endpoint_mock.request_data = request_data + + return SMSPaginator(sinch=client, endpoint=endpoint_mock) + + +def test_page_sms_iterator_sync_using_manual_pagination( + sms_pagination_request_data, + mock_sms_pagination_responses, + mock_int_pagination_expected_delivery_reports ): - endpoint = Mock() - endpoint.request_data = int_based_pagination_request_data - sinch_client = Mock() - - sinch_client.configuration.transport.request.side_effect = [ - first_int_based_pagination_response, - second_int_based_pagination_response, - third_int_based_pagination_response - ] - - int_based_paginator = IntBasedPaginator._initialize( - sinch=sinch_client, - endpoint=endpoint + """Test that the pagination iterates correctly through multiple items.""" + sms_paginator = initialize_sms_paginator( + endpoint_mock=Mock(), + request_data=sms_pagination_request_data, + responses=mock_sms_pagination_responses ) - assert int_based_paginator + assert sms_paginator is not None page_counter = 0 - assert int_based_paginator.result.page == page_counter - - for page in int_based_paginator.auto_paging_iter(): - page_counter += 1 - assert page.result.page == page_counter - assert isinstance(page, IntBasedPaginator) + assert sms_paginator.result.page == page_counter + + delivery_reports_list = [] + reached_last_page = False + while not reached_last_page: + delivery_reports_list.extend([report.batch_id for report in sms_paginator.content()]) + if sms_paginator.has_next_page: + sms_paginator = sms_paginator.next_page() + page_counter += 1 + assert isinstance(sms_paginator, SMSPaginator) + else: + reached_last_page = True - assert page_counter == 2 + assert page_counter == 1 + assert delivery_reports_list == mock_int_pagination_expected_delivery_reports -async def test_page_int_iterator_async_using_manual_pagination( - first_int_based_pagination_response, - second_int_based_pagination_response, - third_int_based_pagination_response, - int_based_pagination_request_data +def test_page_sms_iterator_sync_using_auto_pagination( + sms_pagination_request_data, + mock_sms_pagination_responses, + mock_int_pagination_expected_delivery_reports ): - endpoint = Mock() - endpoint.request_data = int_based_pagination_request_data - sinch_client = AsyncMock() - - sinch_client.configuration.transport.request.side_effect = [ - first_int_based_pagination_response, - second_int_based_pagination_response, - third_int_based_pagination_response - ] - int_based_paginator = await AsyncIntBasedPaginator._initialize( - sinch=sinch_client, - endpoint=endpoint + """Test that the pagination iterates correctly through multiple items.""" + sms_paginator = initialize_sms_paginator( + endpoint_mock=Mock(), + request_data=sms_pagination_request_data, + responses=mock_sms_pagination_responses ) - assert int_based_paginator + assert sms_paginator is not None page_counter = 0 - assert int_based_paginator.result.page == page_counter - - while int_based_paginator.has_next_page: - int_based_paginator = await int_based_paginator.next_page() - page_counter += 1 - assert int_based_paginator.result.page == page_counter - - assert page_counter == 2 + assert sms_paginator.result.page == page_counter + all_delivery_reports = [] + for delivery_report in sms_paginator.iterator(): + all_delivery_reports.append(delivery_report.batch_id) + + # Should have 4 delivery reports total (2 from page 0, 2 from page 1, 0 from page 2) + assert len(all_delivery_reports) == 4 + assert all_delivery_reports == mock_int_pagination_expected_delivery_reports -async def test_page_int_iterator_async_using_auto_pagination( - first_int_based_pagination_response, - second_int_based_pagination_response, - third_int_based_pagination_response, - int_based_pagination_request_data -): - endpoint = Mock() - endpoint.request_data = int_based_pagination_request_data - sinch_client = AsyncMock() - - sinch_client.configuration.transport.request.side_effect = [ - first_int_based_pagination_response, - second_int_based_pagination_response, - third_int_based_pagination_response - ] - - int_based_paginator = await AsyncIntBasedPaginator._initialize( - sinch=sinch_client, - endpoint=endpoint - ) - assert int_based_paginator - page_counter = 0 - assert int_based_paginator.result.page == page_counter +# Helper function to initialize token paginator +def initialize_token_paginator(endpoint_mock, request_data, responses): + client = Mock() + client.configuration.transport.request.side_effect = responses - page_counter = 0 - async for page in int_based_paginator.auto_paging_iter(): - page_counter += 1 - assert isinstance(page, AsyncIntBasedPaginator) + endpoint_mock.request_data = request_data - assert page_counter == 2 - assert not int_based_paginator.result.pig_dogs + return TokenBasedPaginator(sinch=client, endpoint=endpoint_mock) def test_page_token_iterator_sync_using_manual_pagination( token_based_pagination_request_data, - first_token_based_pagination_response, - second_token_based_pagination_response + mock_pagination_active_number_responses, + mock_pagination_expected_phone_numbers_response ): - endpoint = Mock() - endpoint.request_data = token_based_pagination_request_data - sinch_client = Mock() - - sinch_client.configuration.transport.request.side_effect = [ - first_token_based_pagination_response, - second_token_based_pagination_response - ] - token_based_paginator = TokenBasedPaginator._initialize( - sinch=sinch_client, - endpoint=endpoint + """ Test that the pagination iterates correctly through multiple items. """ + token_based_paginator = initialize_token_paginator( + endpoint_mock=Mock(), + request_data=token_based_pagination_request_data, + responses=mock_pagination_active_number_responses ) - assert token_based_paginator + assert token_based_paginator is not None - page_counter = 0 - while token_based_paginator.has_next_page: - token_based_paginator = token_based_paginator.next_page() - page_counter += 1 - assert isinstance(token_based_paginator, TokenBasedPaginator) + page_counter = 1 + active_numbers_list = [] + reached_last_page = False + while not reached_last_page: + active_numbers_list.extend([num.phone_number for num in token_based_paginator.content()]) + if token_based_paginator.has_next_page: + token_based_paginator = token_based_paginator.next_page() + page_counter += 1 + assert isinstance(token_based_paginator, TokenBasedPaginator) + else: + reached_last_page = True - assert page_counter == 1 + assert page_counter == 3 + assert active_numbers_list == mock_pagination_expected_phone_numbers_response -def test_page_token_iterator_sync_using_auto_pagination( +def test_page_token_iterator_sync_using_auto_pagination_expects_iter( token_based_pagination_request_data, - first_token_based_pagination_response, - second_token_based_pagination_response + mock_pagination_active_number_responses, + mock_pagination_expected_phone_numbers_response ): - endpoint = Mock() - endpoint.request_data = token_based_pagination_request_data - sinch_client = Mock() - - sinch_client.configuration.transport.request.side_effect = [ - first_token_based_pagination_response, - second_token_based_pagination_response - ] - token_based_paginator = TokenBasedPaginator._initialize( - sinch=sinch_client, - endpoint=endpoint + """Test that the pagination iterates correctly through multiple items.""" + token_based_paginator = initialize_token_paginator( + endpoint_mock=Mock(), + request_data=token_based_pagination_request_data, + responses=mock_pagination_active_number_responses ) - assert token_based_paginator + assert token_based_paginator is not None - page_counter = 0 - for page in token_based_paginator.auto_paging_iter(): - page_counter += 1 - assert isinstance(page, TokenBasedPaginator) + active_numbers_list = [] + for number in token_based_paginator.iterator(): + active_numbers_list.append(number.phone_number) - assert page_counter == 1 - - -async def test_page_token_iterator_async_using_manual_pagination( - token_based_pagination_request_data, - first_token_based_pagination_response, - second_token_based_pagination_response -): - endpoint = Mock() - endpoint.request_data = token_based_pagination_request_data - sinch_client = AsyncMock() - - sinch_client.configuration.transport.request.side_effect = [ - first_token_based_pagination_response, - second_token_based_pagination_response - ] - token_based_paginator = await AsyncTokenBasedPaginator._initialize( - sinch=sinch_client, - endpoint=endpoint - ) - assert token_based_paginator - - page_counter = 0 - while token_based_paginator.has_next_page: - token_based_paginator = await token_based_paginator.next_page() - page_counter += 1 - assert isinstance(token_based_paginator, AsyncTokenBasedPaginator) - - assert page_counter == 1 - - -async def test_page_token_iterator_async_using_auto_pagination( - token_based_pagination_request_data, - first_token_based_pagination_response, - second_token_based_pagination_response -): - endpoint = Mock() - endpoint.request_data = token_based_pagination_request_data - sinch_client = AsyncMock() - - sinch_client.configuration.transport.request.side_effect = [ - first_token_based_pagination_response, - second_token_based_pagination_response - ] - token_based_paginator = await AsyncTokenBasedPaginator._initialize( - sinch=sinch_client, - endpoint=endpoint - ) - assert token_based_paginator - - page_counter = 0 - async for page in token_based_paginator.auto_paging_iter(): - page_counter += 1 - assert isinstance(page, AsyncTokenBasedPaginator) - - assert page_counter == 1 + assert len(active_numbers_list) == len(mock_pagination_expected_phone_numbers_response) + assert active_numbers_list == mock_pagination_expected_phone_numbers_response diff --git a/tests/unit/test_token_manager.py b/tests/unit/test_token_manager.py index 4bd316b5..502d5ad1 100644 --- a/tests/unit/test_token_manager.py +++ b/tests/unit/test_token_manager.py @@ -1,8 +1,8 @@ import pytest -from unittest.mock import Mock, AsyncMock +from unittest.mock import Mock -from sinch.core.token_manager import TokenManager, TokenManagerAsync -from sinch.domains.authentication.models.authentication import OAuthToken +from sinch.core.token_manager import TokenManager +from sinch.domains.authentication.models.v1.authentication import OAuthToken from sinch.core.exceptions import ValidationException @@ -30,13 +30,3 @@ def test_get_auth_token_and_check_if_cached(sinch_client_sync, auth_token): assert isinstance(access_token, OAuthToken) assert token_manager.token is auth_token - - -async def test_get_auth_token_and_check_if_cached_async(sinch_client_async, auth_token): - sinch_client_async = AsyncMock() - sinch_client_async.configuration.transport.request.return_value = auth_token - token_manager = TokenManagerAsync(sinch_client_async) - access_token = await token_manager.get_auth_token() - - assert isinstance(access_token, OAuthToken) - assert token_manager.token is auth_token diff --git a/tests/unit/test_user_agent_header.py b/tests/unit/test_user_agent_header.py index 159d1c17..243ac882 100644 --- a/tests/unit/test_user_agent_header.py +++ b/tests/unit/test_user_agent_header.py @@ -1,10 +1,59 @@ -from sinch.domains.conversation.endpoints.app.delete_app import DeleteConversationAppEndpoint -from sinch.domains.conversation.models.app.requests import DeleteConversationAppRequest +from platform import python_version +from sinch import __version__ as sdk_version +from sinch.core.endpoint import HTTPEndpoint +from sinch.core.enums import HTTPAuthentication, HTTPMethods +from sinch.core.models.http_response import HTTPResponse -def test_user_agent_header_creation(sinch_client_sync): - endpoint = DeleteConversationAppRequest(app_id="42") - http_endpoint = DeleteConversationAppEndpoint(sinch_client_sync, endpoint) - http_request = sinch_client_sync.configuration.transport.prepare_request(http_endpoint) +class DummyEndpoint(HTTPEndpoint): + """Dummy endpoint for testing core functionality""" + + ENDPOINT_URL = "https://capy.sinch.com/v1/test" + + @property + def HTTP_METHOD(self) -> str: + return HTTPMethods.GET.value + + @property + def HTTP_AUTHENTICATION(self) -> str: + return HTTPAuthentication.OAUTH.value + + def build_url(self, sinch): + return self.ENDPOINT_URL + + def build_query_params(self): + return {} + + def request_body(self): + return "" + + def handle_response(self, response: HTTPResponse): + return response + + +def test_user_agent_header_creation_expects_to_be_included(sinch_client_sync): + """ + Test that User-Agent header is created with the correct format. + + Expected format: sinch-sdk/{sdk_version} (Python/{python_version}; {implementation_type}; {auxiliary_flag}) + Note: auxiliary_flag is currently always empty in the implementation. + """ + endpoint = DummyEndpoint("dummy_project_id", {}) + http_request = sinch_client_sync.configuration.transport.prepare_request(endpoint) + assert "User-Agent" in http_request.headers + user_agent = http_request.headers["User-Agent"] + transport_class_name = sinch_client_sync.configuration.transport.__class__.__name__ + + # Parse the User-Agent string + prefix, info_section = user_agent.split(" (", 1) + info_section = info_section.rstrip(")") + components = [c.strip() for c in info_section.split(";")] + + # Validate structure + assert prefix == f"sinch-sdk/{sdk_version}", f"Expected prefix 'sinch-sdk/{sdk_version}', got '{prefix}'" + assert len(components) == 3, f"Expected 3 components, got {len(components)}: {components}" + assert components[0] == f"Python/{python_version()}", f"Expected 'Python/{python_version()}', got '{components[0]}'" + assert components[1] == transport_class_name, f"Expected '{transport_class_name}', got '{components[1]}'" + assert components[2] == "", f"Auxiliary flag should be empty (not implemented yet), got '{components[2]}'"