callstorm 0.1.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.
- callstorm/__init__.py +29 -0
- callstorm/client.py +244 -0
- callstorm/errors.py +123 -0
- callstorm/pagination.py +51 -0
- callstorm/py.typed +0 -0
- callstorm/resources/__init__.py +18 -0
- callstorm/resources/account.py +48 -0
- callstorm/resources/agents.py +44 -0
- callstorm/resources/api_keys.py +22 -0
- callstorm/resources/calls.py +40 -0
- callstorm/resources/campaigns.py +72 -0
- callstorm/resources/credits.py +32 -0
- callstorm/resources/dnc.py +21 -0
- callstorm/resources/exports.py +34 -0
- callstorm/resources/files.py +40 -0
- callstorm/resources/lead_lists.py +57 -0
- callstorm/resources/templates.py +28 -0
- callstorm/resources/webhooks.py +18 -0
- callstorm/types.py +213 -0
- callstorm/webhooks.py +111 -0
- callstorm-0.1.0.dist-info/METADATA +234 -0
- callstorm-0.1.0.dist-info/RECORD +25 -0
- callstorm-0.1.0.dist-info/WHEEL +5 -0
- callstorm-0.1.0.dist-info/licenses/LICENSE +21 -0
- callstorm-0.1.0.dist-info/top_level.txt +1 -0
callstorm/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Official Python SDK for the Callstorm API.
|
|
2
|
+
|
|
3
|
+
from callstorm import Callstorm
|
|
4
|
+
|
|
5
|
+
client = Callstorm(api_key="vx_live_…")
|
|
6
|
+
for call in client.calls.list(campaign_id="cmp_…"):
|
|
7
|
+
print(call["phone"], call["disposition"])
|
|
8
|
+
"""
|
|
9
|
+
from .client import DEFAULT_BASE_URL, VERSION, Callstorm, RateLimit, Response, Transport, urllib_transport
|
|
10
|
+
from .errors import (
|
|
11
|
+
ApiError, AuthenticationError, CallstormError, ConflictError, ForbiddenError, InsufficientCreditsError, NetworkError, NotFoundError,
|
|
12
|
+
PayloadTooLargeError, PreconditionError, ProcessingFailedError, RateLimitError, ServerError, ValidationError, WaitTimeoutError,
|
|
13
|
+
WebhookSignatureError, error_from_response,
|
|
14
|
+
)
|
|
15
|
+
from .pagination import Page, Paginator
|
|
16
|
+
from .webhooks import (
|
|
17
|
+
DELIVERY_HEADER, EVENT_HEADER, SIGNATURE_HEADER, WEBHOOK_EVENTS, WebhookEvent, is_balance_low, is_campaign_status_changed, parse_event,
|
|
18
|
+
sign_payload, verify_signature,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__version__ = VERSION
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Callstorm", "DEFAULT_BASE_URL", "VERSION", "RateLimit", "Response", "Transport", "urllib_transport", "Page", "Paginator",
|
|
24
|
+
"ApiError", "AuthenticationError", "CallstormError", "ConflictError", "ForbiddenError", "InsufficientCreditsError", "NetworkError",
|
|
25
|
+
"NotFoundError", "PayloadTooLargeError", "PreconditionError", "ProcessingFailedError", "RateLimitError", "ServerError", "ValidationError",
|
|
26
|
+
"WaitTimeoutError", "WebhookSignatureError", "error_from_response",
|
|
27
|
+
"DELIVERY_HEADER", "EVENT_HEADER", "SIGNATURE_HEADER", "WEBHOOK_EVENTS", "WebhookEvent", "is_balance_low", "is_campaign_status_changed",
|
|
28
|
+
"parse_event", "sign_payload", "verify_signature",
|
|
29
|
+
]
|
callstorm/client.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""The HTTP client: authentication, JSON, timeouts, idempotency, rate-limit bookkeeping and retries."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import platform
|
|
6
|
+
import socket
|
|
7
|
+
import ssl
|
|
8
|
+
import time
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import urllib.request
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Any, Callable, Dict, Mapping, Optional, Sequence, Union
|
|
15
|
+
|
|
16
|
+
from .errors import CallstormError, NetworkError, error_from_response
|
|
17
|
+
from .resources.account import Account
|
|
18
|
+
from .resources.agents import Agents
|
|
19
|
+
from .resources.api_keys import ApiKeys
|
|
20
|
+
from .resources.calls import Calls
|
|
21
|
+
from .resources.campaigns import Campaigns
|
|
22
|
+
from .resources.credits import Credits
|
|
23
|
+
from .resources.dnc import Dnc
|
|
24
|
+
from .resources.exports import Exports
|
|
25
|
+
from .resources.files import Files
|
|
26
|
+
from .resources.lead_lists import LeadLists
|
|
27
|
+
from .resources.templates import Templates
|
|
28
|
+
from .resources.webhooks import Webhooks
|
|
29
|
+
|
|
30
|
+
VERSION = "0.1.0"
|
|
31
|
+
DEFAULT_BASE_URL = "https://api.callstorm.ai/v1/ext"
|
|
32
|
+
_MAX_429_WAIT = 60.0
|
|
33
|
+
|
|
34
|
+
QueryValue = Union[str, int, float, bool, None, Sequence[Union[str, int]]]
|
|
35
|
+
Query = Mapping[str, QueryValue]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Response:
|
|
40
|
+
"""What a transport returns: status, lower-cased headers and the raw body."""
|
|
41
|
+
|
|
42
|
+
status: int
|
|
43
|
+
headers: Dict[str, str]
|
|
44
|
+
body: bytes
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
Transport = Callable[[str, str, Dict[str, str], Optional[bytes], float], Response]
|
|
48
|
+
"""``transport(method, url, headers, body, timeout) -> Response``; raises on network failure."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def urllib_transport(method: str, url: str, headers: Dict[str, str], body: Optional[bytes], timeout: float) -> Response:
|
|
52
|
+
"""Default transport on the standard library."""
|
|
53
|
+
req = urllib.request.Request(url, data=body, method=method, headers=headers)
|
|
54
|
+
try:
|
|
55
|
+
with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 (https URLs only)
|
|
56
|
+
return Response(r.status, {k.lower(): v for k, v in r.headers.items()}, r.read())
|
|
57
|
+
except urllib.error.HTTPError as e:
|
|
58
|
+
return Response(e.code, {k.lower(): v for k, v in e.headers.items()}, e.read())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class RateLimit:
|
|
63
|
+
"""Window of the key as the last answer reported it (``X-RateLimit-*``)."""
|
|
64
|
+
|
|
65
|
+
limit: int
|
|
66
|
+
remaining: int
|
|
67
|
+
reset_at: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _backoff(attempt: int) -> float:
|
|
71
|
+
return min(0.5 * (2 ** attempt), 8.0)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _parse_iso(value: Optional[str]) -> Optional[datetime]:
|
|
75
|
+
if not value:
|
|
76
|
+
return None
|
|
77
|
+
try:
|
|
78
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
79
|
+
except ValueError:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Callstorm:
|
|
84
|
+
"""Client of the Callstorm API.
|
|
85
|
+
|
|
86
|
+
:param api_key: ``vx_live_…`` (production) or ``vx_test_…`` (sandbox).
|
|
87
|
+
:param base_url: defaults to ``https://api.callstorm.ai/v1/ext``.
|
|
88
|
+
:param timeout: per-request timeout in seconds (30).
|
|
89
|
+
:param max_retries: retries after a 429, a 502/503/504 or a network failure (2); writes retry only with an
|
|
90
|
+
idempotency key.
|
|
91
|
+
:param use_bearer: send the key as ``Authorization: Bearer`` instead of ``X-Api-Key``.
|
|
92
|
+
:param transport: alternative transport (tests, proxies).
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self, api_key: str, *, base_url: str = DEFAULT_BASE_URL, timeout: float = 30.0, max_retries: int = 2, use_bearer: bool = False,
|
|
97
|
+
user_agent: Optional[str] = None, transport: Optional[Transport] = None, sleep: Callable[[float], None] = time.sleep,
|
|
98
|
+
):
|
|
99
|
+
if not api_key:
|
|
100
|
+
raise CallstormError("api_key is required (vx_live_… or vx_test_…)")
|
|
101
|
+
self._api_key = api_key
|
|
102
|
+
self.base_url = base_url.rstrip("/")
|
|
103
|
+
self.timeout = timeout
|
|
104
|
+
self.max_retries = max_retries
|
|
105
|
+
self.use_bearer = use_bearer
|
|
106
|
+
self.user_agent = user_agent or f"callstorm-python/{VERSION} (python {platform.python_version()})"
|
|
107
|
+
self._transport = transport or urllib_transport
|
|
108
|
+
self._sleep = sleep
|
|
109
|
+
#: Rate-limit window of the key as of the last answer.
|
|
110
|
+
self.rate_limit: Optional[RateLimit] = None
|
|
111
|
+
#: True when the last answer was a replay of an earlier request with the same ``Idempotency-Key``.
|
|
112
|
+
self.last_replayed = False
|
|
113
|
+
self.last_response: Optional[Response] = None
|
|
114
|
+
self.campaigns = Campaigns(self)
|
|
115
|
+
self.templates = Templates(self)
|
|
116
|
+
self.lead_lists = LeadLists(self)
|
|
117
|
+
self.files = Files(self)
|
|
118
|
+
self.dnc = Dnc(self)
|
|
119
|
+
self.calls = Calls(self)
|
|
120
|
+
self.exports = Exports(self)
|
|
121
|
+
self.account = Account(self)
|
|
122
|
+
self.agents = Agents(self)
|
|
123
|
+
self.credits = Credits(self)
|
|
124
|
+
self.webhooks = Webhooks(self)
|
|
125
|
+
self.api_keys = ApiKeys(self)
|
|
126
|
+
|
|
127
|
+
# ---- shortcuts
|
|
128
|
+
def me(self) -> Dict[str, Any]:
|
|
129
|
+
"""Who the key belongs to: ``{"user": …, "account": …}``."""
|
|
130
|
+
return self.get("/me")
|
|
131
|
+
|
|
132
|
+
def dashboard(self) -> Dict[str, Any]:
|
|
133
|
+
return self.get("/dashboard")
|
|
134
|
+
|
|
135
|
+
def get(self, path: str, query: Optional[Query] = None) -> Any:
|
|
136
|
+
return self.request("GET", path, query=query)
|
|
137
|
+
|
|
138
|
+
def post(self, path: str, body: Any = None, *, idempotency_key: Optional[str] = None, query: Optional[Query] = None) -> Any:
|
|
139
|
+
return self.request("POST", path, body=body, idempotency_key=idempotency_key, query=query)
|
|
140
|
+
|
|
141
|
+
def put(self, path: str, body: Any = None, *, idempotency_key: Optional[str] = None) -> Any:
|
|
142
|
+
return self.request("PUT", path, body=body, idempotency_key=idempotency_key)
|
|
143
|
+
|
|
144
|
+
def patch(self, path: str, body: Any = None, *, idempotency_key: Optional[str] = None) -> Any:
|
|
145
|
+
return self.request("PATCH", path, body=body, idempotency_key=idempotency_key)
|
|
146
|
+
|
|
147
|
+
def delete(self, path: str, *, idempotency_key: Optional[str] = None) -> Any:
|
|
148
|
+
return self.request("DELETE", path, idempotency_key=idempotency_key)
|
|
149
|
+
|
|
150
|
+
# ---- core
|
|
151
|
+
def request(
|
|
152
|
+
self, method: str, path: str, *, query: Optional[Query] = None, body: Any = None, idempotency_key: Optional[str] = None,
|
|
153
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
154
|
+
) -> Any:
|
|
155
|
+
"""One API call with authentication, JSON, timeout, rate-limit bookkeeping and retries. Returns the parsed JSON (None on 204)."""
|
|
156
|
+
url = self.url(path, query)
|
|
157
|
+
data = None if method in ("GET", "DELETE") else json.dumps({} if body is None else body).encode("utf-8")
|
|
158
|
+
hdrs: Dict[str, str] = {"Accept": "application/json", "User-Agent": self.user_agent}
|
|
159
|
+
if self.use_bearer:
|
|
160
|
+
hdrs["Authorization"] = f"Bearer {self._api_key}"
|
|
161
|
+
else:
|
|
162
|
+
hdrs["X-Api-Key"] = self._api_key
|
|
163
|
+
if data is not None:
|
|
164
|
+
hdrs["Content-Type"] = "application/json"
|
|
165
|
+
if idempotency_key:
|
|
166
|
+
hdrs["Idempotency-Key"] = idempotency_key
|
|
167
|
+
if headers:
|
|
168
|
+
hdrs.update(headers)
|
|
169
|
+
retriable = method == "GET" or bool(idempotency_key)
|
|
170
|
+
attempt = 0
|
|
171
|
+
while True:
|
|
172
|
+
try:
|
|
173
|
+
res = self._transport(method, url, hdrs, data, self.timeout)
|
|
174
|
+
except (urllib.error.URLError, socket.timeout, ssl.SSLError, ConnectionError, TimeoutError, OSError) as e:
|
|
175
|
+
if retriable and attempt < self.max_retries:
|
|
176
|
+
self._sleep(_backoff(attempt))
|
|
177
|
+
attempt += 1
|
|
178
|
+
continue
|
|
179
|
+
raise NetworkError(f"{method} {path}: {e}") from e
|
|
180
|
+
self.last_response = res
|
|
181
|
+
self._read_rate_limit(res.headers)
|
|
182
|
+
self.last_replayed = res.headers.get("idempotent-replayed", "").lower() == "true"
|
|
183
|
+
if 200 <= res.status < 300:
|
|
184
|
+
if res.status == 204 or not res.body:
|
|
185
|
+
return None
|
|
186
|
+
return json.loads(res.body.decode("utf-8"))
|
|
187
|
+
err_body = self._error_body(res.body)
|
|
188
|
+
if res.status == 429 and attempt < self.max_retries:
|
|
189
|
+
self._sleep(self._wait_for_429(err_body, res.headers))
|
|
190
|
+
attempt += 1
|
|
191
|
+
continue
|
|
192
|
+
if res.status in (502, 503, 504) and retriable and attempt < self.max_retries:
|
|
193
|
+
self._sleep(_backoff(attempt))
|
|
194
|
+
attempt += 1
|
|
195
|
+
continue
|
|
196
|
+
raise error_from_response(res.status, err_body, self.last_replayed)
|
|
197
|
+
|
|
198
|
+
def upload(self, url: str, data: bytes, headers: Mapping[str, str], method: str = "PUT") -> None:
|
|
199
|
+
"""Raw request outside the API (pre-signed S3 uploads)."""
|
|
200
|
+
try:
|
|
201
|
+
res = self._transport(method, url, dict(headers), data, max(self.timeout, 120.0))
|
|
202
|
+
except (urllib.error.URLError, socket.timeout, ssl.SSLError, ConnectionError, TimeoutError, OSError) as e:
|
|
203
|
+
raise NetworkError(f"upload: {e}") from e
|
|
204
|
+
if not 200 <= res.status < 300:
|
|
205
|
+
raise NetworkError(f"upload failed with HTTP {res.status}: {res.body[:300]!r}", status=res.status)
|
|
206
|
+
|
|
207
|
+
def url(self, path: str, query: Optional[Query] = None) -> str:
|
|
208
|
+
"""``path`` joined to the base URL, with the query serialised the way the API reads it (lists comma-separated, blanks skipped)."""
|
|
209
|
+
params = []
|
|
210
|
+
for k, v in (query or {}).items():
|
|
211
|
+
if v is None or v == "" or (isinstance(v, (list, tuple)) and not v):
|
|
212
|
+
continue
|
|
213
|
+
if isinstance(v, bool):
|
|
214
|
+
params.append((k, "true" if v else "false"))
|
|
215
|
+
elif isinstance(v, (list, tuple)):
|
|
216
|
+
params.append((k, ",".join(str(x) for x in v)))
|
|
217
|
+
else:
|
|
218
|
+
params.append((k, str(v)))
|
|
219
|
+
qs = urllib.parse.urlencode(params)
|
|
220
|
+
return self.base_url + (path if path.startswith("/") else "/" + path) + (f"?{qs}" if qs else "")
|
|
221
|
+
|
|
222
|
+
# ---- helpers
|
|
223
|
+
def _read_rate_limit(self, h: Mapping[str, str]) -> None:
|
|
224
|
+
limit, remaining, reset_at = h.get("x-ratelimit-limit"), h.get("x-ratelimit-remaining"), h.get("x-ratelimit-reset")
|
|
225
|
+
if limit and remaining and reset_at:
|
|
226
|
+
try:
|
|
227
|
+
self.rate_limit = RateLimit(int(limit), int(remaining), reset_at)
|
|
228
|
+
except ValueError:
|
|
229
|
+
pass
|
|
230
|
+
|
|
231
|
+
@staticmethod
|
|
232
|
+
def _error_body(raw: bytes) -> Optional[Dict[str, Any]]:
|
|
233
|
+
try:
|
|
234
|
+
parsed = json.loads(raw.decode("utf-8"))
|
|
235
|
+
return parsed if isinstance(parsed, dict) else None
|
|
236
|
+
except ValueError:
|
|
237
|
+
text = raw.decode("utf-8", "replace").strip()
|
|
238
|
+
return {"code": "HTTP_ERROR", "message": text[:300]} if text else None
|
|
239
|
+
|
|
240
|
+
def _wait_for_429(self, body: Optional[Dict[str, Any]], headers: Mapping[str, str]) -> float:
|
|
241
|
+
details = body.get("details") if isinstance(body, dict) else None
|
|
242
|
+
at = _parse_iso((details or {}).get("resetAt") if isinstance(details, dict) else None) or _parse_iso(headers.get("x-ratelimit-reset"))
|
|
243
|
+
wait = (at - datetime.now(timezone.utc)).total_seconds() if at else 1.0
|
|
244
|
+
return min(max(wait, 0.0) + 0.1, _MAX_429_WAIT)
|
callstorm/errors.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Errors raised by the SDK. Every API failure maps by HTTP status to a class; `code` keeps the API's own code."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _parse_iso(value: Optional[str]) -> Optional[datetime]:
|
|
9
|
+
if not value:
|
|
10
|
+
return None
|
|
11
|
+
try:
|
|
12
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
13
|
+
except ValueError:
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CallstormError(Exception):
|
|
18
|
+
"""Base class of every error raised by the SDK."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, message: str, *, status: Optional[int] = None, code: Optional[str] = None, details: Any = None, replayed: bool = False):
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.status = status
|
|
23
|
+
self.code = code
|
|
24
|
+
self.details = details
|
|
25
|
+
#: True when the answer was a replay of an earlier request with the same ``Idempotency-Key``.
|
|
26
|
+
self.replayed = replayed
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ApiError(CallstormError):
|
|
30
|
+
"""The API answered with an error status."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ValidationError(ApiError):
|
|
34
|
+
"""400: the request body or query is invalid; ``issues`` lists each field."""
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def issues(self) -> List[Dict[str, Any]]:
|
|
38
|
+
d = self.details
|
|
39
|
+
if isinstance(d, list):
|
|
40
|
+
return d
|
|
41
|
+
if isinstance(d, dict) and isinstance(d.get("issues"), list):
|
|
42
|
+
return d["issues"]
|
|
43
|
+
return []
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AuthenticationError(ApiError):
|
|
47
|
+
"""401: missing, invalid, revoked or expired API key."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class InsufficientCreditsError(ApiError):
|
|
51
|
+
"""402: the account balance does not cover the operation (``details['balanceMicros']``)."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ForbiddenError(ApiError):
|
|
55
|
+
"""403: the key lacks the scope, or the account is suspended (``ACCOUNT_SUSPENDED``)."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class NotFoundError(ApiError):
|
|
59
|
+
"""404: no such resource."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ConflictError(ApiError):
|
|
63
|
+
"""409: the resource is not in a state that allows the operation (``INVALID_TRANSITION``, ``NOT_DRAFT``, ``IN_USE``…)."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class PreconditionError(ApiError):
|
|
67
|
+
"""412: the account misses a prerequisite (``CARRIER_NOT_CONFIGURED``, ``ELEVENLABS_NOT_CONFIGURED``)."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class PayloadTooLargeError(ApiError):
|
|
71
|
+
"""413: the file exceeds the size allowed for its kind."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class RateLimitError(ApiError):
|
|
75
|
+
"""429: the key's per-minute limit was exceeded; ``reset_at`` says when the window opens again."""
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def reset_at(self) -> Optional[datetime]:
|
|
79
|
+
d = self.details if isinstance(self.details, dict) else {}
|
|
80
|
+
return _parse_iso(d.get("resetAt"))
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def retry_after_seconds(self) -> float:
|
|
84
|
+
at = self.reset_at
|
|
85
|
+
if not at:
|
|
86
|
+
return 0.0
|
|
87
|
+
return max(0.0, (at - datetime.now(timezone.utc)).total_seconds())
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ServerError(ApiError):
|
|
91
|
+
"""5xx: the API or one of its providers failed; safe to retry reads and idempotent writes."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class NetworkError(CallstormError):
|
|
95
|
+
"""The request never got an answer: DNS, TLS, connection reset or timeout."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class WaitTimeoutError(CallstormError):
|
|
99
|
+
"""A ``wait_until_…`` helper gave up before the resource reached its final state."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ProcessingFailedError(CallstormError):
|
|
103
|
+
"""The resource ended in an error state while a ``wait_until_…`` helper was polling it."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class WebhookSignatureError(CallstormError):
|
|
107
|
+
"""The ``X-Voxcore-Signature`` of a webhook delivery does not match the secret."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
_BY_STATUS = {
|
|
111
|
+
400: ValidationError, 401: AuthenticationError, 402: InsufficientCreditsError, 403: ForbiddenError, 404: NotFoundError,
|
|
112
|
+
405: NotFoundError, 409: ConflictError, 412: PreconditionError, 413: PayloadTooLargeError, 429: RateLimitError,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def error_from_response(status: int, body: Optional[Dict[str, Any]], replayed: bool = False) -> ApiError:
|
|
117
|
+
"""Builds the typed error for an API answer."""
|
|
118
|
+
body = body if isinstance(body, dict) else {}
|
|
119
|
+
code = body.get("code") or ("UNAUTHORIZED" if status == 401 else "HTTP_ERROR")
|
|
120
|
+
text = body.get("message")
|
|
121
|
+
message = f"{text} ({code}, HTTP {status})" if text else f"HTTP {status} ({code})"
|
|
122
|
+
cls = ServerError if status >= 500 else _BY_STATUS.get(status, ApiError)
|
|
123
|
+
return cls(message, status=status, code=code, details=body.get("details"), replayed=replayed)
|
callstorm/pagination.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Cursor pagination: iterate items, pages, or collect them."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Callable, Dict, Generic, Iterator, List, Optional, TypeVar
|
|
5
|
+
|
|
6
|
+
T = TypeVar("T")
|
|
7
|
+
|
|
8
|
+
Page = Dict[str, Any]
|
|
9
|
+
"""One page: ``{"items": [...], "nextCursor": str | None, "total": int | None}``. ``nextCursor`` is absent on the last page."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Paginator(Generic[T]):
|
|
13
|
+
"""Lazy walker over a cursor-paginated endpoint.
|
|
14
|
+
|
|
15
|
+
Iterate it for items, call :meth:`pages` for pages or :meth:`all` to collect. Pages can be short or empty while a
|
|
16
|
+
cursor is still present (filters apply after the page is read), so the walker goes on until the API stops
|
|
17
|
+
returning a cursor.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, fetch_page: Callable[[Optional[str]], Page]):
|
|
21
|
+
self._fetch_page = fetch_page
|
|
22
|
+
|
|
23
|
+
def page(self, cursor: Optional[str] = None) -> Page:
|
|
24
|
+
"""The first page, or the page after ``cursor``."""
|
|
25
|
+
return self._fetch_page(cursor)
|
|
26
|
+
|
|
27
|
+
def first_page(self) -> Page:
|
|
28
|
+
return self._fetch_page(None)
|
|
29
|
+
|
|
30
|
+
def pages(self) -> Iterator[Page]:
|
|
31
|
+
cursor: Optional[str] = None
|
|
32
|
+
while True:
|
|
33
|
+
page = self._fetch_page(cursor)
|
|
34
|
+
yield page
|
|
35
|
+
cursor = page.get("nextCursor") or None
|
|
36
|
+
if not cursor:
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
def __iter__(self) -> Iterator[T]:
|
|
40
|
+
for page in self.pages():
|
|
41
|
+
for item in page.get("items") or []:
|
|
42
|
+
yield item
|
|
43
|
+
|
|
44
|
+
def all(self, max_items: Optional[int] = None) -> List[T]:
|
|
45
|
+
"""Every item, or at most ``max_items`` of them, in one list."""
|
|
46
|
+
out: List[T] = []
|
|
47
|
+
for item in self:
|
|
48
|
+
out.append(item)
|
|
49
|
+
if max_items is not None and len(out) >= max_items:
|
|
50
|
+
break
|
|
51
|
+
return out
|
callstorm/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Resource classes; each wraps one part of the API and is reachable as an attribute of :class:`callstorm.Callstorm`."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
8
|
+
from ..client import Callstorm
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def seg(value: str) -> str:
|
|
12
|
+
"""One path segment, URL-encoded (``+`` of an E.164 number included)."""
|
|
13
|
+
return quote(str(value), safe="")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Resource:
|
|
17
|
+
def __init__(self, client: "Callstorm"):
|
|
18
|
+
self._client = client
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Telephony of the account: engine cells, SIP trunks and caller IDs (``settings:telephony`` for writes)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from . import Resource, seg
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Account(Resource):
|
|
10
|
+
def cells(self) -> List[Dict[str, Any]]:
|
|
11
|
+
"""Engine cells with the public IPs to allow on the carrier."""
|
|
12
|
+
return self._client.get("/account/cells")
|
|
13
|
+
|
|
14
|
+
def trunks(self) -> List[Dict[str, Any]]:
|
|
15
|
+
return self._client.get("/account/trunks")
|
|
16
|
+
|
|
17
|
+
def create_trunk(self, trunk: Dict[str, Any], *, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
18
|
+
"""``{"name", "ip", "transport", "channels", "cps", "port"?, "domain"?, "sipUser"?, "sipPass"?, "prefix"?, "cellId"?}``."""
|
|
19
|
+
return self._client.post("/account/trunks", trunk, idempotency_key=idempotency_key)
|
|
20
|
+
|
|
21
|
+
def trunk(self, trunk_id: str) -> Dict[str, Any]:
|
|
22
|
+
return self._client.get(f"/account/trunks/{seg(trunk_id)}")
|
|
23
|
+
|
|
24
|
+
def update_trunk(self, trunk_id: str, trunk: Dict[str, Any]) -> Dict[str, Any]:
|
|
25
|
+
return self._client.put(f"/account/trunks/{seg(trunk_id)}", trunk)
|
|
26
|
+
|
|
27
|
+
def delete_trunk(self, trunk_id: str) -> None:
|
|
28
|
+
self._client.delete(f"/account/trunks/{seg(trunk_id)}")
|
|
29
|
+
|
|
30
|
+
def test_trunk(self, trunk_id: str) -> Dict[str, Any]:
|
|
31
|
+
"""SIP OPTIONS round trip against the carrier: ``{"ok", "error"?, "latencyMs"?}``."""
|
|
32
|
+
return self._client.post(f"/account/trunks/{seg(trunk_id)}/test", {})
|
|
33
|
+
|
|
34
|
+
def caller_ids(self) -> List[Dict[str, Any]]:
|
|
35
|
+
return self._client.get("/account/caller-ids")
|
|
36
|
+
|
|
37
|
+
def create_caller_id(self, e164: str, country: str, trunk_id: str, *, label: Optional[str] = None, **extra: Any) -> Dict[str, Any]:
|
|
38
|
+
body: Dict[str, Any] = {"e164": e164, "country": country, "trunkId": trunk_id, **extra}
|
|
39
|
+
if label:
|
|
40
|
+
body["label"] = label
|
|
41
|
+
return self._client.post("/account/caller-ids", body)
|
|
42
|
+
|
|
43
|
+
def update_caller_id(self, e164: str, changes: Dict[str, Any]) -> Dict[str, Any]:
|
|
44
|
+
"""``{"trunkId", "label"?, "kind"?, "templateId"?}``."""
|
|
45
|
+
return self._client.put(f"/account/caller-ids/{seg(e164)}", changes)
|
|
46
|
+
|
|
47
|
+
def delete_caller_id(self, e164: str) -> None:
|
|
48
|
+
self._client.delete(f"/account/caller-ids/{seg(e164)}")
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Human agents of the account (contact center) and the token that embeds their console in a CRM."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from . import Resource, seg
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Agents(Resource):
|
|
10
|
+
def list(self) -> List[Dict[str, Any]]:
|
|
11
|
+
return self._client.get("/agents")
|
|
12
|
+
|
|
13
|
+
def get(self, agent_id: str) -> Dict[str, Any]:
|
|
14
|
+
return self._client.get(f"/agents/{seg(agent_id)}")
|
|
15
|
+
|
|
16
|
+
def create(self, name: str, email: str, *, extension: Optional[str] = None, cell_id: Optional[str] = None, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
17
|
+
body: Dict[str, Any] = {"name": name, "email": email}
|
|
18
|
+
if extension:
|
|
19
|
+
body["extension"] = extension
|
|
20
|
+
if cell_id:
|
|
21
|
+
body["cellId"] = cell_id
|
|
22
|
+
return self._client.post("/agents", body, idempotency_key=idempotency_key)
|
|
23
|
+
|
|
24
|
+
def update(self, agent_id: str, changes: Dict[str, Any]) -> Dict[str, Any]:
|
|
25
|
+
"""``{"name"?, "extension"?, "cellId"?}``."""
|
|
26
|
+
return self._client.patch(f"/agents/{seg(agent_id)}", changes)
|
|
27
|
+
|
|
28
|
+
def delete(self, agent_id: str) -> None:
|
|
29
|
+
self._client.delete(f"/agents/{seg(agent_id)}")
|
|
30
|
+
|
|
31
|
+
def set_status(self, agent_id: str, status: str, *, pause_reason: Optional[str] = None) -> Dict[str, Any]:
|
|
32
|
+
"""Forces the presence of an agent (supervisor action)."""
|
|
33
|
+
body: Dict[str, Any] = {"status": status}
|
|
34
|
+
if pause_reason:
|
|
35
|
+
body["pauseReason"] = pause_reason
|
|
36
|
+
return self._client.post(f"/agents/{seg(agent_id)}/status", body)
|
|
37
|
+
|
|
38
|
+
def sip_credentials(self, agent_id: str) -> Dict[str, Any]:
|
|
39
|
+
"""SIP credentials of the agent's softphone on the cell's SBC."""
|
|
40
|
+
return self._client.post(f"/agents/{seg(agent_id)}/sip-credentials", {})
|
|
41
|
+
|
|
42
|
+
def session_token(self, agent_id: str, *, ttl_sec: Optional[int] = None, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
43
|
+
"""Short-lived token for the embedded console (``ttl_sec`` 300 to 43 200, default 8 h); hand ``embedUrl`` to the browser."""
|
|
44
|
+
return self._client.post(f"/agents/{seg(agent_id)}/session-token", {"ttlSec": ttl_sec} if ttl_sec else {}, idempotency_key=idempotency_key)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""API keys of the account. Creating and revoking need a user session (the web app); an API key can only list them."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
5
|
+
|
|
6
|
+
from . import Resource, seg
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ApiKeys(Resource):
|
|
10
|
+
def list(self) -> List[Dict[str, Any]]:
|
|
11
|
+
return self._client.get("/account/api-keys")
|
|
12
|
+
|
|
13
|
+
def create(self, name: str, scopes: Sequence[str], *, expires_at: Optional[str] = None, rate_limit_per_min: Optional[int] = None) -> Dict[str, Any]:
|
|
14
|
+
body: Dict[str, Any] = {"name": name, "scopes": list(scopes)}
|
|
15
|
+
if expires_at:
|
|
16
|
+
body["expiresAt"] = expires_at
|
|
17
|
+
if rate_limit_per_min:
|
|
18
|
+
body["rateLimitPerMin"] = rate_limit_per_min
|
|
19
|
+
return self._client.post("/account/api-keys", body)
|
|
20
|
+
|
|
21
|
+
def delete(self, key_id: str) -> None:
|
|
22
|
+
self._client.delete(f"/account/api-keys/{seg(key_id)}")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Calls made by the campaigns: list, aggregates and the detail with transcript and recording URLs."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Dict, Optional, Sequence, Union
|
|
5
|
+
|
|
6
|
+
from ..pagination import Paginator
|
|
7
|
+
from . import Resource, seg
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _filters(
|
|
11
|
+
campaign_id: Optional[str], type: Optional[str], direction: Optional[str], from_: Optional[str], to: Optional[str],
|
|
12
|
+
disposition: Union[str, Sequence[str], None], result_code: Union[str, Sequence[str], None], phone: Optional[str], q: Optional[str], agent_id: Optional[str],
|
|
13
|
+
) -> Dict[str, Any]:
|
|
14
|
+
return {
|
|
15
|
+
"campaignId": campaign_id, "type": type, "direction": direction, "from": from_, "to": to, "disposition": disposition,
|
|
16
|
+
"resultCode": result_code, "phone": phone, "q": q, "agentId": agent_id,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Calls(Resource):
|
|
21
|
+
def list(
|
|
22
|
+
self, *, campaign_id: Optional[str] = None, type: Optional[str] = None, direction: Optional[str] = None, from_: Optional[str] = None,
|
|
23
|
+
to: Optional[str] = None, disposition: Union[str, Sequence[str], None] = None, result_code: Union[str, Sequence[str], None] = None,
|
|
24
|
+
phone: Optional[str] = None, q: Optional[str] = None, agent_id: Optional[str] = None, sort: Optional[str] = None, limit: Optional[int] = None,
|
|
25
|
+
) -> Paginator[Dict[str, Any]]:
|
|
26
|
+
"""``sort`` is ``startedAt|durationSec|attemptNo`` with ``:asc`` or ``:desc`` (default ``startedAt:desc``)."""
|
|
27
|
+
params = {**_filters(campaign_id, type, direction, from_, to, disposition, result_code, phone, q, agent_id), "sort": sort, "limit": limit}
|
|
28
|
+
return Paginator(lambda cursor: self._client.get("/calls", {**params, "cursor": cursor}))
|
|
29
|
+
|
|
30
|
+
def summary(
|
|
31
|
+
self, *, campaign_id: Optional[str] = None, type: Optional[str] = None, direction: Optional[str] = None, from_: Optional[str] = None,
|
|
32
|
+
to: Optional[str] = None, disposition: Union[str, Sequence[str], None] = None, result_code: Union[str, Sequence[str], None] = None,
|
|
33
|
+
phone: Optional[str] = None, q: Optional[str] = None, agent_id: Optional[str] = None,
|
|
34
|
+
) -> Dict[str, Any]:
|
|
35
|
+
"""Totals by disposition and result code for the same filters as :meth:`list`."""
|
|
36
|
+
return self._client.get("/calls/summary", _filters(campaign_id, type, direction, from_, to, disposition, result_code, phone, q, agent_id))
|
|
37
|
+
|
|
38
|
+
def get(self, call_id: str) -> Dict[str, Any]:
|
|
39
|
+
"""Detail with transcript, answers and pre-signed recording URLs valid for five minutes."""
|
|
40
|
+
return self._client.get(f"/calls/{seg(call_id)}")
|