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.
- {sendly-4.3.0 → sendly-4.4.0}/CHANGELOG.md +10 -0
- {sendly-4.3.0 → sendly-4.4.0}/PKG-INFO +5 -5
- {sendly-4.3.0 → sendly-4.4.0}/README.md +4 -4
- {sendly-4.3.0 → sendly-4.4.0}/pyproject.toml +1 -1
- {sendly-4.3.0 → sendly-4.4.0}/sendly/__init__.py +3 -1
- {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/media.py +5 -4
- {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/messages.py +10 -2
- {sendly-4.3.0 → sendly-4.4.0}/sendly/types.py +83 -3
- {sendly-4.3.0 → sendly-4.4.0}/sendly/utils/http.py +1 -1
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_media.py +30 -7
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_whatsapp.py +173 -0
- {sendly-4.3.0 → sendly-4.4.0}/.github/header.svg +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/.gitignore +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/examples/async_example.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/examples/error_handling.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/examples/send_sms.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/sendly/client.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/sendly/errors.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/sendly/py.typed +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/__init__.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/account.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/business_upgrade.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/calls.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/campaigns.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/contacts.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/conversations.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/drafts.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/enterprise.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/labels.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/links.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/numbers.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/rcs.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/rules.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/templates.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/tendlc.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/verify.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/voice.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/webhooks.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/sendly/resources/whatsapp.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/sendly/utils/__init__.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/sendly/utils/validation.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/sendly/webhooks.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/conftest.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_account.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_async_client.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_calls.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_campaigns.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_client.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_contacts.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_conversations.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_enterprise.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_errors.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_http_body_kwarg.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_http_retry.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_idempotency.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_messages.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_messages.py.bak +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_messages_batch.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_messages_schedule.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_messages_wire.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_numbers.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_path_ids.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_rcs.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_rcs_registration.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_rules.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_templates.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_tendlc.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_validation.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_voice.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_webhooks.py +0 -0
- {sendly-4.3.0 → sendly-4.4.0}/tests/test_webhooks_resource.py +0 -0
- {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
|
+
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
|
|
1076
|
-
|
|
1077
|
-
for your account it is
|
|
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
|
|
1041
|
-
|
|
1042
|
-
for your account it is
|
|
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
|
)
|
|
@@ -29,7 +29,7 @@ Async Example:
|
|
|
29
29
|
>>> asyncio.run(main())
|
|
30
30
|
"""
|
|
31
31
|
|
|
32
|
-
__version__ = "4.
|
|
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; ``
|
|
53
|
-
content_type is any other type
|
|
54
|
-
|
|
55
|
-
MMS is not enabled for
|
|
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
|
|
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(
|
|
3240
|
-
|
|
3241
|
-
|
|
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
|
|
|
@@ -62,26 +62,48 @@ class TestMediaUpload:
|
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
class TestUploadRejections:
|
|
65
|
-
def
|
|
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=
|
|
73
|
-
json={
|
|
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 == "
|
|
80
|
-
assert exc_info.value.status_code ==
|
|
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
|
|
127
|
+
def test_docs_name_every_rejection(self):
|
|
106
128
|
doc = MediaResource.upload.__doc__ or ""
|
|
107
129
|
|
|
108
|
-
assert "``
|
|
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
|
|
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
|