verixo 0.1.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.
- verixo-0.1.0/.gitignore +7 -0
- verixo-0.1.0/PKG-INFO +93 -0
- verixo-0.1.0/README.md +80 -0
- verixo-0.1.0/examples/quickstart.py +36 -0
- verixo-0.1.0/pyproject.toml +26 -0
- verixo-0.1.0/src/verixo/__init__.py +55 -0
- verixo-0.1.0/src/verixo/client.py +54 -0
- verixo-0.1.0/src/verixo/errors.py +55 -0
- verixo-0.1.0/src/verixo/py.typed +0 -0
- verixo-0.1.0/src/verixo/resources/__init__.py +0 -0
- verixo-0.1.0/src/verixo/resources/analytics.py +14 -0
- verixo-0.1.0/src/verixo/resources/numbers.py +54 -0
- verixo-0.1.0/src/verixo/resources/sessions.py +38 -0
- verixo-0.1.0/src/verixo/subscribe.py +94 -0
- verixo-0.1.0/src/verixo/types.py +119 -0
verixo-0.1.0/.gitignore
ADDED
verixo-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: verixo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Verixo OTP-as-a-Service API
|
|
5
|
+
Project-URL: Homepage, https://verifiedcore.com
|
|
6
|
+
Project-URL: Repository, https://github.com/titicodes/verixo
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Keywords: otp,phone-verification,sms-verification,verixo
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Requires-Dist: requests>=2.27
|
|
11
|
+
Requires-Dist: websocket-client>=1.6
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# verixo
|
|
15
|
+
|
|
16
|
+
Official Python SDK for the [Verixo](https://verifiedcore.com) OTP-as-a-Service API.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install verixo
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quickstart
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import os
|
|
28
|
+
import verixo
|
|
29
|
+
|
|
30
|
+
client = verixo.Client(os.environ["VC_API_KEY"])
|
|
31
|
+
|
|
32
|
+
result = client.numbers.search(
|
|
33
|
+
service_slug="whatsapp",
|
|
34
|
+
country_code="NG",
|
|
35
|
+
min_score=80,
|
|
36
|
+
limit=5,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
session = client.numbers.purchase(
|
|
40
|
+
service_slug="whatsapp",
|
|
41
|
+
country_code="NG",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def handle(push):
|
|
45
|
+
print(f"OTP: {push.otp} in {push.latency_ms}ms")
|
|
46
|
+
|
|
47
|
+
client.subscribe(session.session_token, handle)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Note: `purchase()` takes `service_slug` + `country_code`, not a specific
|
|
51
|
+
`number_id` -- the server picks the best available number by Health Score at
|
|
52
|
+
purchase time, since a candidate returned by `search()` may already be gone
|
|
53
|
+
by the time you'd reference it back.
|
|
54
|
+
|
|
55
|
+
## Polling instead of real-time push
|
|
56
|
+
|
|
57
|
+
`subscribe()` runs a background thread holding a persistent WebSocket
|
|
58
|
+
connection, which isn't always practical (e.g. one-shot scripts, serverless
|
|
59
|
+
functions). Use `sessions.wait_for_otp()` instead:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
result = client.sessions.wait_for_otp(session.session_token)
|
|
63
|
+
if result.status == "DELIVERED":
|
|
64
|
+
print(result.otp_code)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Errors
|
|
68
|
+
|
|
69
|
+
All non-2xx responses raise `verixo.VerixoError` with `.status`, and usually
|
|
70
|
+
`.code`/`.detail` from the API's error body:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from verixo import VerixoError
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
client.numbers.purchase(service_slug="whatsapp", country_code="NG")
|
|
77
|
+
except VerixoError as err:
|
|
78
|
+
if err.status == 402:
|
|
79
|
+
print("Insufficient wallet balance")
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Requirements
|
|
83
|
+
|
|
84
|
+
Python 3.8+.
|
|
85
|
+
|
|
86
|
+
## Development
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
python -m venv .venv
|
|
90
|
+
.venv/Scripts/activate # or `source .venv/bin/activate` on macOS/Linux
|
|
91
|
+
pip install -e .
|
|
92
|
+
python examples/quickstart.py # requires VC_API_KEY env var, see file header
|
|
93
|
+
```
|
verixo-0.1.0/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# verixo
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the [Verixo](https://verifiedcore.com) OTP-as-a-Service API.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install verixo
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quickstart
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import os
|
|
15
|
+
import verixo
|
|
16
|
+
|
|
17
|
+
client = verixo.Client(os.environ["VC_API_KEY"])
|
|
18
|
+
|
|
19
|
+
result = client.numbers.search(
|
|
20
|
+
service_slug="whatsapp",
|
|
21
|
+
country_code="NG",
|
|
22
|
+
min_score=80,
|
|
23
|
+
limit=5,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
session = client.numbers.purchase(
|
|
27
|
+
service_slug="whatsapp",
|
|
28
|
+
country_code="NG",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def handle(push):
|
|
32
|
+
print(f"OTP: {push.otp} in {push.latency_ms}ms")
|
|
33
|
+
|
|
34
|
+
client.subscribe(session.session_token, handle)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Note: `purchase()` takes `service_slug` + `country_code`, not a specific
|
|
38
|
+
`number_id` -- the server picks the best available number by Health Score at
|
|
39
|
+
purchase time, since a candidate returned by `search()` may already be gone
|
|
40
|
+
by the time you'd reference it back.
|
|
41
|
+
|
|
42
|
+
## Polling instead of real-time push
|
|
43
|
+
|
|
44
|
+
`subscribe()` runs a background thread holding a persistent WebSocket
|
|
45
|
+
connection, which isn't always practical (e.g. one-shot scripts, serverless
|
|
46
|
+
functions). Use `sessions.wait_for_otp()` instead:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
result = client.sessions.wait_for_otp(session.session_token)
|
|
50
|
+
if result.status == "DELIVERED":
|
|
51
|
+
print(result.otp_code)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Errors
|
|
55
|
+
|
|
56
|
+
All non-2xx responses raise `verixo.VerixoError` with `.status`, and usually
|
|
57
|
+
`.code`/`.detail` from the API's error body:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from verixo import VerixoError
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
client.numbers.purchase(service_slug="whatsapp", country_code="NG")
|
|
64
|
+
except VerixoError as err:
|
|
65
|
+
if err.status == 402:
|
|
66
|
+
print("Insufficient wallet balance")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Requirements
|
|
70
|
+
|
|
71
|
+
Python 3.8+.
|
|
72
|
+
|
|
73
|
+
## Development
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
python -m venv .venv
|
|
77
|
+
.venv/Scripts/activate # or `source .venv/bin/activate` on macOS/Linux
|
|
78
|
+
pip install -e .
|
|
79
|
+
python examples/quickstart.py # requires VC_API_KEY env var, see file header
|
|
80
|
+
```
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Run against a local dev gateway:
|
|
3
|
+
VC_API_KEY=<your JWT or vc_test_/vc_live_ key> python examples/quickstart.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
import verixo
|
|
10
|
+
|
|
11
|
+
client = verixo.Client(
|
|
12
|
+
os.environ["VC_API_KEY"],
|
|
13
|
+
base_url=os.environ.get("VC_BASE_URL", "http://localhost:8080"),
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
result = client.numbers.search(service_slug="whatsapp", country_code="NG", min_score=0, limit=5)
|
|
17
|
+
top_score = result.numbers[0].health_score if result.numbers else None
|
|
18
|
+
print(f"Found {len(result.numbers)} numbers, top score: {top_score}")
|
|
19
|
+
|
|
20
|
+
session = client.numbers.purchase(service_slug="whatsapp", country_code="NG", test_mode=True)
|
|
21
|
+
print(f"Purchased {session.e164_number}, session {session.session_token}")
|
|
22
|
+
|
|
23
|
+
received = {"done": False}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def handle(push: verixo.OtpPush) -> None:
|
|
27
|
+
print(f"OTP: {push.otp} in {push.latency_ms}ms")
|
|
28
|
+
received["done"] = True
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
unsubscribe = client.subscribe(session.session_token, handle)
|
|
32
|
+
for _ in range(50):
|
|
33
|
+
if received["done"]:
|
|
34
|
+
break
|
|
35
|
+
time.sleep(0.2)
|
|
36
|
+
unsubscribe()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "verixo"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for the Verixo OTP-as-a-Service API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
keywords = ["otp", "sms-verification", "verixo", "phone-verification"]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"requests>=2.27",
|
|
15
|
+
"websocket-client>=1.6",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://verifiedcore.com"
|
|
20
|
+
Repository = "https://github.com/titicodes/verixo"
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/verixo"]
|
|
24
|
+
|
|
25
|
+
[tool.mypy]
|
|
26
|
+
strict = true
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Callable, Optional
|
|
4
|
+
|
|
5
|
+
from .client import HttpClient
|
|
6
|
+
from .errors import VerixoError
|
|
7
|
+
from .resources.analytics import Analytics
|
|
8
|
+
from .resources.numbers import Numbers
|
|
9
|
+
from .resources.sessions import Sessions
|
|
10
|
+
from .subscribe import subscribe as _subscribe
|
|
11
|
+
from .types import (
|
|
12
|
+
DeliveryRate,
|
|
13
|
+
DeliveryRates,
|
|
14
|
+
OtpPush,
|
|
15
|
+
PurchaseResult,
|
|
16
|
+
SearchNumbersResult,
|
|
17
|
+
SessionStatus,
|
|
18
|
+
VirtualNumber,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Client",
|
|
23
|
+
"VerixoError",
|
|
24
|
+
"VirtualNumber",
|
|
25
|
+
"SearchNumbersResult",
|
|
26
|
+
"PurchaseResult",
|
|
27
|
+
"SessionStatus",
|
|
28
|
+
"DeliveryRate",
|
|
29
|
+
"DeliveryRates",
|
|
30
|
+
"OtpPush",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Client:
|
|
35
|
+
def __init__(self, api_key: str, base_url: Optional[str] = None, timeout: float = 30) -> None:
|
|
36
|
+
self._http = HttpClient(api_key, base_url, timeout=timeout)
|
|
37
|
+
self.numbers = Numbers(self._http)
|
|
38
|
+
self.sessions = Sessions(self._http)
|
|
39
|
+
self.analytics = Analytics(self._http)
|
|
40
|
+
|
|
41
|
+
def subscribe(self, session_token: str, on_otp: Callable[[OtpPush], None]) -> Callable[[], None]:
|
|
42
|
+
"""
|
|
43
|
+
Get pushed the OTP for a session in real time, instead of polling
|
|
44
|
+
``sessions.wait_for_otp()``. Returns an unsubscribe function.
|
|
45
|
+
|
|
46
|
+
Example::
|
|
47
|
+
|
|
48
|
+
session = client.numbers.purchase(service_slug="whatsapp", country_code="NG")
|
|
49
|
+
|
|
50
|
+
def handle(push):
|
|
51
|
+
print(f"OTP: {push.otp} in {push.latency_ms}ms")
|
|
52
|
+
|
|
53
|
+
client.subscribe(session.session_token, handle)
|
|
54
|
+
"""
|
|
55
|
+
return _subscribe(self._http, session_token, on_otp)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from .errors import VerixoError
|
|
8
|
+
|
|
9
|
+
_DEFAULT_BASE_URL = "https://api.verixo.com"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HttpClient:
|
|
13
|
+
def __init__(self, api_key: str, base_url: Optional[str] = None, timeout: float = 30) -> None:
|
|
14
|
+
if not api_key:
|
|
15
|
+
raise ValueError("Verixo: an API key (or JWT) is required, e.g. Client('vc_test_...')")
|
|
16
|
+
self.api_key = api_key
|
|
17
|
+
self.base_url = (base_url or _DEFAULT_BASE_URL).rstrip("/")
|
|
18
|
+
self.timeout = timeout
|
|
19
|
+
self._session = requests.Session()
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def _authorization_header(self) -> str:
|
|
23
|
+
# The gateway classifies the credential by sniffing the raw Authorization header for a
|
|
24
|
+
# vc_test_/vc_live_ prefix -- a "Bearer " prefix on an API key would make it misclassify
|
|
25
|
+
# the request as a (malformed) JWT and reject it, so API keys go out unprefixed.
|
|
26
|
+
is_api_key = self.api_key.startswith("vc_test_") or self.api_key.startswith("vc_live_")
|
|
27
|
+
return self.api_key if is_api_key else f"Bearer {self.api_key}"
|
|
28
|
+
|
|
29
|
+
def request(self, method: str, path: str, *, json: Optional[Dict[str, Any]] = None) -> Any:
|
|
30
|
+
response = self._session.request(
|
|
31
|
+
method,
|
|
32
|
+
f"{self.base_url}{path}",
|
|
33
|
+
json=json,
|
|
34
|
+
headers={"Authorization": self._authorization_header},
|
|
35
|
+
timeout=self.timeout,
|
|
36
|
+
)
|
|
37
|
+
if not response.ok:
|
|
38
|
+
raise VerixoError.from_response(response)
|
|
39
|
+
if response.status_code == 204 or not response.content:
|
|
40
|
+
return None
|
|
41
|
+
return response.json()
|
|
42
|
+
|
|
43
|
+
def get(self, path: str) -> Any:
|
|
44
|
+
return self.request("GET", path)
|
|
45
|
+
|
|
46
|
+
def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Any:
|
|
47
|
+
return self.request("POST", path, json=json)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def ws_origin(self) -> str:
|
|
51
|
+
"""ws:// or wss:// origin derived from base_url, for the OTP push subscription."""
|
|
52
|
+
if self.base_url.startswith("https"):
|
|
53
|
+
return "wss" + self.base_url[len("https"):]
|
|
54
|
+
return "ws" + self.base_url[len("http"):]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
_STATUS_FALLBACKS = {
|
|
6
|
+
401: "Missing or invalid API key.",
|
|
7
|
+
402: "Insufficient wallet balance.",
|
|
8
|
+
403: "You don't have access to this resource.",
|
|
9
|
+
404: "Not found.",
|
|
10
|
+
408: "Request timed out.",
|
|
11
|
+
422: "The request did not meet a required condition (e.g. min_score).",
|
|
12
|
+
429: "Rate limit exceeded.",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class VerixoError(Exception):
|
|
17
|
+
"""Raised for any non-2xx response from the Verixo API.
|
|
18
|
+
|
|
19
|
+
``status`` is always present; ``code``/``detail`` are populated when the
|
|
20
|
+
gateway returns a JSON error body (most do) -- a small number of paths
|
|
21
|
+
(e.g. an invalid API key rejected before reaching a controller) return an
|
|
22
|
+
empty body, in which case only ``status`` and a generic message are
|
|
23
|
+
available.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
message: str,
|
|
29
|
+
*,
|
|
30
|
+
status: int,
|
|
31
|
+
code: Optional[str] = None,
|
|
32
|
+
detail: Optional[str] = None,
|
|
33
|
+
raw: Any = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.status = status
|
|
37
|
+
self.code = code
|
|
38
|
+
self.detail = detail
|
|
39
|
+
self.raw = raw
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_response(cls, response: Any) -> "VerixoError":
|
|
43
|
+
try:
|
|
44
|
+
body = response.json()
|
|
45
|
+
except ValueError:
|
|
46
|
+
body = None
|
|
47
|
+
|
|
48
|
+
obj = body if isinstance(body, dict) else {}
|
|
49
|
+
detail = obj.get("detail") or obj.get("error") or obj.get("message")
|
|
50
|
+
code = obj.get("grpcCode") or obj.get("code")
|
|
51
|
+
message = detail or _STATUS_FALLBACKS.get(
|
|
52
|
+
response.status_code, f"Request failed with status {response.status_code}"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return cls(message, status=response.status_code, code=code, detail=detail, raw=body)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..client import HttpClient
|
|
4
|
+
from ..types import DeliveryRates
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Analytics:
|
|
8
|
+
def __init__(self, http: HttpClient) -> None:
|
|
9
|
+
self._http = http
|
|
10
|
+
|
|
11
|
+
def rates(self, service_slug: str = "whatsapp") -> DeliveryRates:
|
|
12
|
+
"""Public delivery-rate stats by country for a service. No auth required by the API itself."""
|
|
13
|
+
data = self._http.get(f"/api/v1/analytics/rates?serviceSlug={service_slug}")
|
|
14
|
+
return DeliveryRates._from_json(data)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from urllib.parse import urlencode
|
|
5
|
+
|
|
6
|
+
from ..client import HttpClient
|
|
7
|
+
from ..types import PurchaseResult, SearchNumbersResult, _from_json
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Numbers:
|
|
11
|
+
def __init__(self, http: HttpClient) -> None:
|
|
12
|
+
self._http = http
|
|
13
|
+
|
|
14
|
+
def search(
|
|
15
|
+
self,
|
|
16
|
+
service_slug: str,
|
|
17
|
+
country_code: Optional[str] = None,
|
|
18
|
+
min_score: Optional[int] = None,
|
|
19
|
+
limit: Optional[int] = None,
|
|
20
|
+
) -> SearchNumbersResult:
|
|
21
|
+
"""Browse available numbers ranked by Health Score (TM). No purchase is made."""
|
|
22
|
+
params = {"serviceSlug": service_slug}
|
|
23
|
+
if country_code:
|
|
24
|
+
params["countryCode"] = country_code
|
|
25
|
+
if min_score is not None:
|
|
26
|
+
params["minScore"] = str(min_score)
|
|
27
|
+
if limit is not None:
|
|
28
|
+
params["limit"] = str(limit)
|
|
29
|
+
data = self._http.get(f"/api/v1/numbers/search?{urlencode(params)}")
|
|
30
|
+
return SearchNumbersResult._from_json(data)
|
|
31
|
+
|
|
32
|
+
def purchase(
|
|
33
|
+
self,
|
|
34
|
+
service_slug: str,
|
|
35
|
+
country_code: str,
|
|
36
|
+
*,
|
|
37
|
+
privacy_mode: bool = False,
|
|
38
|
+
test_mode: bool = False,
|
|
39
|
+
channel: str = "SMS",
|
|
40
|
+
) -> PurchaseResult:
|
|
41
|
+
"""
|
|
42
|
+
Buy a number for a service + country. The server picks the best available
|
|
43
|
+
candidate by Health Score automatically -- there's no number_id parameter,
|
|
44
|
+
since by the time you'd pass one back the number may already be gone.
|
|
45
|
+
"""
|
|
46
|
+
body = {
|
|
47
|
+
"serviceSlug": service_slug,
|
|
48
|
+
"countryCode": country_code,
|
|
49
|
+
"privacyMode": privacy_mode,
|
|
50
|
+
"testMode": test_mode,
|
|
51
|
+
"channel": channel,
|
|
52
|
+
}
|
|
53
|
+
data = self._http.post("/api/v1/numbers/purchase", json=body)
|
|
54
|
+
return _from_json(PurchaseResult, data)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
from ..client import HttpClient
|
|
7
|
+
from ..types import SessionStatus, _from_json
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Sessions:
|
|
11
|
+
def __init__(self, http: HttpClient) -> None:
|
|
12
|
+
self._http = http
|
|
13
|
+
|
|
14
|
+
def get(self, session_token: str) -> SessionStatus:
|
|
15
|
+
"""Poll a session's current delivery status."""
|
|
16
|
+
data = self._http.get(f"/api/v1/numbers/session/{quote(session_token)}")
|
|
17
|
+
return _from_json(SessionStatus, data)
|
|
18
|
+
|
|
19
|
+
def wait_for_otp(
|
|
20
|
+
self,
|
|
21
|
+
session_token: str,
|
|
22
|
+
timeout: float = 90,
|
|
23
|
+
interval: float = 2,
|
|
24
|
+
) -> SessionStatus:
|
|
25
|
+
"""
|
|
26
|
+
Poll until the session reaches a terminal state (DELIVERED, REFUNDED, EXPIRED,
|
|
27
|
+
FAILED) or the timeout elapses. Prefer ``Verixo.subscribe()`` for real-time
|
|
28
|
+
push -- this is a fallback for environments where a persistent WebSocket isn't
|
|
29
|
+
practical (e.g. serverless functions, simple scripts).
|
|
30
|
+
"""
|
|
31
|
+
deadline = time.monotonic() + timeout
|
|
32
|
+
while True:
|
|
33
|
+
session = self.get(session_token)
|
|
34
|
+
if session.status != "PENDING":
|
|
35
|
+
return session
|
|
36
|
+
if time.monotonic() >= deadline:
|
|
37
|
+
return session
|
|
38
|
+
time.sleep(interval)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Callable, Dict, List
|
|
8
|
+
|
|
9
|
+
import websocket
|
|
10
|
+
|
|
11
|
+
from .client import HttpClient
|
|
12
|
+
from .types import OtpPush, _from_json
|
|
13
|
+
|
|
14
|
+
_NULL = "\x00"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class _StompFrame:
|
|
19
|
+
command: str
|
|
20
|
+
headers: Dict[str, str]
|
|
21
|
+
body: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _build_frame(command: str, headers: Dict[str, str], body: str = "") -> str:
|
|
25
|
+
header_lines = "\n".join(f"{k}:{v}" for k, v in headers.items())
|
|
26
|
+
return f"{command}\n{header_lines}\n\n{body}{_NULL}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _parse_frame(raw: str) -> _StompFrame:
|
|
30
|
+
head, _, body = raw.partition("\n\n")
|
|
31
|
+
lines = head.split("\n")
|
|
32
|
+
command = lines[0]
|
|
33
|
+
headers: Dict[str, str] = {}
|
|
34
|
+
for line in lines[1:]:
|
|
35
|
+
if ":" in line:
|
|
36
|
+
key, _, value = line.partition(":")
|
|
37
|
+
headers[key] = value
|
|
38
|
+
return _StompFrame(command=command, headers=headers, body=body.rstrip(_NULL))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def subscribe(http: HttpClient, session_token: str, on_otp: Callable[[OtpPush], None]) -> Callable[[], None]:
|
|
42
|
+
"""
|
|
43
|
+
Get pushed the OTP for a session in real time over the gateway's WebSocket
|
|
44
|
+
proxy (wss://.../ws -> delivery-service's STOMP broker, topic
|
|
45
|
+
/topic/otp/{session_token}), instead of polling ``sessions.wait_for_otp()``.
|
|
46
|
+
|
|
47
|
+
Runs the connection on a background thread and returns immediately with an
|
|
48
|
+
unsubscribe function. ``on_otp`` fires at most once per session (a session
|
|
49
|
+
only ever delivers one OTP).
|
|
50
|
+
"""
|
|
51
|
+
subscribed_at = time.monotonic()
|
|
52
|
+
destination = f"/topic/otp/{session_token}"
|
|
53
|
+
buffer: List[str] = []
|
|
54
|
+
|
|
55
|
+
def handle_frame(raw: str) -> None:
|
|
56
|
+
frame = _parse_frame(raw)
|
|
57
|
+
if frame.command == "MESSAGE" and frame.headers.get("destination") == destination:
|
|
58
|
+
try:
|
|
59
|
+
payload = json.loads(frame.body)
|
|
60
|
+
except (ValueError, TypeError):
|
|
61
|
+
return
|
|
62
|
+
payload["latencyMs"] = int((time.monotonic() - subscribed_at) * 1000)
|
|
63
|
+
on_otp(_from_json(OtpPush, payload))
|
|
64
|
+
|
|
65
|
+
def on_open(ws: websocket.WebSocketApp) -> None:
|
|
66
|
+
ws.send(_build_frame("CONNECT", {"accept-version": "1.2", "heart-beat": "0,0"}))
|
|
67
|
+
|
|
68
|
+
def on_message(ws: websocket.WebSocketApp, message: str) -> None:
|
|
69
|
+
buffer.append(message)
|
|
70
|
+
joined = "".join(buffer)
|
|
71
|
+
if _NULL not in joined:
|
|
72
|
+
return
|
|
73
|
+
frames, _, remainder = joined.rpartition(_NULL)
|
|
74
|
+
buffer.clear()
|
|
75
|
+
if remainder:
|
|
76
|
+
buffer.append(remainder)
|
|
77
|
+
for raw in filter(None, frames.split(_NULL)):
|
|
78
|
+
if raw.strip().startswith("CONNECTED"):
|
|
79
|
+
ws.send(_build_frame("SUBSCRIBE", {"id": "sub-0", "destination": destination}))
|
|
80
|
+
else:
|
|
81
|
+
handle_frame(raw)
|
|
82
|
+
|
|
83
|
+
app = websocket.WebSocketApp(
|
|
84
|
+
f"{http.ws_origin}/ws",
|
|
85
|
+
on_open=on_open,
|
|
86
|
+
on_message=on_message,
|
|
87
|
+
)
|
|
88
|
+
thread = threading.Thread(target=app.run_forever, kwargs={"reconnect": 5}, daemon=True)
|
|
89
|
+
thread.start()
|
|
90
|
+
|
|
91
|
+
def unsubscribe() -> None:
|
|
92
|
+
app.close()
|
|
93
|
+
|
|
94
|
+
return unsubscribe
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, fields
|
|
5
|
+
from typing import Any, Dict, List, Optional, Type, TypeVar
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
|
|
9
|
+
_CAMEL_RE = re.compile(r"(?<!^)(?=[A-Z])")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _to_snake(name: str) -> str:
|
|
13
|
+
return _CAMEL_RE.sub("_", name).lower()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _from_json(cls: Type[T], data: Dict[str, Any]) -> T:
|
|
17
|
+
"""Builds a dataclass from a camelCase JSON dict, mapping keys to snake_case fields."""
|
|
18
|
+
snake = {_to_snake(k): v for k, v in data.items()}
|
|
19
|
+
known = {f.name for f in fields(cls)} # type: ignore[arg-type]
|
|
20
|
+
return cls(**{k: v for k, v in snake.items() if k in known})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class VirtualNumber:
|
|
25
|
+
number_id: str
|
|
26
|
+
number: str
|
|
27
|
+
e164_number: str
|
|
28
|
+
health_score: int
|
|
29
|
+
number_type: str
|
|
30
|
+
country_code: str
|
|
31
|
+
country_name: str
|
|
32
|
+
country_flag: str
|
|
33
|
+
price_usd: float
|
|
34
|
+
delivery_rate: float
|
|
35
|
+
avg_latency_ms: Optional[int]
|
|
36
|
+
available: bool
|
|
37
|
+
score_color: str
|
|
38
|
+
carrier_name: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class SearchNumbersResult:
|
|
43
|
+
numbers: List[VirtualNumber]
|
|
44
|
+
total_available: int
|
|
45
|
+
country_code: str
|
|
46
|
+
service_slug: str
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def _from_json(cls, data: Dict[str, Any]) -> "SearchNumbersResult":
|
|
50
|
+
result = _from_json(cls, data)
|
|
51
|
+
result.numbers = [_from_json(VirtualNumber, n) for n in data.get("numbers", [])]
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class PurchaseResult:
|
|
57
|
+
session_token: str
|
|
58
|
+
purchase_id: str
|
|
59
|
+
web_socket_topic: str
|
|
60
|
+
e164_number: str
|
|
61
|
+
health_score_at_purchase: int
|
|
62
|
+
score_color: str
|
|
63
|
+
carrier_type: str
|
|
64
|
+
aggregator_type: str
|
|
65
|
+
price_usd: float
|
|
66
|
+
sla_remaining_seconds: int
|
|
67
|
+
sla_expires_at: str
|
|
68
|
+
privacy_mode: bool
|
|
69
|
+
confidence_label: str
|
|
70
|
+
channel: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class SessionStatus:
|
|
75
|
+
session_token: str
|
|
76
|
+
status: str # "PENDING" | "DELIVERED" | "REFUNDED" | "EXPIRED" | "FAILED"
|
|
77
|
+
otp_code: str
|
|
78
|
+
sla_remaining_seconds: int
|
|
79
|
+
failure_reason: str
|
|
80
|
+
aggregator_type: str
|
|
81
|
+
health_score: int
|
|
82
|
+
privacy_mode: bool
|
|
83
|
+
channel: str
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class DeliveryRate:
|
|
88
|
+
country_code: str
|
|
89
|
+
country_name: str
|
|
90
|
+
country_flag: str
|
|
91
|
+
rate: float
|
|
92
|
+
latency: str
|
|
93
|
+
total_deliveries: int
|
|
94
|
+
updated_at: str
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class DeliveryRates:
|
|
99
|
+
rates: List[DeliveryRate]
|
|
100
|
+
updated_at: str
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def _from_json(cls, data: Dict[str, Any]) -> "DeliveryRates":
|
|
104
|
+
return cls(
|
|
105
|
+
rates=[_from_json(DeliveryRate, r) for r in data.get("rates", [])],
|
|
106
|
+
updated_at=data.get("updatedAt", ""),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class OtpPush:
|
|
112
|
+
session_token: str
|
|
113
|
+
otp: str
|
|
114
|
+
e164_number: str
|
|
115
|
+
service_slug: str
|
|
116
|
+
privacy_mode: bool
|
|
117
|
+
delivered_at: str
|
|
118
|
+
latency_ms: int
|
|
119
|
+
"""Milliseconds between calling subscribe() and this push arriving -- not from the server."""
|