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 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)
@@ -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)}")