sendly 4.3.0__tar.gz → 4.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. {sendly-4.3.0 → sendly-4.4.0}/CHANGELOG.md +10 -0
  2. {sendly-4.3.0 → sendly-4.4.0}/PKG-INFO +5 -5
  3. {sendly-4.3.0 → sendly-4.4.0}/README.md +4 -4
  4. {sendly-4.3.0 → sendly-4.4.0}/pyproject.toml +1 -1
  5. {sendly-4.3.0 → sendly-4.4.0}/sendly/__init__.py +3 -1
  6. {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/media.py +5 -4
  7. {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/messages.py +10 -2
  8. {sendly-4.3.0 → sendly-4.4.0}/sendly/types.py +83 -3
  9. {sendly-4.3.0 → sendly-4.4.0}/sendly/utils/http.py +1 -1
  10. {sendly-4.3.0 → sendly-4.4.0}/tests/test_media.py +30 -7
  11. {sendly-4.3.0 → sendly-4.4.0}/tests/test_whatsapp.py +173 -0
  12. {sendly-4.3.0 → sendly-4.4.0}/.github/header.svg +0 -0
  13. {sendly-4.3.0 → sendly-4.4.0}/.gitignore +0 -0
  14. {sendly-4.3.0 → sendly-4.4.0}/examples/async_example.py +0 -0
  15. {sendly-4.3.0 → sendly-4.4.0}/examples/error_handling.py +0 -0
  16. {sendly-4.3.0 → sendly-4.4.0}/examples/send_sms.py +0 -0
  17. {sendly-4.3.0 → sendly-4.4.0}/sendly/client.py +0 -0
  18. {sendly-4.3.0 → sendly-4.4.0}/sendly/errors.py +0 -0
  19. {sendly-4.3.0 → sendly-4.4.0}/sendly/py.typed +0 -0
  20. {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/__init__.py +0 -0
  21. {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/account.py +0 -0
  22. {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/business_upgrade.py +0 -0
  23. {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/calls.py +0 -0
  24. {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/campaigns.py +0 -0
  25. {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/contacts.py +0 -0
  26. {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/conversations.py +0 -0
  27. {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/drafts.py +0 -0
  28. {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/enterprise.py +0 -0
  29. {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/labels.py +0 -0
  30. {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/links.py +0 -0
  31. {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/numbers.py +0 -0
  32. {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/rcs.py +0 -0
  33. {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/rules.py +0 -0
  34. {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/templates.py +0 -0
  35. {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/tendlc.py +0 -0
  36. {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/verify.py +0 -0
  37. {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/voice.py +0 -0
  38. {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/webhooks.py +0 -0
  39. {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/whatsapp.py +0 -0
  40. {sendly-4.3.0 → sendly-4.4.0}/sendly/utils/__init__.py +0 -0
  41. {sendly-4.3.0 → sendly-4.4.0}/sendly/utils/validation.py +0 -0
  42. {sendly-4.3.0 → sendly-4.4.0}/sendly/webhooks.py +0 -0
  43. {sendly-4.3.0 → sendly-4.4.0}/tests/conftest.py +0 -0
  44. {sendly-4.3.0 → sendly-4.4.0}/tests/test_account.py +0 -0
  45. {sendly-4.3.0 → sendly-4.4.0}/tests/test_async_client.py +0 -0
  46. {sendly-4.3.0 → sendly-4.4.0}/tests/test_calls.py +0 -0
  47. {sendly-4.3.0 → sendly-4.4.0}/tests/test_campaigns.py +0 -0
  48. {sendly-4.3.0 → sendly-4.4.0}/tests/test_client.py +0 -0
  49. {sendly-4.3.0 → sendly-4.4.0}/tests/test_contacts.py +0 -0
  50. {sendly-4.3.0 → sendly-4.4.0}/tests/test_conversations.py +0 -0
  51. {sendly-4.3.0 → sendly-4.4.0}/tests/test_enterprise.py +0 -0
  52. {sendly-4.3.0 → sendly-4.4.0}/tests/test_errors.py +0 -0
  53. {sendly-4.3.0 → sendly-4.4.0}/tests/test_http_body_kwarg.py +0 -0
  54. {sendly-4.3.0 → sendly-4.4.0}/tests/test_http_retry.py +0 -0
  55. {sendly-4.3.0 → sendly-4.4.0}/tests/test_idempotency.py +0 -0
  56. {sendly-4.3.0 → sendly-4.4.0}/tests/test_messages.py +0 -0
  57. {sendly-4.3.0 → sendly-4.4.0}/tests/test_messages.py.bak +0 -0
  58. {sendly-4.3.0 → sendly-4.4.0}/tests/test_messages_batch.py +0 -0
  59. {sendly-4.3.0 → sendly-4.4.0}/tests/test_messages_schedule.py +0 -0
  60. {sendly-4.3.0 → sendly-4.4.0}/tests/test_messages_wire.py +0 -0
  61. {sendly-4.3.0 → sendly-4.4.0}/tests/test_numbers.py +0 -0
  62. {sendly-4.3.0 → sendly-4.4.0}/tests/test_path_ids.py +0 -0
  63. {sendly-4.3.0 → sendly-4.4.0}/tests/test_rcs.py +0 -0
  64. {sendly-4.3.0 → sendly-4.4.0}/tests/test_rcs_registration.py +0 -0
  65. {sendly-4.3.0 → sendly-4.4.0}/tests/test_rules.py +0 -0
  66. {sendly-4.3.0 → sendly-4.4.0}/tests/test_templates.py +0 -0
  67. {sendly-4.3.0 → sendly-4.4.0}/tests/test_tendlc.py +0 -0
  68. {sendly-4.3.0 → sendly-4.4.0}/tests/test_validation.py +0 -0
  69. {sendly-4.3.0 → sendly-4.4.0}/tests/test_voice.py +0 -0
  70. {sendly-4.3.0 → sendly-4.4.0}/tests/test_webhooks.py +0 -0
  71. {sendly-4.3.0 → sendly-4.4.0}/tests/test_webhooks_resource.py +0 -0
  72. {sendly-4.3.0 → sendly-4.4.0}/tests/test_whatsapp_extras.py +0 -0
@@ -1,5 +1,15 @@
1
1
  # sendly (Python)
2
2
 
3
+ ## 4.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - **WhatsApp to SMS fallback.** `messages.send(channel="whatsapp", fallback_to_sms=True)` (sync and async) sends `fallbackToSms`. When WhatsApp can't deliver the message, it goes as an SMS from the same number, billed as an SMS. The new `WhatsAppSmsFallback` (`channel`, `status`, `reason`, `message_id`, `credits_used`, `error`, `content_dropped`) is `WhatsAppMessage.fallback`; when the SMS went in place of the WhatsApp message, `status` is `failed` and `fallback.status` is `sent`. `Message` (from `messages.get` and `list`) gains `fallback`, `fallback_of` (on the SMS, the WhatsApp message it replaced), `message_format`, `batch_id` and `media_urls`.
8
+
9
+ ### Patch Changes
10
+
11
+ - A WhatsApp send that WhatsApp accepted but Sendly couldn't fully record answers 201 without `whatsapp` or `createdAt`, and `messages.send` raised `invalid_response` on it. It now returns the message, with `whatsapp` and `created_at` set to `None`. That message is in flight and was charged, so don't send it again.
12
+
3
13
  ## 4.3.0
4
14
 
5
15
  ### Minor Changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sendly
3
- Version: 4.3.0
3
+ Version: 4.4.0
4
4
  Summary: Official Sendly Python SDK for SMS messaging
5
5
  Project-URL: Homepage, https://sendly.live
6
6
  Project-URL: Documentation, https://sendly.live/docs
@@ -1072,9 +1072,10 @@ client.messages.send(
1072
1072
 
1073
1073
  Uploads accept JPEG, PNG and GIF up to 600 KB. When `content_type` is one of
1074
1074
  those types but the content is not, the upload raises `SendlyError` with code
1075
- `invalid_file`; any other `content_type`, or a file over 600 KB, raises
1076
- `internal_error` (HTTP 500) with the reason in the message. Without MMS enabled
1077
- for your account it is `feature_disabled`.
1075
+ `invalid_file`; any other `content_type` raises `unsupported_media_type`
1076
+ (HTTP 415) with the reason in the message, and a file over 600 KB raises
1077
+ `file_too_large` (HTTP 413). Without MMS enabled for your account it is
1078
+ `feature_disabled`.
1078
1079
 
1079
1080
  ## Account & Credits
1080
1081
 
@@ -2592,7 +2593,6 @@ client.enterprise.workspaces.revoke_key("ws_xxx", "key_abc")
2592
2593
 
2593
2594
  # Pooled credits and auto top-up
2594
2595
  client.enterprise.credits.get()
2595
- client.enterprise.credits.deposit(10000, description="Q2 top-up")
2596
2596
  client.enterprise.settings.update_auto_top_up(
2597
2597
  enabled=True, threshold=1000, amount=5000, source_workspace_id="ws_source"
2598
2598
  )
@@ -1037,9 +1037,10 @@ client.messages.send(
1037
1037
 
1038
1038
  Uploads accept JPEG, PNG and GIF up to 600 KB. When `content_type` is one of
1039
1039
  those types but the content is not, the upload raises `SendlyError` with code
1040
- `invalid_file`; any other `content_type`, or a file over 600 KB, raises
1041
- `internal_error` (HTTP 500) with the reason in the message. Without MMS enabled
1042
- for your account it is `feature_disabled`.
1040
+ `invalid_file`; any other `content_type` raises `unsupported_media_type`
1041
+ (HTTP 415) with the reason in the message, and a file over 600 KB raises
1042
+ `file_too_large` (HTTP 413). Without MMS enabled for your account it is
1043
+ `feature_disabled`.
1043
1044
 
1044
1045
  ## Account & Credits
1045
1046
 
@@ -2557,7 +2558,6 @@ client.enterprise.workspaces.revoke_key("ws_xxx", "key_abc")
2557
2558
 
2558
2559
  # Pooled credits and auto top-up
2559
2560
  client.enterprise.credits.get()
2560
- client.enterprise.credits.deposit(10000, description="Q2 top-up")
2561
2561
  client.enterprise.settings.update_auto_top_up(
2562
2562
  enabled=True, threshold=1000, amount=5000, source_workspace_id="ws_source"
2563
2563
  )
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sendly"
7
- version = "4.3.0"
7
+ version = "4.4.0"
8
8
  description = "Official Sendly Python SDK for SMS messaging"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -29,7 +29,7 @@ Async Example:
29
29
  >>> asyncio.run(main())
30
30
  """
31
31
 
32
- __version__ = "4.3.0"
32
+ __version__ = "4.4.0"
33
33
 
34
34
  # Main clients
35
35
  from .client import AsyncSendly, Sendly
@@ -160,6 +160,7 @@ from .types import (
160
160
  TenDlcThroughput,
161
161
  # WhatsApp types
162
162
  WhatsAppMessage,
163
+ WhatsAppSmsFallback,
163
164
  WhatsAppMessageDetails,
164
165
  WhatsAppMessageTemplate,
165
166
  WhatsAppSender,
@@ -409,6 +410,7 @@ __all__ = [
409
410
  "TenDlcThroughput",
410
411
  # WhatsApp types
411
412
  "WhatsAppMessage",
413
+ "WhatsAppSmsFallback",
412
414
  "WhatsAppMessageDetails",
413
415
  "WhatsAppMessageTemplate",
414
416
  "WhatsAppSender",
@@ -49,10 +49,11 @@ class MediaResource:
49
49
  ValidationError: If no file reached the API (code ``invalid_request``)
50
50
  SendlyError: With code ``invalid_file`` when content_type is
51
51
  image/jpeg, image/png or image/gif but the content is not a
52
- JPEG, PNG or GIF; ``internal_error`` (HTTP 500) when
53
- content_type is any other type or the file is over 600 KB,
54
- with the reason in the message; or ``feature_disabled`` when
55
- MMS is not enabled for your account
52
+ JPEG, PNG or GIF; ``unsupported_media_type`` (HTTP 415) when
53
+ content_type is any other type, with the reason in the
54
+ message; ``file_too_large`` (HTTP 413) when the file is over
55
+ 600 KB; or ``feature_disabled`` when MMS is not enabled for
56
+ your account
56
57
  AuthenticationError: If the API key is invalid
57
58
  RateLimitError: If rate limit is exceeded
58
59
 
@@ -126,9 +126,13 @@ class MessagesResource:
126
126
  'url': 'https://example.com/track'}}]. Card buttons go in
127
127
  card['suggestions'] instead. Dropped (and disclosed via
128
128
  message.rcs.suggestions_dropped) when the send falls back
129
- fallback_to_sms: RCS only - deliver as SMS when the recipient
129
+ fallback_to_sms: RCS - deliver as SMS when the recipient
130
130
  doesn't support RCS (default True). Pass False to get a 422
131
- rcs_not_supported_for_recipient instead
131
+ rcs_not_supported_for_recipient instead. WhatsApp - default
132
+ False; True sends an SMS from the same number when WhatsApp
133
+ can't deliver the message (see message.fallback). The number
134
+ must be able to send SMS to the recipient, or the API answers
135
+ 403 sms_fallback_unavailable
132
136
  idempotency_key: Optional key (1-255 printable ASCII characters)
133
137
  sent as the Idempotency-Key header - retrying with the same
134
138
  key returns the original result instead of sending again.
@@ -242,6 +246,8 @@ class MessagesResource:
242
246
  body["template"] = template
243
247
  if metadata:
244
248
  body["metadata"] = metadata
249
+ if fallback_to_sms is not None:
250
+ body["fallbackToSms"] = fallback_to_sms
245
251
 
246
252
  data = self._http.request(
247
253
  method="POST",
@@ -1116,6 +1122,8 @@ class AsyncMessagesResource:
1116
1122
  body["template"] = template
1117
1123
  if metadata:
1118
1124
  body["metadata"] = metadata
1125
+ if fallback_to_sms is not None:
1126
+ body["fallbackToSms"] = fallback_to_sms
1119
1127
 
1120
1128
  data = await self._http.request(
1121
1129
  method="POST",
@@ -280,6 +280,28 @@ class Message(BaseModel):
280
280
  ai_metadata: Optional[Dict[str, Any]] = Field(
281
281
  default=None, alias="aiMetadata", description="AI classification metadata for inbound messages"
282
282
  )
283
+ message_format: Optional[str] = Field(
284
+ default=None,
285
+ description="The channel and format the message was sent in: sms, mms, whatsapp or rcs",
286
+ )
287
+ batch_id: Optional[str] = Field(
288
+ default=None, description="The batch that sent the message, for a batch message"
289
+ )
290
+ media_urls: Optional[List[str]] = Field(
291
+ default=None, description="The message's media URLs, when it has media"
292
+ )
293
+ fallback: Optional["WhatsAppSmsFallback"] = Field(
294
+ default=None,
295
+ description=(
296
+ "On a WhatsApp message sent with fallback_to_sms: what happened to "
297
+ "its SMS fallback"
298
+ ),
299
+ )
300
+ fallback_of: Optional[str] = Field(
301
+ default=None,
302
+ alias="fallbackOf",
303
+ description="On an SMS fallback: the id of the WhatsApp message it replaced",
304
+ )
283
305
 
284
306
  model_config = ConfigDict(populate_by_name=True)
285
307
 
@@ -925,6 +947,11 @@ class WebhookEventType(str, Enum):
925
947
  SHORT_CODE_REJECTED = "short_code.rejected"
926
948
  SHORT_CODE_FILED = "short_code.filed"
927
949
  SHORT_CODE_LIVE = "short_code.live"
950
+ SHORT_CODE_SUSPENDED = "short_code.suspended"
951
+ SHORT_CODE_REACTIVATED = "short_code.reactivated"
952
+ SHORT_CODE_CANCELLED = "short_code.cancelled"
953
+ SHORT_CODE_PAYMENT_SUCCEEDED = "short_code.payment_succeeded"
954
+ SHORT_CODE_PAYMENT_FAILED = "short_code.payment_failed"
928
955
 
929
956
 
930
957
  class ListHealthEventSource(str, Enum):
@@ -3202,6 +3229,42 @@ class WhatsAppMessageDetails(BaseModel):
3202
3229
  model_config = ConfigDict(populate_by_name=True)
3203
3230
 
3204
3231
 
3232
+ class WhatsAppSmsFallback(BaseModel):
3233
+ """The SMS fallback of a WhatsApp message sent with fallback_to_sms=True"""
3234
+
3235
+ channel: str = Field(default="sms", description='Always "sms"')
3236
+ status: str = Field(
3237
+ ...,
3238
+ description=(
3239
+ "pending until WhatsApp's outcome is known; sent when the SMS went; "
3240
+ "failed when the SMS was refused or credits ran out; unavailable "
3241
+ "when the SMS could no longer be sent from this number; skipped "
3242
+ "when the WhatsApp failure isn't one an SMS fixes"
3243
+ ),
3244
+ )
3245
+ reason: Optional[str] = Field(
3246
+ default=None,
3247
+ description="What made it fall back: window_closed, whatsapp_refused or whatsapp_failed",
3248
+ )
3249
+ message_id: Optional[str] = Field(
3250
+ default=None, alias="messageId", description="The SMS message's id, once it exists"
3251
+ )
3252
+ credits_used: Optional[int] = Field(
3253
+ default=None, alias="creditsUsed", description="Credits charged for the SMS"
3254
+ )
3255
+ error: Optional[str] = Field(default=None, description="Why the SMS didn't go")
3256
+ content_dropped: Optional[List[str]] = Field(
3257
+ default=None,
3258
+ alias="contentDropped",
3259
+ description="Template parts with no SMS form, such as quick_replies",
3260
+ )
3261
+
3262
+ model_config = ConfigDict(populate_by_name=True)
3263
+
3264
+
3265
+ Message.model_rebuild()
3266
+
3267
+
3205
3268
  class WhatsAppMessage(BaseModel):
3206
3269
  """A sent WhatsApp message"""
3207
3270
 
@@ -3236,13 +3299,30 @@ class WhatsAppMessage(BaseModel):
3236
3299
  "failed send gives its slot back"
3237
3300
  ),
3238
3301
  )
3239
- whatsapp: WhatsAppMessageDetails = Field(..., description="WhatsApp-specific details")
3240
- created_at: str = Field(
3241
- ..., alias="createdAt", description="When the message was created (ISO 8601)"
3302
+ whatsapp: Optional[WhatsAppMessageDetails] = Field(
3303
+ default=None,
3304
+ description=(
3305
+ "WhatsApp-specific details. None when WhatsApp accepted the message "
3306
+ "but recording it failed: the message is in flight and was charged, "
3307
+ "so don't send it again"
3308
+ ),
3309
+ )
3310
+ created_at: Optional[str] = Field(
3311
+ default=None,
3312
+ alias="createdAt",
3313
+ description="When the message was created (ISO 8601). None in the same case as whatsapp",
3242
3314
  )
3243
3315
  metadata: Optional[Dict[str, Any]] = Field(
3244
3316
  default=None, description="Custom JSON metadata attached to the message"
3245
3317
  )
3318
+ fallback: Optional[WhatsAppSmsFallback] = Field(
3319
+ default=None,
3320
+ description=(
3321
+ "Present when the send asked for fallback_to_sms. When WhatsApp "
3322
+ "couldn't take the message and it went as an SMS, status is "
3323
+ "failed and fallback.status is sent"
3324
+ ),
3325
+ )
3246
3326
 
3247
3327
  model_config = ConfigDict(populate_by_name=True)
3248
3328
 
@@ -28,7 +28,7 @@ T = TypeVar("T")
28
28
  DEFAULT_BASE_URL = "https://sendly.live/api/v1"
29
29
  DEFAULT_TIMEOUT = 30.0
30
30
  DEFAULT_MAX_RETRIES = 3
31
- SDK_VERSION = "4.3.0"
31
+ SDK_VERSION = "4.4.0"
32
32
 
33
33
 
34
34
  _WAIT_AND_RETRY_CODES = (
@@ -62,26 +62,48 @@ class TestMediaUpload:
62
62
 
63
63
 
64
64
  class TestUploadRejections:
65
- def test_a_type_the_route_filters_out_raises_internal_error(
65
+ def test_a_type_the_route_filters_out_raises_unsupported_media_type(
66
66
  self, api_key, httpx_mock: HTTPXMock
67
67
  ):
68
68
  client = Sendly(api_key)
69
69
  httpx_mock.add_response(
70
70
  url=f"{BASE}/media",
71
71
  method="POST",
72
- status_code=500,
73
- json={"message": "Only JPEG, PNG, and GIF images are allowed for MMS"},
72
+ status_code=415,
73
+ json={
74
+ "error": "unsupported_media_type",
75
+ "message": "Only JPEG, PNG, and GIF images are allowed for MMS",
76
+ },
74
77
  )
75
78
 
76
79
  with pytest.raises(SendlyError) as exc_info:
77
80
  client.media.upload(io.BytesIO(b"RIFF0000WEBP"), content_type="image/webp")
78
81
 
79
- assert exc_info.value.code == "internal_error"
80
- assert exc_info.value.status_code == 500
82
+ assert exc_info.value.code == "unsupported_media_type"
83
+ assert exc_info.value.status_code == 415
81
84
  assert exc_info.value.message == "Only JPEG, PNG, and GIF images are allowed for MMS"
82
85
  assert len(httpx_mock.get_requests()) == 1
83
86
  client.close()
84
87
 
88
+ def test_a_file_over_the_limit_raises_file_too_large(
89
+ self, api_key, httpx_mock: HTTPXMock
90
+ ):
91
+ client = Sendly(api_key)
92
+ httpx_mock.add_response(
93
+ url=f"{BASE}/media",
94
+ method="POST",
95
+ status_code=413,
96
+ json={"error": "file_too_large", "message": 'The file in field "file" is too large.'},
97
+ )
98
+
99
+ with pytest.raises(SendlyError) as exc_info:
100
+ client.media.upload(io.BytesIO(b"\xff" * 16), content_type="image/jpeg")
101
+
102
+ assert exc_info.value.code == "file_too_large"
103
+ assert exc_info.value.status_code == 413
104
+ assert len(httpx_mock.get_requests()) == 1
105
+ client.close()
106
+
85
107
  def test_content_that_is_not_the_declared_image_raises_invalid_file(
86
108
  self, api_key, httpx_mock: HTTPXMock
87
109
  ):
@@ -102,9 +124,10 @@ class TestUploadRejections:
102
124
  assert exc_info.value.code == "invalid_file"
103
125
  client.close()
104
126
 
105
- def test_docs_name_both_rejections(self):
127
+ def test_docs_name_every_rejection(self):
106
128
  doc = MediaResource.upload.__doc__ or ""
107
129
 
108
- assert "``internal_error`` (HTTP 500)" in doc
130
+ assert "``unsupported_media_type`` (HTTP 415)" in doc
131
+ assert "``file_too_large`` (HTTP 413)" in doc
109
132
  assert "600 KB" in doc
110
133
  assert "``invalid_file``" in doc
@@ -1046,3 +1046,176 @@ class TestAsyncWhatsApp:
1046
1046
  assert message.whatsapp.kind == "text"
1047
1047
 
1048
1048
  await client.close()
1049
+
1050
+
1051
+ class TestWhatsAppSmsFallback:
1052
+ def _fallback_message(self, mock_whatsapp_message):
1053
+ return {
1054
+ **mock_whatsapp_message,
1055
+ "status": "failed",
1056
+ "creditsUsed": 0,
1057
+ "fallback": {
1058
+ "channel": "sms",
1059
+ "status": "sent",
1060
+ "reason": "window_closed",
1061
+ "messageId": "msg_sms_1",
1062
+ "creditsUsed": 2,
1063
+ },
1064
+ }
1065
+
1066
+ def test_sends_fallback_to_sms_and_reads_the_fallback(
1067
+ self, api_key, mock_whatsapp_message, httpx_mock: HTTPXMock
1068
+ ):
1069
+ client = Sendly(api_key)
1070
+ httpx_mock.add_response(
1071
+ url="https://sendly.live/api/v1/messages",
1072
+ method="POST",
1073
+ json=self._fallback_message(mock_whatsapp_message),
1074
+ )
1075
+
1076
+ message = client.messages.send(
1077
+ channel="whatsapp",
1078
+ to="+15551234567",
1079
+ from_="+15559876543",
1080
+ text="Your table is ready!",
1081
+ fallback_to_sms=True,
1082
+ )
1083
+
1084
+ payload = json.loads(httpx_mock.get_request().read().decode())
1085
+ assert payload["fallbackToSms"] is True
1086
+ assert message.status.value == "failed"
1087
+ assert message.fallback is not None
1088
+ assert message.fallback.status == "sent"
1089
+ assert message.fallback.message_id == "msg_sms_1"
1090
+ assert message.fallback.credits_used == 2
1091
+
1092
+ def test_leaves_fallback_out_by_default(
1093
+ self, api_key, mock_whatsapp_message, httpx_mock: HTTPXMock
1094
+ ):
1095
+ client = Sendly(api_key)
1096
+ httpx_mock.add_response(
1097
+ url="https://sendly.live/api/v1/messages",
1098
+ method="POST",
1099
+ json=mock_whatsapp_message,
1100
+ )
1101
+
1102
+ message = client.messages.send(
1103
+ channel="whatsapp", to="+15551234567", from_="+15559876543", text="Hi"
1104
+ )
1105
+
1106
+ payload = json.loads(httpx_mock.get_request().read().decode())
1107
+ assert "fallbackToSms" not in payload
1108
+ assert message.fallback is None
1109
+
1110
+ @pytest.mark.asyncio
1111
+ async def test_async_sends_fallback_to_sms(
1112
+ self, api_key, mock_whatsapp_message, httpx_mock: HTTPXMock
1113
+ ):
1114
+ httpx_mock.add_response(
1115
+ url="https://sendly.live/api/v1/messages",
1116
+ method="POST",
1117
+ json=self._fallback_message(mock_whatsapp_message),
1118
+ )
1119
+
1120
+ async with AsyncSendly(api_key) as client:
1121
+ message = await client.messages.send(
1122
+ channel="whatsapp",
1123
+ to="+15551234567",
1124
+ from_="+15559876543",
1125
+ text="Your table is ready!",
1126
+ fallback_to_sms=True,
1127
+ )
1128
+
1129
+ payload = json.loads(httpx_mock.get_request().read().decode())
1130
+ assert payload["fallbackToSms"] is True
1131
+ assert message.fallback.status == "sent"
1132
+
1133
+
1134
+ class TestWhatsAppRecoveredSend:
1135
+ def test_reads_a_send_that_was_accepted_but_not_fully_recorded(
1136
+ self, api_key, httpx_mock: HTTPXMock
1137
+ ):
1138
+ client = Sendly(api_key)
1139
+ httpx_mock.add_response(
1140
+ url="https://sendly.live/api/v1/messages",
1141
+ method="POST",
1142
+ status_code=201,
1143
+ json={
1144
+ "id": "msg_wa_1",
1145
+ "channel": "whatsapp",
1146
+ "message_format": "whatsapp",
1147
+ "messageFormat": "whatsapp",
1148
+ "to": "+15551234567",
1149
+ "from": "+15559876543",
1150
+ "status": "sent",
1151
+ "direction": "outbound",
1152
+ "segments": 1,
1153
+ "metadata": {},
1154
+ },
1155
+ )
1156
+
1157
+ message = client.messages.send(
1158
+ channel="whatsapp", to="+15551234567", from_="+15559876543", text="Hi"
1159
+ )
1160
+
1161
+ assert message.id == "msg_wa_1"
1162
+ assert message.status.value == "sent"
1163
+ assert message.whatsapp is None
1164
+ assert message.created_at is None
1165
+ assert len(httpx_mock.get_requests()) == 1
1166
+
1167
+
1168
+ class TestMessageFallbackFields:
1169
+ def test_get_reads_the_fallback_of_a_whatsapp_message(
1170
+ self, api_key, mock_message, httpx_mock: HTTPXMock
1171
+ ):
1172
+ client = Sendly(api_key)
1173
+ httpx_mock.add_response(
1174
+ url="https://sendly.live/api/v1/messages/msg_wa_1",
1175
+ method="GET",
1176
+ json={
1177
+ **mock_message,
1178
+ "id": "msg_wa_1",
1179
+ "status": "failed",
1180
+ "message_format": "whatsapp",
1181
+ "messageFormat": "whatsapp",
1182
+ "fallback": {
1183
+ "channel": "sms",
1184
+ "status": "sent",
1185
+ "reason": "whatsapp_failed",
1186
+ "messageId": "msg_sms_1",
1187
+ "creditsUsed": 2,
1188
+ },
1189
+ },
1190
+ )
1191
+
1192
+ message = client.messages.get("msg_wa_1")
1193
+
1194
+ assert message.message_format == "whatsapp"
1195
+ assert message.fallback.status == "sent"
1196
+ assert message.fallback.message_id == "msg_sms_1"
1197
+ assert message.fallback_of is None
1198
+
1199
+ def test_get_links_an_sms_fallback_to_its_whatsapp_message(
1200
+ self, api_key, mock_message, httpx_mock: HTTPXMock
1201
+ ):
1202
+ client = Sendly(api_key)
1203
+ httpx_mock.add_response(
1204
+ url="https://sendly.live/api/v1/messages/msg_sms_1",
1205
+ method="GET",
1206
+ json={
1207
+ **mock_message,
1208
+ "id": "msg_sms_1",
1209
+ "message_format": "sms",
1210
+ "batch_id": "batch_1",
1211
+ "media_urls": ["https://example.com/a.png"],
1212
+ "fallbackOf": "msg_wa_1",
1213
+ },
1214
+ )
1215
+
1216
+ message = client.messages.get("msg_sms_1")
1217
+
1218
+ assert message.fallback_of == "msg_wa_1"
1219
+ assert message.batch_id == "batch_1"
1220
+ assert message.media_urls == ["https://example.com/a.png"]
1221
+ assert message.fallback is None
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes