virtualsmslabs 1.0.0__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.
- virtualsms/__init__.py +34 -0
- virtualsms/client.py +296 -0
- virtualsms/constants.py +15 -0
- virtualsms/exceptions.py +126 -0
- virtualsms/parser.py +160 -0
- virtualsms/response.py +22 -0
- virtualsms/transport.py +41 -0
- virtualsmslabs-1.0.0.dist-info/METADATA +352 -0
- virtualsmslabs-1.0.0.dist-info/RECORD +11 -0
- virtualsmslabs-1.0.0.dist-info/WHEEL +4 -0
- virtualsmslabs-1.0.0.dist-info/licenses/LICENSE +21 -0
virtualsms/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from virtualsms.client import VirtualSMSClient
|
|
2
|
+
from virtualsms.constants import ActivationStatus, PoolProvider
|
|
3
|
+
from virtualsms.exceptions import (
|
|
4
|
+
ActivationException,
|
|
5
|
+
AuthenticationException,
|
|
6
|
+
InsufficientBalanceException,
|
|
7
|
+
NoNumbersException,
|
|
8
|
+
RateLimitException,
|
|
9
|
+
ServerException,
|
|
10
|
+
ValidationException,
|
|
11
|
+
VirtualSMSException,
|
|
12
|
+
)
|
|
13
|
+
from virtualsms.response import BalanceResponse, NumberResponse, StatusResponse
|
|
14
|
+
from virtualsms.transport import Response, Transport, UrllibTransport
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"VirtualSMSClient",
|
|
18
|
+
"ActivationStatus",
|
|
19
|
+
"PoolProvider",
|
|
20
|
+
"VirtualSMSException",
|
|
21
|
+
"AuthenticationException",
|
|
22
|
+
"InsufficientBalanceException",
|
|
23
|
+
"NoNumbersException",
|
|
24
|
+
"ValidationException",
|
|
25
|
+
"ActivationException",
|
|
26
|
+
"RateLimitException",
|
|
27
|
+
"ServerException",
|
|
28
|
+
"BalanceResponse",
|
|
29
|
+
"NumberResponse",
|
|
30
|
+
"StatusResponse",
|
|
31
|
+
"Transport",
|
|
32
|
+
"UrllibTransport",
|
|
33
|
+
"Response",
|
|
34
|
+
]
|
virtualsms/client.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import platform
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any, Dict, List, Optional, Type
|
|
8
|
+
from urllib.parse import urlencode
|
|
9
|
+
|
|
10
|
+
from virtualsms.constants import PoolProvider
|
|
11
|
+
from virtualsms.exceptions import VirtualSMSException
|
|
12
|
+
from virtualsms.parser import TextResponseParser
|
|
13
|
+
from virtualsms.response import BalanceResponse, NumberResponse, StatusResponse
|
|
14
|
+
from virtualsms.transport import Response, Transport, UrllibTransport
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class VirtualSMSClient:
|
|
18
|
+
VERSION = "1.0.0"
|
|
19
|
+
SDK_LANGUAGE = "python"
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
api_key: str,
|
|
24
|
+
base_url: str = "https://api.virtualsms.de",
|
|
25
|
+
transport: Optional[Transport] = None,
|
|
26
|
+
):
|
|
27
|
+
self._api_key = api_key
|
|
28
|
+
self._base_url = base_url.rstrip("/")
|
|
29
|
+
self._transport = transport if transport is not None else UrllibTransport()
|
|
30
|
+
self._parser = TextResponseParser()
|
|
31
|
+
self._machine_id = hashlib.sha256(
|
|
32
|
+
(platform.platform() + sys.implementation.name).encode("utf-8")
|
|
33
|
+
).hexdigest()[:32]
|
|
34
|
+
|
|
35
|
+
def get_balance(self) -> BalanceResponse:
|
|
36
|
+
response = self._call_api("getBalance")
|
|
37
|
+
parsed = self._parser.parse(response.body)
|
|
38
|
+
self._ensure_success(parsed, response)
|
|
39
|
+
return BalanceResponse(balance=parsed.data["balance"])
|
|
40
|
+
|
|
41
|
+
def get_countries(self, pool_provider: Optional[str] = None) -> Dict[str, Any]:
|
|
42
|
+
response = self._call_api("getCountries", {"poolProvider": pool_provider})
|
|
43
|
+
parsed = self._parser.parse(response.body)
|
|
44
|
+
self._ensure_success(parsed, response)
|
|
45
|
+
return parsed.data["decoded"]
|
|
46
|
+
|
|
47
|
+
def get_services_list(
|
|
48
|
+
self,
|
|
49
|
+
country: Optional[int] = None,
|
|
50
|
+
lang: Optional[str] = None,
|
|
51
|
+
) -> Dict[str, Any]:
|
|
52
|
+
response = self._call_api(
|
|
53
|
+
"getServicesList",
|
|
54
|
+
{"country": country, "lang": lang},
|
|
55
|
+
)
|
|
56
|
+
parsed = self._parser.parse(response.body)
|
|
57
|
+
self._ensure_success(parsed, response)
|
|
58
|
+
return parsed.data["decoded"]
|
|
59
|
+
|
|
60
|
+
def get_operators(
|
|
61
|
+
self,
|
|
62
|
+
country: int,
|
|
63
|
+
pool_provider: Optional[str] = None,
|
|
64
|
+
) -> List[str]:
|
|
65
|
+
response = self._call_api(
|
|
66
|
+
"getOperators",
|
|
67
|
+
{"country": country, "poolProvider": pool_provider},
|
|
68
|
+
)
|
|
69
|
+
parsed = self._parser.parse(response.body)
|
|
70
|
+
self._ensure_success(parsed, response)
|
|
71
|
+
decoded = parsed.data["decoded"]
|
|
72
|
+
return decoded.get("countryOperators", decoded)
|
|
73
|
+
|
|
74
|
+
def get_prices(
|
|
75
|
+
self,
|
|
76
|
+
service: Optional[str] = None,
|
|
77
|
+
country: Optional[int] = None,
|
|
78
|
+
pool_provider: Optional[str] = None,
|
|
79
|
+
) -> Dict[str, Any]:
|
|
80
|
+
response = self._call_api(
|
|
81
|
+
"getPrices",
|
|
82
|
+
{"service": service, "country": country, "poolProvider": pool_provider},
|
|
83
|
+
)
|
|
84
|
+
parsed = self._parser.parse(response.body)
|
|
85
|
+
self._ensure_success(parsed, response)
|
|
86
|
+
return parsed.data["decoded"]
|
|
87
|
+
|
|
88
|
+
def get_prices_extended(
|
|
89
|
+
self,
|
|
90
|
+
service: Optional[str] = None,
|
|
91
|
+
country: Optional[int] = None,
|
|
92
|
+
free_price: Optional[bool] = None,
|
|
93
|
+
pool_provider: Optional[str] = None,
|
|
94
|
+
) -> Dict[str, Any]:
|
|
95
|
+
response = self._call_api(
|
|
96
|
+
"getPricesExtended",
|
|
97
|
+
{
|
|
98
|
+
"service": service,
|
|
99
|
+
"country": country,
|
|
100
|
+
"freePrice": "true" if free_price is True else ("false" if free_price is False else None),
|
|
101
|
+
"poolProvider": pool_provider,
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
parsed = self._parser.parse(response.body)
|
|
105
|
+
self._ensure_success(parsed, response)
|
|
106
|
+
return parsed.data["decoded"]
|
|
107
|
+
|
|
108
|
+
def get_prices_verification(
|
|
109
|
+
self,
|
|
110
|
+
service: Optional[str] = None,
|
|
111
|
+
pool_provider: Optional[str] = None,
|
|
112
|
+
) -> Dict[str, Any]:
|
|
113
|
+
response = self._call_api(
|
|
114
|
+
"getPricesVerification",
|
|
115
|
+
{"service": service, "poolProvider": pool_provider},
|
|
116
|
+
)
|
|
117
|
+
parsed = self._parser.parse(response.body)
|
|
118
|
+
self._ensure_success(parsed, response)
|
|
119
|
+
return parsed.data["decoded"]
|
|
120
|
+
|
|
121
|
+
def get_numbers_status(
|
|
122
|
+
self,
|
|
123
|
+
country: int,
|
|
124
|
+
operator: Optional[str] = None,
|
|
125
|
+
pool_provider: Optional[str] = None,
|
|
126
|
+
) -> Dict[str, int]:
|
|
127
|
+
response = self._call_api(
|
|
128
|
+
"getNumbersStatus",
|
|
129
|
+
{"country": country, "operator": operator, "poolProvider": pool_provider},
|
|
130
|
+
)
|
|
131
|
+
parsed = self._parser.parse(response.body)
|
|
132
|
+
self._ensure_success(parsed, response)
|
|
133
|
+
return parsed.data["decoded"]
|
|
134
|
+
|
|
135
|
+
def get_number(
|
|
136
|
+
self,
|
|
137
|
+
service: str,
|
|
138
|
+
country: int,
|
|
139
|
+
max_price: Optional[float] = None,
|
|
140
|
+
operator: Optional[str] = None,
|
|
141
|
+
phone_exception: Optional[str] = None,
|
|
142
|
+
forward: Optional[bool] = None,
|
|
143
|
+
activation_type: Optional[int] = None,
|
|
144
|
+
language: Optional[str] = None,
|
|
145
|
+
use_cashback: Optional[bool] = None,
|
|
146
|
+
user_id: Optional[str] = None,
|
|
147
|
+
ref: Optional[str] = None,
|
|
148
|
+
pool_provider: Optional[str] = None,
|
|
149
|
+
) -> NumberResponse:
|
|
150
|
+
response = self._call_api(
|
|
151
|
+
"getNumber",
|
|
152
|
+
{
|
|
153
|
+
"service": service,
|
|
154
|
+
"country": country,
|
|
155
|
+
"maxPrice": max_price,
|
|
156
|
+
"operator": operator,
|
|
157
|
+
"phoneException": phone_exception,
|
|
158
|
+
"forward": "1" if forward is True else ("0" if forward is False else None),
|
|
159
|
+
"activationType": activation_type,
|
|
160
|
+
"language": language,
|
|
161
|
+
"useCashBack": "true" if use_cashback is True else ("false" if use_cashback is False else None),
|
|
162
|
+
"userId": user_id,
|
|
163
|
+
"ref": ref,
|
|
164
|
+
"poolProvider": pool_provider,
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
parsed = self._parser.parse(response.body)
|
|
168
|
+
self._ensure_success(parsed, response)
|
|
169
|
+
return NumberResponse(
|
|
170
|
+
activation_id=parsed.data["activation_id"],
|
|
171
|
+
phone_number=parsed.data["phone_number"],
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def get_number_v2(
|
|
175
|
+
self,
|
|
176
|
+
service: str,
|
|
177
|
+
country: int,
|
|
178
|
+
max_price: Optional[float] = None,
|
|
179
|
+
operator: Optional[str] = None,
|
|
180
|
+
phone_exception: Optional[str] = None,
|
|
181
|
+
forward: Optional[bool] = None,
|
|
182
|
+
activation_type: Optional[int] = None,
|
|
183
|
+
language: Optional[str] = None,
|
|
184
|
+
use_cashback: Optional[bool] = None,
|
|
185
|
+
user_id: Optional[str] = None,
|
|
186
|
+
ref: Optional[str] = None,
|
|
187
|
+
order_id: Optional[str] = None,
|
|
188
|
+
pool_provider: Optional[str] = None,
|
|
189
|
+
) -> Dict[str, Any]:
|
|
190
|
+
response = self._call_api(
|
|
191
|
+
"getNumberV2",
|
|
192
|
+
{
|
|
193
|
+
"service": service,
|
|
194
|
+
"country": country,
|
|
195
|
+
"maxPrice": max_price,
|
|
196
|
+
"operator": operator,
|
|
197
|
+
"phoneException": phone_exception,
|
|
198
|
+
"forward": "1" if forward is True else ("0" if forward is False else None),
|
|
199
|
+
"activationType": activation_type,
|
|
200
|
+
"language": language,
|
|
201
|
+
"useCashBack": "true" if use_cashback is True else ("false" if use_cashback is False else None),
|
|
202
|
+
"userId": user_id,
|
|
203
|
+
"ref": ref,
|
|
204
|
+
"orderId": order_id,
|
|
205
|
+
"poolProvider": pool_provider,
|
|
206
|
+
},
|
|
207
|
+
)
|
|
208
|
+
parsed = self._parser.parse(response.body)
|
|
209
|
+
self._ensure_success(parsed, response)
|
|
210
|
+
return parsed.data["decoded"]
|
|
211
|
+
|
|
212
|
+
def set_status(self, id: int, status: int) -> str:
|
|
213
|
+
response = self._call_api("setStatus", {"id": id, "status": status})
|
|
214
|
+
parsed = self._parser.parse(response.body)
|
|
215
|
+
self._ensure_success(parsed, response)
|
|
216
|
+
return parsed.data["status"]
|
|
217
|
+
|
|
218
|
+
def get_status(self, id: int) -> StatusResponse:
|
|
219
|
+
response = self._call_api("getStatus", {"id": id})
|
|
220
|
+
parsed = self._parser.parse(response.body)
|
|
221
|
+
self._ensure_success(parsed, response)
|
|
222
|
+
return StatusResponse(
|
|
223
|
+
status=parsed.data["status"],
|
|
224
|
+
code=parsed.data.get("code"),
|
|
225
|
+
sms_text=parsed.data.get("sms_text"),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def get_status_v2(self, id: int) -> Dict[str, Any]:
|
|
229
|
+
response = self._call_api("getStatusV2", {"id": id})
|
|
230
|
+
parsed = self._parser.parse(response.body)
|
|
231
|
+
self._ensure_success(parsed, response)
|
|
232
|
+
return parsed.data["decoded"]
|
|
233
|
+
|
|
234
|
+
def get_active_activations(self) -> List[Dict[str, Any]]:
|
|
235
|
+
response = self._call_api("getActiveActivations")
|
|
236
|
+
parsed = self._parser.parse(response.body)
|
|
237
|
+
self._ensure_success(parsed, response)
|
|
238
|
+
decoded = parsed.data["decoded"]
|
|
239
|
+
return decoded.get("activeActivations", [])
|
|
240
|
+
|
|
241
|
+
def check_extra_activation(self, id: int) -> Dict[str, Any]:
|
|
242
|
+
response = self._call_api("checkExtraActivation", {"id": id})
|
|
243
|
+
parsed = self._parser.parse(response.body)
|
|
244
|
+
self._ensure_success(parsed, response)
|
|
245
|
+
return parsed.data["decoded"]
|
|
246
|
+
|
|
247
|
+
def get_extra_activation(self, id: int) -> NumberResponse:
|
|
248
|
+
response = self._call_api("getExtraActivation", {"id": id})
|
|
249
|
+
parsed = self._parser.parse(response.body)
|
|
250
|
+
self._ensure_success(parsed, response)
|
|
251
|
+
return NumberResponse(
|
|
252
|
+
activation_id=parsed.data["activation_id"],
|
|
253
|
+
phone_number=parsed.data["phone_number"],
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def get_top_countries_by_service(self, service: str) -> List[Dict[str, Any]]:
|
|
257
|
+
response = self._call_api("getListOfTopCountriesByService", {"service": service})
|
|
258
|
+
parsed = self._parser.parse(response.body)
|
|
259
|
+
self._ensure_success(parsed, response)
|
|
260
|
+
return parsed.data["decoded"]
|
|
261
|
+
|
|
262
|
+
def get_notifications(self) -> Dict[str, Any]:
|
|
263
|
+
response = self._call_api("getNotifications")
|
|
264
|
+
parsed = self._parser.parse(response.body)
|
|
265
|
+
self._ensure_success(parsed, response)
|
|
266
|
+
return parsed.data["decoded"]
|
|
267
|
+
|
|
268
|
+
def _call_api(self, action: str, params: Optional[Dict[str, Any]] = None) -> Response:
|
|
269
|
+
query_params: Dict[str, Any] = {"api_key": self._api_key, "action": action}
|
|
270
|
+
if params:
|
|
271
|
+
for key, value in params.items():
|
|
272
|
+
if value is not None:
|
|
273
|
+
query_params[key] = value
|
|
274
|
+
|
|
275
|
+
url = self._base_url + "/stubs/handler_api?" + urlencode(query_params)
|
|
276
|
+
headers = self._build_headers()
|
|
277
|
+
return self._transport.send(url, headers)
|
|
278
|
+
|
|
279
|
+
def _build_headers(self) -> Dict[str, str]:
|
|
280
|
+
return {
|
|
281
|
+
"X-SDK-Version": self.VERSION,
|
|
282
|
+
"X-SDK-Language": self.SDK_LANGUAGE,
|
|
283
|
+
"X-SDK-Machine-Id": self._machine_id,
|
|
284
|
+
"X-SDK-Timestamp": datetime.now(timezone.utc).isoformat(),
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
def _ensure_success(self, parsed, response: Response) -> None:
|
|
288
|
+
if not parsed.is_success:
|
|
289
|
+
retry_after = None
|
|
290
|
+
if response.headers.get("Retry-After"):
|
|
291
|
+
retry_after = int(response.headers["Retry-After"])
|
|
292
|
+
raise VirtualSMSException.from_error_code(
|
|
293
|
+
error_code=parsed.error_code or "UNKNOWN",
|
|
294
|
+
http_status=response.status_code,
|
|
295
|
+
retry_after=retry_after,
|
|
296
|
+
)
|
virtualsms/constants.py
ADDED
virtualsms/exceptions.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class VirtualSMSException(Exception):
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
message: str,
|
|
10
|
+
error_code: str,
|
|
11
|
+
http_status: int,
|
|
12
|
+
retry_after: Optional[int] = None,
|
|
13
|
+
):
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.error_code = error_code
|
|
16
|
+
self.http_status = http_status
|
|
17
|
+
self.retry_after = retry_after
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def from_error_code(
|
|
21
|
+
error_code: str,
|
|
22
|
+
http_status: int,
|
|
23
|
+
retry_after: Optional[int] = None,
|
|
24
|
+
) -> "VirtualSMSException":
|
|
25
|
+
message = VirtualSMSException._message_for_code(error_code)
|
|
26
|
+
|
|
27
|
+
if error_code in ("BAD_KEY", "BANNED", "PURCHASE_RESTRICTED", "SERVICE_RESTRICTED"):
|
|
28
|
+
return AuthenticationException(message, error_code, http_status)
|
|
29
|
+
|
|
30
|
+
if error_code == "NO_BALANCE":
|
|
31
|
+
return InsufficientBalanceException(message, error_code, http_status)
|
|
32
|
+
|
|
33
|
+
if error_code == "NO_NUMBERS":
|
|
34
|
+
return NoNumbersException(message, error_code, http_status)
|
|
35
|
+
|
|
36
|
+
if error_code in (
|
|
37
|
+
"WRONG_SERVICE",
|
|
38
|
+
"WRONG_COUNTRY",
|
|
39
|
+
"BAD_ACTION",
|
|
40
|
+
"BAD_STATUS",
|
|
41
|
+
"NO_PRICES",
|
|
42
|
+
"INVALID_PROVIDER",
|
|
43
|
+
"WRONG_EXCEPTION_PHONE",
|
|
44
|
+
"WRONG_SECURITY",
|
|
45
|
+
):
|
|
46
|
+
return ValidationException(message, error_code, http_status)
|
|
47
|
+
|
|
48
|
+
if error_code in (
|
|
49
|
+
"NO_ACTIVATION",
|
|
50
|
+
"WRONG_ACTIVATION_ID",
|
|
51
|
+
"EARLY_CANCEL_DENIED",
|
|
52
|
+
"RENEW_ACTIVATION_NOT_AVAILABLE",
|
|
53
|
+
"NEW_ACTIVATION_IMPOSSIBLE",
|
|
54
|
+
"SIM_OFFLINE",
|
|
55
|
+
):
|
|
56
|
+
return ActivationException(message, error_code, http_status)
|
|
57
|
+
|
|
58
|
+
if error_code == "CONCURRENT_LIMIT":
|
|
59
|
+
return RateLimitException(message, error_code, http_status, retry_after or 0)
|
|
60
|
+
|
|
61
|
+
return ServerException(message, error_code, http_status)
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def _message_for_code(error_code: str) -> str:
|
|
65
|
+
messages = {
|
|
66
|
+
"BAD_KEY": "Invalid or missing API key.",
|
|
67
|
+
"BANNED": "Account is banned or IP is blocked.",
|
|
68
|
+
"PURCHASE_RESTRICTED": "User is restricted from purchasing numbers.",
|
|
69
|
+
"SERVICE_RESTRICTED": "This service is restricted for your account.",
|
|
70
|
+
"NO_BALANCE": "Insufficient balance to complete the operation.",
|
|
71
|
+
"NO_NUMBERS": "No phone numbers available for the requested service and country.",
|
|
72
|
+
"WRONG_SERVICE": "Invalid or missing service code.",
|
|
73
|
+
"WRONG_COUNTRY": "Invalid or missing country ID.",
|
|
74
|
+
"BAD_ACTION": "Invalid or unknown action.",
|
|
75
|
+
"BAD_STATUS": "Invalid status code provided to setStatus.",
|
|
76
|
+
"NO_PRICES": "No pricing data available for the requested parameters.",
|
|
77
|
+
"INVALID_PROVIDER": "The specified pool provider was not found or is disabled.",
|
|
78
|
+
"WRONG_EXCEPTION_PHONE": "Invalid phone exception parameter.",
|
|
79
|
+
"WRONG_SECURITY": "Security validation failed.",
|
|
80
|
+
"NO_ACTIVATION": "Activation not found or does not belong to this account.",
|
|
81
|
+
"WRONG_ACTIVATION_ID": "Invalid activation ID.",
|
|
82
|
+
"EARLY_CANCEL_DENIED": "Cannot cancel within the first 5 minutes of activation.",
|
|
83
|
+
"RENEW_ACTIVATION_NOT_AVAILABLE": "This number is not available for reactivation.",
|
|
84
|
+
"NEW_ACTIVATION_IMPOSSIBLE": "Cannot create an additional activation on this number.",
|
|
85
|
+
"SIM_OFFLINE": "The SIM card is currently offline.",
|
|
86
|
+
"CONCURRENT_LIMIT": "Too many concurrent activations.",
|
|
87
|
+
"ERROR_SQL": "An internal server error occurred.",
|
|
88
|
+
"NO_METRICS": "Not enough data available for this service.",
|
|
89
|
+
"NO_ACTIVATIONS": "No active activations found.",
|
|
90
|
+
}
|
|
91
|
+
return messages.get(error_code, "An unexpected error occurred.")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class AuthenticationException(VirtualSMSException):
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class InsufficientBalanceException(VirtualSMSException):
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class NoNumbersException(VirtualSMSException):
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ValidationException(VirtualSMSException):
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ActivationException(VirtualSMSException):
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class RateLimitException(VirtualSMSException):
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
message: str,
|
|
118
|
+
error_code: str,
|
|
119
|
+
http_status: int,
|
|
120
|
+
retry_after: int = 0,
|
|
121
|
+
):
|
|
122
|
+
super().__init__(message, error_code, http_status, retry_after=retry_after)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ServerException(VirtualSMSException):
|
|
126
|
+
pass
|
virtualsms/parser.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_ERROR_CODES: List[str] = [
|
|
9
|
+
"BAD_KEY",
|
|
10
|
+
"BANNED",
|
|
11
|
+
"NO_BALANCE",
|
|
12
|
+
"NO_NUMBERS",
|
|
13
|
+
"WRONG_SERVICE",
|
|
14
|
+
"WRONG_COUNTRY",
|
|
15
|
+
"NO_ACTIVATION",
|
|
16
|
+
"BAD_ACTION",
|
|
17
|
+
"BAD_STATUS",
|
|
18
|
+
"EARLY_CANCEL_DENIED",
|
|
19
|
+
"WRONG_ACTIVATION_ID",
|
|
20
|
+
"RENEW_ACTIVATION_NOT_AVAILABLE",
|
|
21
|
+
"PURCHASE_RESTRICTED",
|
|
22
|
+
"SERVICE_RESTRICTED",
|
|
23
|
+
"CONCURRENT_LIMIT",
|
|
24
|
+
"NO_PRICES",
|
|
25
|
+
"INVALID_PROVIDER",
|
|
26
|
+
"ERROR_SQL",
|
|
27
|
+
"NO_METRICS",
|
|
28
|
+
"NO_ACTIVATIONS",
|
|
29
|
+
"NEW_ACTIVATION_IMPOSSIBLE",
|
|
30
|
+
"SIM_OFFLINE",
|
|
31
|
+
"WRONG_EXCEPTION_PHONE",
|
|
32
|
+
"WRONG_SECURITY",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
_STATUS_TYPES = ["STATUS_WAIT_CODE", "STATUS_WAIT_RETRY", "STATUS_CANCEL"]
|
|
36
|
+
_SET_STATUS_TYPES = ["ACCESS_READY", "ACCESS_RETRY_GET", "ACCESS_ACTIVATION", "ACCESS_CANCEL"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ParsedResponse:
|
|
41
|
+
is_success: bool = False
|
|
42
|
+
type: str = ""
|
|
43
|
+
data: Dict[str, Any] = field(default_factory=dict)
|
|
44
|
+
error_code: Optional[str] = None
|
|
45
|
+
error_message: Optional[str] = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TextResponseParser:
|
|
49
|
+
def parse(self, body: str) -> ParsedResponse:
|
|
50
|
+
body = body.strip()
|
|
51
|
+
|
|
52
|
+
if body == "":
|
|
53
|
+
return ParsedResponse(
|
|
54
|
+
is_success=False,
|
|
55
|
+
error_code="UNKNOWN",
|
|
56
|
+
error_message="Empty response body.",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
decoded = json.loads(body)
|
|
61
|
+
if isinstance(decoded, (dict, list)):
|
|
62
|
+
return self._parse_json(decoded, body)
|
|
63
|
+
except (json.JSONDecodeError, ValueError):
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
if body.startswith("ACCESS_BALANCE:"):
|
|
67
|
+
balance = float(body[len("ACCESS_BALANCE:") :])
|
|
68
|
+
return ParsedResponse(
|
|
69
|
+
is_success=True,
|
|
70
|
+
type="balance",
|
|
71
|
+
data={"balance": balance},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if body.startswith("ACCESS_NUMBER:"):
|
|
75
|
+
parts = body.split(":", 2)
|
|
76
|
+
if len(parts) == 3:
|
|
77
|
+
return ParsedResponse(
|
|
78
|
+
is_success=True,
|
|
79
|
+
type="number",
|
|
80
|
+
data={
|
|
81
|
+
"activation_id": int(parts[1]),
|
|
82
|
+
"phone_number": parts[2],
|
|
83
|
+
},
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if body.startswith("STATUS_OK:"):
|
|
87
|
+
code = body[len("STATUS_OK:") :]
|
|
88
|
+
return ParsedResponse(
|
|
89
|
+
is_success=True,
|
|
90
|
+
type="status",
|
|
91
|
+
data={
|
|
92
|
+
"status": "STATUS_OK",
|
|
93
|
+
"code": code if code != "" else None,
|
|
94
|
+
"sms_text": None,
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if body == "STATUS_OK":
|
|
99
|
+
return ParsedResponse(
|
|
100
|
+
is_success=True,
|
|
101
|
+
type="status",
|
|
102
|
+
data={"status": "STATUS_OK", "code": None, "sms_text": None},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if body.startswith("FULL_SMS:"):
|
|
106
|
+
sms_text = body[len("FULL_SMS:") :]
|
|
107
|
+
return ParsedResponse(
|
|
108
|
+
is_success=True,
|
|
109
|
+
type="status",
|
|
110
|
+
data={"status": "FULL_SMS", "code": None, "sms_text": sms_text},
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
for status in _STATUS_TYPES:
|
|
114
|
+
if body == status:
|
|
115
|
+
return ParsedResponse(
|
|
116
|
+
is_success=True,
|
|
117
|
+
type="status",
|
|
118
|
+
data={"status": status, "code": None, "sms_text": None},
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
for status in _SET_STATUS_TYPES:
|
|
122
|
+
if body == status:
|
|
123
|
+
return ParsedResponse(
|
|
124
|
+
is_success=True,
|
|
125
|
+
type="set_status",
|
|
126
|
+
data={"status": status},
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
error_code = self._detect_error_code(body)
|
|
130
|
+
if error_code is not None:
|
|
131
|
+
return ParsedResponse(is_success=False, error_code=error_code)
|
|
132
|
+
|
|
133
|
+
return ParsedResponse(
|
|
134
|
+
is_success=False,
|
|
135
|
+
error_code="UNKNOWN",
|
|
136
|
+
error_message=body,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def _parse_json(self, decoded: Any, raw: str) -> ParsedResponse:
|
|
140
|
+
if isinstance(decoded, dict):
|
|
141
|
+
if decoded.get("status") == "error":
|
|
142
|
+
error_code = decoded.get("errorCode", "UNKNOWN")
|
|
143
|
+
error_message = decoded.get("error", "Unknown error.")
|
|
144
|
+
return ParsedResponse(
|
|
145
|
+
is_success=False,
|
|
146
|
+
error_code=error_code,
|
|
147
|
+
error_message=error_message,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return ParsedResponse(
|
|
151
|
+
is_success=True,
|
|
152
|
+
type="json",
|
|
153
|
+
data={"raw": raw, "decoded": decoded},
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def _detect_error_code(self, body: str) -> Optional[str]:
|
|
157
|
+
for code in _ERROR_CODES:
|
|
158
|
+
if body == code:
|
|
159
|
+
return code
|
|
160
|
+
return None
|
virtualsms/response.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class BalanceResponse:
|
|
9
|
+
balance: float
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class NumberResponse:
|
|
14
|
+
activation_id: int
|
|
15
|
+
phone_number: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class StatusResponse:
|
|
20
|
+
status: str
|
|
21
|
+
code: Optional[str] = None
|
|
22
|
+
sms_text: Optional[str] = None
|
virtualsms/transport.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import urllib.error
|
|
4
|
+
import urllib.request
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Dict, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Response:
|
|
12
|
+
status_code: int
|
|
13
|
+
body: str
|
|
14
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Transport(ABC):
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def send(self, url: str, headers: dict) -> Response:
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UrllibTransport(Transport):
|
|
24
|
+
def __init__(self, timeout: int = 30):
|
|
25
|
+
self.timeout = timeout
|
|
26
|
+
|
|
27
|
+
def send(self, url: str, headers: dict) -> Response:
|
|
28
|
+
request = urllib.request.Request(url, method="GET")
|
|
29
|
+
for key, value in headers.items():
|
|
30
|
+
request.add_header(key, value)
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as resp:
|
|
34
|
+
body = resp.read().decode("utf-8")
|
|
35
|
+
status_code = resp.getcode()
|
|
36
|
+
response_headers = {k: v for k, v in resp.getheaders()}
|
|
37
|
+
return Response(status_code, body, response_headers)
|
|
38
|
+
except urllib.error.HTTPError as e:
|
|
39
|
+
body = e.read().decode("utf-8") if e.fp else ""
|
|
40
|
+
response_headers = {k: v for k, v in e.headers.items()} if e.headers else {}
|
|
41
|
+
return Response(e.code, body, response_headers)
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: virtualsmslabs
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python SDK for the VirtualSMS Consumer API — SMS verification, phone number rental, and activation management.
|
|
5
|
+
Project-URL: Homepage, https://virtualsms.de
|
|
6
|
+
Project-URL: Documentation, https://virtualsms.de/docs/sdk
|
|
7
|
+
Project-URL: Repository, https://github.com/VirtualSMSLabs/python-sdk
|
|
8
|
+
Project-URL: Issues, https://github.com/VirtualSMSLabs/python-sdk/issues
|
|
9
|
+
Author-email: VirtualSMS Labs <contact@virtualsms.de>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: otp,phone,sms,sms-activate,verification,virtual,virtualsms
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Communications :: Telephony
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# VirtualSMS Python SDK
|
|
28
|
+
|
|
29
|
+
Python SDK for the VirtualSMS Consumer API — SMS verification, phone number rental, and activation management.
|
|
30
|
+
|
|
31
|
+
[](https://pypi.org/project/virtualsms/)
|
|
32
|
+
[](LICENSE)
|
|
33
|
+
[](https://www.python.org/)
|
|
34
|
+
|
|
35
|
+
## Requirements
|
|
36
|
+
|
|
37
|
+
- Python 3.9 or higher
|
|
38
|
+
- No external dependencies (uses standard library only)
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install virtualsms
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quick Start
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from virtualsms import VirtualSMSClient, ActivationStatus
|
|
50
|
+
|
|
51
|
+
client = VirtualSMSClient("YOUR_API_KEY", "https://api.virtualsms.de")
|
|
52
|
+
|
|
53
|
+
# Check balance
|
|
54
|
+
balance = client.get_balance()
|
|
55
|
+
print(f"Balance: ${balance.balance:.2f}")
|
|
56
|
+
|
|
57
|
+
# Order a WhatsApp number in Brazil
|
|
58
|
+
number = client.get_number("wa", 73, max_price=2.00)
|
|
59
|
+
print(f"Number: {number.phone_number} (ID: {number.activation_id})")
|
|
60
|
+
|
|
61
|
+
# Set status to ready (SMS sent)
|
|
62
|
+
client.set_status(number.activation_id, ActivationStatus.READY)
|
|
63
|
+
|
|
64
|
+
# Poll for SMS code
|
|
65
|
+
status = client.get_status(number.activation_id)
|
|
66
|
+
if status.code is not None:
|
|
67
|
+
print(f"SMS code: {status.code}")
|
|
68
|
+
# Complete the activation
|
|
69
|
+
client.set_status(number.activation_id, ActivationStatus.COMPLETE)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## API Reference
|
|
73
|
+
|
|
74
|
+
### Client Constructor
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
client = VirtualSMSClient(
|
|
78
|
+
api_key="YOUR_API_KEY", # Your API key (required)
|
|
79
|
+
base_url="https://api.virtualsms.de", # API base URL (optional)
|
|
80
|
+
transport=None, # Optional custom transport
|
|
81
|
+
)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Methods
|
|
85
|
+
|
|
86
|
+
#### Account
|
|
87
|
+
|
|
88
|
+
##### `get_balance() -> BalanceResponse`
|
|
89
|
+
|
|
90
|
+
Returns the current account balance.
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
balance = client.get_balance()
|
|
94
|
+
print(balance.balance) # 10.50
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
#### Information & Pricing
|
|
98
|
+
|
|
99
|
+
##### `get_countries(pool_provider=None) -> dict`
|
|
100
|
+
|
|
101
|
+
Returns all available countries.
|
|
102
|
+
|
|
103
|
+
##### `get_services_list(country=None, lang=None) -> dict`
|
|
104
|
+
|
|
105
|
+
Returns available services for a country.
|
|
106
|
+
|
|
107
|
+
##### `get_operators(country, pool_provider=None) -> list`
|
|
108
|
+
|
|
109
|
+
Returns available mobile operators for a country.
|
|
110
|
+
|
|
111
|
+
##### `get_prices(service=None, country=None, pool_provider=None) -> dict`
|
|
112
|
+
|
|
113
|
+
Returns pricing data organized by country and service.
|
|
114
|
+
|
|
115
|
+
##### `get_prices_extended(service=None, country=None, free_price=None, pool_provider=None) -> dict`
|
|
116
|
+
|
|
117
|
+
Returns extended pricing with price tiers.
|
|
118
|
+
|
|
119
|
+
##### `get_prices_verification(service=None, pool_provider=None) -> dict`
|
|
120
|
+
|
|
121
|
+
Returns pricing in inverted format (service → country).
|
|
122
|
+
|
|
123
|
+
##### `get_numbers_status(country, operator=None, pool_provider=None) -> dict`
|
|
124
|
+
|
|
125
|
+
Returns available phone quantity per service.
|
|
126
|
+
|
|
127
|
+
##### `get_top_countries_by_service(service) -> list`
|
|
128
|
+
|
|
129
|
+
Returns top 10 countries for a service, ranked by purchase share and success rate.
|
|
130
|
+
|
|
131
|
+
#### Ordering Numbers
|
|
132
|
+
|
|
133
|
+
##### `get_number(service, country, **options) -> NumberResponse`
|
|
134
|
+
|
|
135
|
+
Orders a phone number. Returns text format response.
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
number = client.get_number(
|
|
139
|
+
service="wa",
|
|
140
|
+
country=73,
|
|
141
|
+
max_price=2.00,
|
|
142
|
+
operator="claro",
|
|
143
|
+
forward=True,
|
|
144
|
+
)
|
|
145
|
+
print(number.activation_id) # 123
|
|
146
|
+
print(number.phone_number) # 447777777777
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
**Options:**
|
|
150
|
+
|
|
151
|
+
| Parameter | Type | Description |
|
|
152
|
+
|-----------|------|-------------|
|
|
153
|
+
| `max_price` | `float` | Maximum price willing to pay |
|
|
154
|
+
| `operator` | `str` | Mobile operator filter |
|
|
155
|
+
| `phone_exception` | `str` | Phone prefixes to exclude (comma-separated) |
|
|
156
|
+
| `forward` | `bool` | Enable call forwarding |
|
|
157
|
+
| `activation_type` | `int` | Activation type: 0=SMS, 1=number, 2=voice |
|
|
158
|
+
| `language` | `str` | Language for voice activation |
|
|
159
|
+
| `use_cashback` | `bool` | Use cashback balance first |
|
|
160
|
+
| `user_id` | `str` | End-user ID for tracking |
|
|
161
|
+
| `ref` | `str` | Referral ID |
|
|
162
|
+
| `pool_provider` | `str` | Pool provider: alpha, prime, gamma, zeta |
|
|
163
|
+
|
|
164
|
+
##### `get_number_v2(service, country, **options) -> dict`
|
|
165
|
+
|
|
166
|
+
Same as `get_number` but returns JSON with additional fields. Supports `order_id` for idempotency.
|
|
167
|
+
|
|
168
|
+
#### Activation Management
|
|
169
|
+
|
|
170
|
+
##### `set_status(id, status) -> str`
|
|
171
|
+
|
|
172
|
+
Changes activation status.
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
from virtualsms import ActivationStatus
|
|
176
|
+
|
|
177
|
+
client.set_status(activation_id, ActivationStatus.READY) # 1 - SMS sent
|
|
178
|
+
client.set_status(activation_id, ActivationStatus.RETRY) # 3 - Request another SMS
|
|
179
|
+
client.set_status(activation_id, ActivationStatus.COMPLETE) # 6 - Finish
|
|
180
|
+
client.set_status(activation_id, ActivationStatus.CANCEL) # 8 - Cancel
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
##### `get_status(id) -> StatusResponse`
|
|
184
|
+
|
|
185
|
+
Returns activation status in text format.
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
status = client.get_status(activation_id)
|
|
189
|
+
print(status.status) # STATUS_OK, STATUS_WAIT_CODE, STATUS_CANCEL
|
|
190
|
+
print(status.code) # 123456 (None if not yet received)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
##### `get_status_v2(id) -> dict`
|
|
194
|
+
|
|
195
|
+
Returns activation status in JSON format with SMS/call details.
|
|
196
|
+
|
|
197
|
+
##### `get_active_activations() -> list`
|
|
198
|
+
|
|
199
|
+
Returns all currently active activations.
|
|
200
|
+
|
|
201
|
+
##### `check_extra_activation(id) -> dict`
|
|
202
|
+
|
|
203
|
+
Checks if a number is available for reactivation.
|
|
204
|
+
|
|
205
|
+
##### `get_extra_activation(id) -> NumberResponse`
|
|
206
|
+
|
|
207
|
+
Creates an extra activation on a previously used number.
|
|
208
|
+
|
|
209
|
+
#### Notifications
|
|
210
|
+
|
|
211
|
+
##### `get_notifications() -> dict`
|
|
212
|
+
|
|
213
|
+
Returns user notifications including penalties, low balance alerts, and admin messages.
|
|
214
|
+
|
|
215
|
+
### Constants
|
|
216
|
+
|
|
217
|
+
#### ActivationStatus
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
ActivationStatus.READY # 1 - SMS has been sent to the number
|
|
221
|
+
ActivationStatus.RETRY # 3 - Request another SMS code
|
|
222
|
+
ActivationStatus.COMPLETE # 6 - Finish activation
|
|
223
|
+
ActivationStatus.CANCEL # 8 - Cancel activation
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
#### PoolProvider
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
PoolProvider.ALPHA # 'alpha'
|
|
230
|
+
PoolProvider.PRIME # 'prime'
|
|
231
|
+
PoolProvider.GAMMA # 'gamma'
|
|
232
|
+
PoolProvider.ZETA # 'zeta'
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## Error Handling
|
|
236
|
+
|
|
237
|
+
The SDK raises typed exceptions for all API errors. Each error code maps to a specific exception class:
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
from virtualsms import (
|
|
241
|
+
VirtualSMSClient,
|
|
242
|
+
VirtualSMSException,
|
|
243
|
+
AuthenticationException,
|
|
244
|
+
InsufficientBalanceException,
|
|
245
|
+
NoNumbersException,
|
|
246
|
+
ValidationException,
|
|
247
|
+
ActivationException,
|
|
248
|
+
RateLimitException,
|
|
249
|
+
ServerException,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
number = client.get_number("wa", 73)
|
|
254
|
+
except AuthenticationException:
|
|
255
|
+
# BAD_KEY, BANNED, PURCHASE_RESTRICTED, SERVICE_RESTRICTED
|
|
256
|
+
pass
|
|
257
|
+
except InsufficientBalanceException:
|
|
258
|
+
# NO_BALANCE
|
|
259
|
+
pass
|
|
260
|
+
except NoNumbersException:
|
|
261
|
+
# NO_NUMBERS
|
|
262
|
+
pass
|
|
263
|
+
except ValidationException:
|
|
264
|
+
# WRONG_SERVICE, WRONG_COUNTRY, BAD_ACTION, BAD_STATUS, NO_PRICES, INVALID_PROVIDER
|
|
265
|
+
pass
|
|
266
|
+
except ActivationException:
|
|
267
|
+
# NO_ACTIVATION, WRONG_ACTIVATION_ID, EARLY_CANCEL_DENIED, RENEW_ACTIVATION_NOT_AVAILABLE
|
|
268
|
+
pass
|
|
269
|
+
except RateLimitException as e:
|
|
270
|
+
# CONCURRENT_LIMIT — check e.retry_after
|
|
271
|
+
pass
|
|
272
|
+
except ServerException:
|
|
273
|
+
# ERROR_SQL, unknown errors
|
|
274
|
+
pass
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### Error Code Reference
|
|
278
|
+
|
|
279
|
+
| Error Code | Exception | Description |
|
|
280
|
+
|-----------|-----------|-------------|
|
|
281
|
+
| `BAD_KEY` | `AuthenticationException` | Invalid API key |
|
|
282
|
+
| `BANNED` | `AuthenticationException` | Account banned or IP blocked |
|
|
283
|
+
| `PURCHASE_RESTRICTED` | `AuthenticationException` | User restricted from purchasing |
|
|
284
|
+
| `SERVICE_RESTRICTED` | `AuthenticationException` | Service restricted for account |
|
|
285
|
+
| `NO_BALANCE` | `InsufficientBalanceException` | Insufficient balance |
|
|
286
|
+
| `NO_NUMBERS` | `NoNumbersException` | No numbers available |
|
|
287
|
+
| `WRONG_SERVICE` | `ValidationException` | Invalid service code |
|
|
288
|
+
| `WRONG_COUNTRY` | `ValidationException` | Invalid country ID |
|
|
289
|
+
| `BAD_ACTION` | `ValidationException` | Invalid action |
|
|
290
|
+
| `BAD_STATUS` | `ValidationException` | Invalid status code |
|
|
291
|
+
| `NO_PRICES` | `ValidationException` | No pricing data available |
|
|
292
|
+
| `INVALID_PROVIDER` | `ValidationException` | Invalid pool provider |
|
|
293
|
+
| `NO_ACTIVATION` | `ActivationException` | Activation not found |
|
|
294
|
+
| `WRONG_ACTIVATION_ID` | `ActivationException` | Invalid activation ID |
|
|
295
|
+
| `EARLY_CANCEL_DENIED` | `ActivationException` | Cannot cancel within 5 minutes |
|
|
296
|
+
| `RENEW_ACTIVATION_NOT_AVAILABLE` | `ActivationException` | Number not available for reactivation |
|
|
297
|
+
| `CONCURRENT_LIMIT` | `RateLimitException` | Too many concurrent activations |
|
|
298
|
+
| `ERROR_SQL` | `ServerException` | Internal server error |
|
|
299
|
+
|
|
300
|
+
## Tracking Headers
|
|
301
|
+
|
|
302
|
+
The SDK sends anonymous tracking headers with every request for analytics and debugging:
|
|
303
|
+
|
|
304
|
+
| Header | Value | Privacy |
|
|
305
|
+
|--------|-------|---------|
|
|
306
|
+
| `X-SDK-Version` | `1.0.0` | SDK version string |
|
|
307
|
+
| `X-SDK-Language` | `python` | SDK language |
|
|
308
|
+
| `X-SDK-Machine-Id` | SHA-256 hash of `platform.platform()` + `sys.implementation.name` (truncated to 32 chars) | Irreversible hash — no hostname or IP exposed |
|
|
309
|
+
| `X-SDK-Timestamp` | ISO 8601 UTC timestamp | Request time |
|
|
310
|
+
|
|
311
|
+
No personally identifiable information is transmitted. The machine ID is a one-way hash and cannot be reversed to identify the source machine.
|
|
312
|
+
|
|
313
|
+
## Custom Transport
|
|
314
|
+
|
|
315
|
+
By default, the SDK uses `urllib.request` for HTTP requests. You can provide your own transport implementation:
|
|
316
|
+
|
|
317
|
+
```python
|
|
318
|
+
from virtualsms import VirtualSMSClient, Transport, Response
|
|
319
|
+
|
|
320
|
+
class MyTransport(Transport):
|
|
321
|
+
def send(self, url: str, headers: dict) -> Response:
|
|
322
|
+
# Your HTTP implementation
|
|
323
|
+
return Response(status_code=200, body="ACCESS_BALANCE:10.50")
|
|
324
|
+
|
|
325
|
+
client = VirtualSMSClient("API_KEY", "https://api.example.com", transport=MyTransport())
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
## Examples
|
|
329
|
+
|
|
330
|
+
See the `examples/` directory:
|
|
331
|
+
|
|
332
|
+
- [`balance.py`](examples/balance.py) — Check account balance
|
|
333
|
+
- [`order_number.py`](examples/order_number.py) — Order a phone number
|
|
334
|
+
- [`full_workflow.py`](examples/full_workflow.py) — Complete SMS verification workflow
|
|
335
|
+
|
|
336
|
+
## Testing
|
|
337
|
+
|
|
338
|
+
```bash
|
|
339
|
+
pip install pytest
|
|
340
|
+
pytest
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
## License
|
|
344
|
+
|
|
345
|
+
MIT — see [LICENSE](LICENSE).
|
|
346
|
+
|
|
347
|
+
## Links
|
|
348
|
+
|
|
349
|
+
- [PyPI](https://pypi.org/project/virtualsms/)
|
|
350
|
+
- [GitHub](https://github.com/VirtualSMSLabs/python-sdk)
|
|
351
|
+
- [API Documentation](https://virtualsms.de/docs)
|
|
352
|
+
- [MCP Integration](https://virtualsms.de/docs/mcp)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
virtualsms/__init__.py,sha256=tpdXEuFgdolHHxQ4Kp8PJsLMcSvS73fAbJK4kdHgjJo,935
|
|
2
|
+
virtualsms/client.py,sha256=FKlMvuKc-uWVFXXzVPhKhY_wj1Ka2HQ5KuVZ3eFXHU0,11209
|
|
3
|
+
virtualsms/constants.py,sha256=dtpxUVPPd3cJ8Ml3SGRkvPwrGsWpm0W-ILulGuJf-bQ,221
|
|
4
|
+
virtualsms/exceptions.py,sha256=W5evMaR-vyjd59CE93XuofzIy5IJ9NFD0-aViK1qN5g,4525
|
|
5
|
+
virtualsms/parser.py,sha256=a0z50BbgKfRWGceBHP0gIvqET0AVSVkpfAJINpzPiOU,4802
|
|
6
|
+
virtualsms/response.py,sha256=G6UV5lj6lvrA2lXj0VuVH5Ngr2XZUeSO9mEUMGymHb8,350
|
|
7
|
+
virtualsms/transport.py,sha256=fuX61xh5HUF1FWixXWRYdaQUwxVehUm6fUFBfbmm9nk,1322
|
|
8
|
+
virtualsmslabs-1.0.0.dist-info/METADATA,sha256=RKvmLV1TwWwiBsyo4_pViNYkX8ejeb1F3YhVVzP-kZQ,11053
|
|
9
|
+
virtualsmslabs-1.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
10
|
+
virtualsmslabs-1.0.0.dist-info/licenses/LICENSE,sha256=TiJ43kHbayLnloMYbsfwBE-dnVUuta3a6mhbzsXo6Y4,1072
|
|
11
|
+
virtualsmslabs-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 VirtualSMS Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|