sendly 3.8.1__py3-none-any.whl
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/__init__.py +165 -0
- sendly/client.py +248 -0
- sendly/errors.py +169 -0
- sendly/resources/__init__.py +5 -0
- sendly/resources/account.py +264 -0
- sendly/resources/messages.py +1087 -0
- sendly/resources/webhooks.py +435 -0
- sendly/types.py +748 -0
- sendly/utils/__init__.py +26 -0
- sendly/utils/http.py +358 -0
- sendly/utils/validation.py +248 -0
- sendly/webhooks.py +245 -0
- sendly-3.8.1.dist-info/METADATA +589 -0
- sendly-3.8.1.dist-info/RECORD +15 -0
- sendly-3.8.1.dist-info/WHEEL +4 -0
sendly/utils/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Sendly SDK Utilities"""
|
|
2
|
+
|
|
3
|
+
from .http import AsyncHttpClient, HttpClient
|
|
4
|
+
from .validation import (
|
|
5
|
+
calculate_segments,
|
|
6
|
+
get_country_from_phone,
|
|
7
|
+
is_country_supported,
|
|
8
|
+
validate_limit,
|
|
9
|
+
validate_message_id,
|
|
10
|
+
validate_message_text,
|
|
11
|
+
validate_phone_number,
|
|
12
|
+
validate_sender_id,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"HttpClient",
|
|
17
|
+
"AsyncHttpClient",
|
|
18
|
+
"validate_phone_number",
|
|
19
|
+
"validate_message_text",
|
|
20
|
+
"validate_sender_id",
|
|
21
|
+
"validate_limit",
|
|
22
|
+
"validate_message_id",
|
|
23
|
+
"get_country_from_phone",
|
|
24
|
+
"is_country_supported",
|
|
25
|
+
"calculate_segments",
|
|
26
|
+
]
|
sendly/utils/http.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP Client Utility
|
|
3
|
+
|
|
4
|
+
Handles HTTP requests to the Sendly API with retries and rate limiting.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import random
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Dict, Optional, TypeVar, Union
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from ..errors import (
|
|
16
|
+
NetworkError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
SendlyError,
|
|
19
|
+
TimeoutError,
|
|
20
|
+
)
|
|
21
|
+
from ..types import RateLimitInfo
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
|
|
25
|
+
DEFAULT_BASE_URL = "https://sendly.live/api/v1"
|
|
26
|
+
DEFAULT_TIMEOUT = 30.0
|
|
27
|
+
DEFAULT_MAX_RETRIES = 3
|
|
28
|
+
SDK_VERSION = "1.0.5"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class HttpClient:
|
|
32
|
+
"""Synchronous HTTP client for making API requests"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
api_key: str,
|
|
37
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
38
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
39
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
40
|
+
):
|
|
41
|
+
self.api_key = api_key
|
|
42
|
+
self.base_url = base_url.rstrip("/")
|
|
43
|
+
self.timeout = timeout
|
|
44
|
+
self.max_retries = max_retries
|
|
45
|
+
self._rate_limit_info: Optional[RateLimitInfo] = None
|
|
46
|
+
self._client: Optional[httpx.Client] = None
|
|
47
|
+
|
|
48
|
+
# Validate API key format
|
|
49
|
+
if not self._is_valid_api_key(api_key):
|
|
50
|
+
raise ValueError("Invalid API key format. Expected sk_test_v1_xxx or sk_live_v1_xxx")
|
|
51
|
+
|
|
52
|
+
def _is_valid_api_key(self, key: str) -> bool:
|
|
53
|
+
"""Validate API key format"""
|
|
54
|
+
return bool(re.match(r"^sk_(test|live)_v1_[a-zA-Z0-9_-]+$", key))
|
|
55
|
+
|
|
56
|
+
def is_test_mode(self) -> bool:
|
|
57
|
+
"""Check if using a test API key"""
|
|
58
|
+
return self.api_key.startswith("sk_test_")
|
|
59
|
+
|
|
60
|
+
def get_rate_limit_info(self) -> Optional[RateLimitInfo]:
|
|
61
|
+
"""Get current rate limit info"""
|
|
62
|
+
return self._rate_limit_info
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def client(self) -> httpx.Client:
|
|
66
|
+
"""Get or create the HTTP client"""
|
|
67
|
+
if self._client is None or self._client.is_closed:
|
|
68
|
+
self._client = httpx.Client(
|
|
69
|
+
base_url=self.base_url,
|
|
70
|
+
timeout=self.timeout,
|
|
71
|
+
headers=self._build_headers(),
|
|
72
|
+
)
|
|
73
|
+
return self._client
|
|
74
|
+
|
|
75
|
+
def close(self) -> None:
|
|
76
|
+
"""Close the HTTP client"""
|
|
77
|
+
if self._client is not None:
|
|
78
|
+
self._client.close()
|
|
79
|
+
self._client = None
|
|
80
|
+
|
|
81
|
+
def __enter__(self) -> "HttpClient":
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def __exit__(self, *args: Any) -> None:
|
|
85
|
+
self.close()
|
|
86
|
+
|
|
87
|
+
def _build_headers(self) -> Dict[str, str]:
|
|
88
|
+
"""Build request headers"""
|
|
89
|
+
return {
|
|
90
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
91
|
+
"Content-Type": "application/json",
|
|
92
|
+
"Accept": "application/json",
|
|
93
|
+
"User-Agent": f"sendly-python/{SDK_VERSION}",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
def _update_rate_limit_info(self, headers: httpx.Headers) -> None:
|
|
97
|
+
"""Update rate limit info from response headers"""
|
|
98
|
+
limit = headers.get("X-RateLimit-Limit")
|
|
99
|
+
remaining = headers.get("X-RateLimit-Remaining")
|
|
100
|
+
reset = headers.get("X-RateLimit-Reset")
|
|
101
|
+
|
|
102
|
+
if limit and remaining and reset:
|
|
103
|
+
self._rate_limit_info = RateLimitInfo(
|
|
104
|
+
limit=int(limit),
|
|
105
|
+
remaining=int(remaining),
|
|
106
|
+
reset=int(reset),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def _calculate_backoff(self, attempt: int) -> float:
|
|
110
|
+
"""Calculate exponential backoff time"""
|
|
111
|
+
base_delay = 2**attempt
|
|
112
|
+
jitter = random.uniform(0, 0.5)
|
|
113
|
+
return min(base_delay + jitter, 30.0)
|
|
114
|
+
|
|
115
|
+
def request(
|
|
116
|
+
self,
|
|
117
|
+
method: str,
|
|
118
|
+
path: str,
|
|
119
|
+
body: Optional[Dict[str, Any]] = None,
|
|
120
|
+
params: Optional[Dict[str, Any]] = None,
|
|
121
|
+
) -> Any:
|
|
122
|
+
"""Make an HTTP request to the API"""
|
|
123
|
+
last_error: Optional[Exception] = None
|
|
124
|
+
|
|
125
|
+
for attempt in range(self.max_retries + 1):
|
|
126
|
+
try:
|
|
127
|
+
response = self.client.request(
|
|
128
|
+
method=method,
|
|
129
|
+
url=path,
|
|
130
|
+
json=body,
|
|
131
|
+
params=params,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Update rate limit info
|
|
135
|
+
self._update_rate_limit_info(response.headers)
|
|
136
|
+
|
|
137
|
+
# Parse response
|
|
138
|
+
data = self._parse_response(response)
|
|
139
|
+
return data
|
|
140
|
+
|
|
141
|
+
except SendlyError as e:
|
|
142
|
+
last_error = e
|
|
143
|
+
|
|
144
|
+
# Don't retry certain errors
|
|
145
|
+
if e.status_code in (400, 401, 402, 403, 404):
|
|
146
|
+
raise
|
|
147
|
+
|
|
148
|
+
# Handle rate limiting
|
|
149
|
+
if isinstance(e, RateLimitError):
|
|
150
|
+
if attempt < self.max_retries:
|
|
151
|
+
time.sleep(e.retry_after)
|
|
152
|
+
continue
|
|
153
|
+
raise
|
|
154
|
+
|
|
155
|
+
except httpx.TimeoutException as e:
|
|
156
|
+
last_error = TimeoutError(f"Request timed out after {self.timeout}s")
|
|
157
|
+
if attempt < self.max_retries:
|
|
158
|
+
time.sleep(self._calculate_backoff(attempt))
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
except httpx.RequestError as e:
|
|
162
|
+
last_error = NetworkError(f"Network error: {str(e)}", e)
|
|
163
|
+
if attempt < self.max_retries:
|
|
164
|
+
time.sleep(self._calculate_backoff(attempt))
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
if last_error:
|
|
168
|
+
raise last_error
|
|
169
|
+
raise NetworkError("Request failed after retries")
|
|
170
|
+
|
|
171
|
+
def _parse_response(self, response: httpx.Response) -> Any:
|
|
172
|
+
"""Parse the response body"""
|
|
173
|
+
content_type = response.headers.get("content-type", "")
|
|
174
|
+
|
|
175
|
+
if "application/json" in content_type:
|
|
176
|
+
try:
|
|
177
|
+
data = response.json()
|
|
178
|
+
except Exception:
|
|
179
|
+
data = response.text
|
|
180
|
+
else:
|
|
181
|
+
data = response.text
|
|
182
|
+
|
|
183
|
+
# Handle error responses
|
|
184
|
+
if not response.is_success:
|
|
185
|
+
if isinstance(data, dict):
|
|
186
|
+
raise SendlyError.from_response(response.status_code, data)
|
|
187
|
+
raise SendlyError(
|
|
188
|
+
message=str(data) or f"HTTP {response.status_code}",
|
|
189
|
+
code="internal_error",
|
|
190
|
+
status_code=response.status_code,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return data
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class AsyncHttpClient:
|
|
197
|
+
"""Asynchronous HTTP client for making API requests"""
|
|
198
|
+
|
|
199
|
+
def __init__(
|
|
200
|
+
self,
|
|
201
|
+
api_key: str,
|
|
202
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
203
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
204
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
205
|
+
):
|
|
206
|
+
self.api_key = api_key
|
|
207
|
+
self.base_url = base_url.rstrip("/")
|
|
208
|
+
self.timeout = timeout
|
|
209
|
+
self.max_retries = max_retries
|
|
210
|
+
self._rate_limit_info: Optional[RateLimitInfo] = None
|
|
211
|
+
self._client: Optional[httpx.AsyncClient] = None
|
|
212
|
+
|
|
213
|
+
# Validate API key format
|
|
214
|
+
if not self._is_valid_api_key(api_key):
|
|
215
|
+
raise ValueError("Invalid API key format. Expected sk_test_v1_xxx or sk_live_v1_xxx")
|
|
216
|
+
|
|
217
|
+
def _is_valid_api_key(self, key: str) -> bool:
|
|
218
|
+
"""Validate API key format"""
|
|
219
|
+
return bool(re.match(r"^sk_(test|live)_v1_[a-zA-Z0-9_-]+$", key))
|
|
220
|
+
|
|
221
|
+
def is_test_mode(self) -> bool:
|
|
222
|
+
"""Check if using a test API key"""
|
|
223
|
+
return self.api_key.startswith("sk_test_")
|
|
224
|
+
|
|
225
|
+
def get_rate_limit_info(self) -> Optional[RateLimitInfo]:
|
|
226
|
+
"""Get current rate limit info"""
|
|
227
|
+
return self._rate_limit_info
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def client(self) -> httpx.AsyncClient:
|
|
231
|
+
"""Get or create the async HTTP client"""
|
|
232
|
+
if self._client is None or self._client.is_closed:
|
|
233
|
+
self._client = httpx.AsyncClient(
|
|
234
|
+
base_url=self.base_url,
|
|
235
|
+
timeout=self.timeout,
|
|
236
|
+
headers=self._build_headers(),
|
|
237
|
+
)
|
|
238
|
+
return self._client
|
|
239
|
+
|
|
240
|
+
async def close(self) -> None:
|
|
241
|
+
"""Close the HTTP client"""
|
|
242
|
+
if self._client is not None:
|
|
243
|
+
await self._client.aclose()
|
|
244
|
+
self._client = None
|
|
245
|
+
|
|
246
|
+
async def __aenter__(self) -> "AsyncHttpClient":
|
|
247
|
+
return self
|
|
248
|
+
|
|
249
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
250
|
+
await self.close()
|
|
251
|
+
|
|
252
|
+
def _build_headers(self) -> Dict[str, str]:
|
|
253
|
+
"""Build request headers"""
|
|
254
|
+
return {
|
|
255
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
256
|
+
"Content-Type": "application/json",
|
|
257
|
+
"Accept": "application/json",
|
|
258
|
+
"User-Agent": f"sendly-python/{SDK_VERSION}",
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
def _update_rate_limit_info(self, headers: httpx.Headers) -> None:
|
|
262
|
+
"""Update rate limit info from response headers"""
|
|
263
|
+
limit = headers.get("X-RateLimit-Limit")
|
|
264
|
+
remaining = headers.get("X-RateLimit-Remaining")
|
|
265
|
+
reset = headers.get("X-RateLimit-Reset")
|
|
266
|
+
|
|
267
|
+
if limit and remaining and reset:
|
|
268
|
+
self._rate_limit_info = RateLimitInfo(
|
|
269
|
+
limit=int(limit),
|
|
270
|
+
remaining=int(remaining),
|
|
271
|
+
reset=int(reset),
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def _calculate_backoff(self, attempt: int) -> float:
|
|
275
|
+
"""Calculate exponential backoff time"""
|
|
276
|
+
base_delay = 2**attempt
|
|
277
|
+
jitter = random.uniform(0, 0.5)
|
|
278
|
+
return min(base_delay + jitter, 30.0)
|
|
279
|
+
|
|
280
|
+
async def request(
|
|
281
|
+
self,
|
|
282
|
+
method: str,
|
|
283
|
+
path: str,
|
|
284
|
+
body: Optional[Dict[str, Any]] = None,
|
|
285
|
+
params: Optional[Dict[str, Any]] = None,
|
|
286
|
+
) -> Any:
|
|
287
|
+
"""Make an async HTTP request to the API"""
|
|
288
|
+
last_error: Optional[Exception] = None
|
|
289
|
+
|
|
290
|
+
for attempt in range(self.max_retries + 1):
|
|
291
|
+
try:
|
|
292
|
+
response = await self.client.request(
|
|
293
|
+
method=method,
|
|
294
|
+
url=path,
|
|
295
|
+
json=body,
|
|
296
|
+
params=params,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# Update rate limit info
|
|
300
|
+
self._update_rate_limit_info(response.headers)
|
|
301
|
+
|
|
302
|
+
# Parse response
|
|
303
|
+
data = self._parse_response(response)
|
|
304
|
+
return data
|
|
305
|
+
|
|
306
|
+
except SendlyError as e:
|
|
307
|
+
last_error = e
|
|
308
|
+
|
|
309
|
+
# Don't retry certain errors
|
|
310
|
+
if e.status_code in (400, 401, 402, 403, 404):
|
|
311
|
+
raise
|
|
312
|
+
|
|
313
|
+
# Handle rate limiting
|
|
314
|
+
if isinstance(e, RateLimitError):
|
|
315
|
+
if attempt < self.max_retries:
|
|
316
|
+
await asyncio.sleep(e.retry_after)
|
|
317
|
+
continue
|
|
318
|
+
raise
|
|
319
|
+
|
|
320
|
+
except httpx.TimeoutException as e:
|
|
321
|
+
last_error = TimeoutError(f"Request timed out after {self.timeout}s")
|
|
322
|
+
if attempt < self.max_retries:
|
|
323
|
+
await asyncio.sleep(self._calculate_backoff(attempt))
|
|
324
|
+
continue
|
|
325
|
+
|
|
326
|
+
except httpx.RequestError as e:
|
|
327
|
+
last_error = NetworkError(f"Network error: {str(e)}", e)
|
|
328
|
+
if attempt < self.max_retries:
|
|
329
|
+
await asyncio.sleep(self._calculate_backoff(attempt))
|
|
330
|
+
continue
|
|
331
|
+
|
|
332
|
+
if last_error:
|
|
333
|
+
raise last_error
|
|
334
|
+
raise NetworkError("Request failed after retries")
|
|
335
|
+
|
|
336
|
+
def _parse_response(self, response: httpx.Response) -> Any:
|
|
337
|
+
"""Parse the response body"""
|
|
338
|
+
content_type = response.headers.get("content-type", "")
|
|
339
|
+
|
|
340
|
+
if "application/json" in content_type:
|
|
341
|
+
try:
|
|
342
|
+
data = response.json()
|
|
343
|
+
except Exception:
|
|
344
|
+
data = response.text
|
|
345
|
+
else:
|
|
346
|
+
data = response.text
|
|
347
|
+
|
|
348
|
+
# Handle error responses
|
|
349
|
+
if not response.is_success:
|
|
350
|
+
if isinstance(data, dict):
|
|
351
|
+
raise SendlyError.from_response(response.status_code, data)
|
|
352
|
+
raise SendlyError(
|
|
353
|
+
message=str(data) or f"HTTP {response.status_code}",
|
|
354
|
+
code="internal_error",
|
|
355
|
+
status_code=response.status_code,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
return data
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Input Validation Utilities
|
|
3
|
+
|
|
4
|
+
Functions for validating API inputs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from ..errors import ValidationError
|
|
11
|
+
from ..types import ALL_SUPPORTED_COUNTRIES
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def validate_phone_number(phone: str) -> None:
|
|
15
|
+
"""
|
|
16
|
+
Validate phone number format (E.164)
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
phone: Phone number to validate
|
|
20
|
+
|
|
21
|
+
Raises:
|
|
22
|
+
ValidationError: If phone number is invalid
|
|
23
|
+
"""
|
|
24
|
+
if not phone:
|
|
25
|
+
raise ValidationError("Phone number is required")
|
|
26
|
+
|
|
27
|
+
# E.164 format: + followed by 1-15 digits
|
|
28
|
+
e164_pattern = r"^\+[1-9]\d{1,14}$"
|
|
29
|
+
|
|
30
|
+
if not re.match(e164_pattern, phone):
|
|
31
|
+
raise ValidationError(
|
|
32
|
+
f"Invalid phone number format: {phone}. Expected E.164 format (e.g., +15551234567)"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def validate_message_text(text: str) -> None:
|
|
37
|
+
"""
|
|
38
|
+
Validate message text
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
text: Message text to validate
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
ValidationError: If text is invalid
|
|
45
|
+
"""
|
|
46
|
+
if not text:
|
|
47
|
+
raise ValidationError("Message text is required")
|
|
48
|
+
|
|
49
|
+
if not isinstance(text, str):
|
|
50
|
+
raise ValidationError("Message text must be a string")
|
|
51
|
+
|
|
52
|
+
# Warn about very long messages
|
|
53
|
+
if len(text) > 1600:
|
|
54
|
+
import warnings
|
|
55
|
+
|
|
56
|
+
warnings.warn(
|
|
57
|
+
f"Message is {len(text)} characters. "
|
|
58
|
+
f"This will be split into {calculate_segments(text)} segments."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def validate_sender_id(from_: Optional[str]) -> None:
|
|
63
|
+
"""
|
|
64
|
+
Validate sender ID
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
from_: Sender ID to validate
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
ValidationError: If sender ID is invalid
|
|
71
|
+
"""
|
|
72
|
+
if not from_:
|
|
73
|
+
return # Optional field
|
|
74
|
+
|
|
75
|
+
# Phone number format (toll-free)
|
|
76
|
+
if from_.startswith("+"):
|
|
77
|
+
validate_phone_number(from_)
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
# Alphanumeric sender ID (2-11 characters)
|
|
81
|
+
alphanumeric_pattern = r"^[a-zA-Z0-9]{2,11}$"
|
|
82
|
+
|
|
83
|
+
if not re.match(alphanumeric_pattern, from_):
|
|
84
|
+
raise ValidationError(
|
|
85
|
+
f"Invalid sender ID: {from_}. "
|
|
86
|
+
"Must be 2-11 alphanumeric characters or a valid phone number."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def validate_limit(limit: Optional[int]) -> None:
|
|
91
|
+
"""
|
|
92
|
+
Validate list limit
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
limit: Limit value to validate
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
ValidationError: If limit is invalid
|
|
99
|
+
"""
|
|
100
|
+
if limit is None:
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
if not isinstance(limit, int):
|
|
104
|
+
raise ValidationError("Limit must be an integer")
|
|
105
|
+
|
|
106
|
+
if limit < 1 or limit > 100:
|
|
107
|
+
raise ValidationError("Limit must be between 1 and 100")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def validate_message_id(id: str) -> None:
|
|
111
|
+
"""
|
|
112
|
+
Validate message ID format
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
id: Message ID to validate
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
ValidationError: If ID is invalid
|
|
119
|
+
"""
|
|
120
|
+
if not id:
|
|
121
|
+
raise ValidationError("Message ID is required")
|
|
122
|
+
|
|
123
|
+
if not isinstance(id, str):
|
|
124
|
+
raise ValidationError("Message ID must be a string")
|
|
125
|
+
|
|
126
|
+
# UUID format or prefixed format
|
|
127
|
+
uuid_pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
|
128
|
+
prefixed_pattern = r"^msg_[a-zA-Z0-9_]+$"
|
|
129
|
+
|
|
130
|
+
if not (re.match(uuid_pattern, id, re.IGNORECASE) or re.match(prefixed_pattern, id)):
|
|
131
|
+
raise ValidationError(f"Invalid message ID format: {id}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def get_country_from_phone(phone: str) -> Optional[str]:
|
|
135
|
+
"""
|
|
136
|
+
Get country code from phone number
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
phone: Phone number in E.164 format
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
ISO country code or None if not found
|
|
143
|
+
"""
|
|
144
|
+
# Remove + prefix
|
|
145
|
+
digits = phone.lstrip("+")
|
|
146
|
+
|
|
147
|
+
# Check US/Canada (country code 1)
|
|
148
|
+
if digits.startswith("1") and len(digits) == 11:
|
|
149
|
+
return "US" # Could be CA, but we treat as domestic
|
|
150
|
+
|
|
151
|
+
# Map of country codes to ISO codes
|
|
152
|
+
country_prefixes = {
|
|
153
|
+
"44": "GB",
|
|
154
|
+
"48": "PL",
|
|
155
|
+
"351": "PT",
|
|
156
|
+
"40": "RO",
|
|
157
|
+
"420": "CZ",
|
|
158
|
+
"36": "HU",
|
|
159
|
+
"86": "CN",
|
|
160
|
+
"82": "KR",
|
|
161
|
+
"91": "IN",
|
|
162
|
+
"63": "PH",
|
|
163
|
+
"66": "TH",
|
|
164
|
+
"84": "VN",
|
|
165
|
+
"33": "FR",
|
|
166
|
+
"34": "ES",
|
|
167
|
+
"46": "SE",
|
|
168
|
+
"47": "NO",
|
|
169
|
+
"45": "DK",
|
|
170
|
+
"358": "FI",
|
|
171
|
+
"353": "IE",
|
|
172
|
+
"81": "JP",
|
|
173
|
+
"61": "AU",
|
|
174
|
+
"64": "NZ",
|
|
175
|
+
"65": "SG",
|
|
176
|
+
"852": "HK",
|
|
177
|
+
"60": "MY",
|
|
178
|
+
"62": "ID",
|
|
179
|
+
"55": "BR",
|
|
180
|
+
"54": "AR",
|
|
181
|
+
"56": "CL",
|
|
182
|
+
"57": "CO",
|
|
183
|
+
"27": "ZA",
|
|
184
|
+
"30": "GR",
|
|
185
|
+
"49": "DE",
|
|
186
|
+
"39": "IT",
|
|
187
|
+
"31": "NL",
|
|
188
|
+
"32": "BE",
|
|
189
|
+
"43": "AT",
|
|
190
|
+
"41": "CH",
|
|
191
|
+
"52": "MX",
|
|
192
|
+
"972": "IL",
|
|
193
|
+
"971": "AE",
|
|
194
|
+
"966": "SA",
|
|
195
|
+
"20": "EG",
|
|
196
|
+
"234": "NG",
|
|
197
|
+
"254": "KE",
|
|
198
|
+
"886": "TW",
|
|
199
|
+
"92": "PK",
|
|
200
|
+
"90": "TR",
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
# Try to match country prefixes (longest first)
|
|
204
|
+
sorted_prefixes = sorted(country_prefixes.keys(), key=len, reverse=True)
|
|
205
|
+
|
|
206
|
+
for prefix in sorted_prefixes:
|
|
207
|
+
if digits.startswith(prefix):
|
|
208
|
+
return country_prefixes[prefix]
|
|
209
|
+
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def is_country_supported(country_code: str) -> bool:
|
|
214
|
+
"""
|
|
215
|
+
Check if a country is supported
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
country_code: ISO country code
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
True if country is supported
|
|
222
|
+
"""
|
|
223
|
+
return country_code.upper() in ALL_SUPPORTED_COUNTRIES
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def calculate_segments(text: str) -> int:
|
|
227
|
+
"""
|
|
228
|
+
Calculate number of SMS segments for a message
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
text: Message text
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
Number of SMS segments
|
|
235
|
+
"""
|
|
236
|
+
# Check if message contains non-GSM characters (requires UCS-2 encoding)
|
|
237
|
+
# Simple check: any character outside basic ASCII range
|
|
238
|
+
is_unicode = any(ord(c) > 127 for c in text)
|
|
239
|
+
|
|
240
|
+
# GSM: 160 chars single, 153 chars per segment for multi
|
|
241
|
+
# UCS-2: 70 chars single, 67 chars per segment for multi
|
|
242
|
+
single_limit = 70 if is_unicode else 160
|
|
243
|
+
multi_limit = 67 if is_unicode else 153
|
|
244
|
+
|
|
245
|
+
if len(text) <= single_limit:
|
|
246
|
+
return 1
|
|
247
|
+
|
|
248
|
+
return (len(text) + multi_limit - 1) // multi_limit
|