voxcore-sdk 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.
- voxcore/__about__.py +1 -0
- voxcore/__init__.py +41 -0
- voxcore/_http.py +242 -0
- voxcore/client.py +130 -0
- voxcore/errors.py +158 -0
- voxcore/py.typed +0 -0
- voxcore/resources/__init__.py +277 -0
- voxcore/resources/_base.py +134 -0
- voxcore_sdk-0.1.0.dist-info/METADATA +173 -0
- voxcore_sdk-0.1.0.dist-info/RECORD +11 -0
- voxcore_sdk-0.1.0.dist-info/WHEEL +4 -0
voxcore/__about__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
voxcore/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
VOXCORE for Python.
|
|
3
|
+
|
|
4
|
+
from voxcore import Voxcore
|
|
5
|
+
|
|
6
|
+
vox = Voxcore()
|
|
7
|
+
for contact in vox.contacts.iterate(status="customer"):
|
|
8
|
+
print(contact["display_name"])
|
|
9
|
+
|
|
10
|
+
Everything is reached through ``Voxcore``. Errors all inherit from
|
|
11
|
+
``VoxcoreError``, so one except clause catches everything this library raises
|
|
12
|
+
and nothing it does not.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .__about__ import __version__
|
|
16
|
+
from .client import Voxcore
|
|
17
|
+
from .errors import (
|
|
18
|
+
APIError,
|
|
19
|
+
AuthenticationError,
|
|
20
|
+
ConnectionError,
|
|
21
|
+
NotFoundError,
|
|
22
|
+
PermissionDenied,
|
|
23
|
+
RateLimitError,
|
|
24
|
+
ServerError,
|
|
25
|
+
ValidationError,
|
|
26
|
+
VoxcoreError,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Voxcore",
|
|
31
|
+
"VoxcoreError",
|
|
32
|
+
"APIError",
|
|
33
|
+
"AuthenticationError",
|
|
34
|
+
"PermissionDenied",
|
|
35
|
+
"NotFoundError",
|
|
36
|
+
"ValidationError",
|
|
37
|
+
"RateLimitError",
|
|
38
|
+
"ServerError",
|
|
39
|
+
"ConnectionError",
|
|
40
|
+
"__version__",
|
|
41
|
+
]
|
voxcore/_http.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The one place a request is made, and the only place retries are decided.
|
|
3
|
+
|
|
4
|
+
Everything in ``resources/`` is a thin call into ``Transport.request``. That is
|
|
5
|
+
deliberate: retry policy, error mapping, idempotency and the auth header are
|
|
6
|
+
each the kind of rule that is correct once and then wrong at whichever call
|
|
7
|
+
site forgot it, so no resource is allowed its own.
|
|
8
|
+
|
|
9
|
+
**What is retried, and what is not.** A GET is safe to repeat, so connection
|
|
10
|
+
failures, 429s and 5xx are retried with backoff. A POST is not -- repeating one
|
|
11
|
+
can create a second contact or place a second call -- so it is retried only
|
|
12
|
+
when the caller supplied an idempotency key, which is what makes the repeat
|
|
13
|
+
safe on the server. Retrying an unkeyed POST is how one request becomes two
|
|
14
|
+
phone calls to the same person.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import random
|
|
20
|
+
import time
|
|
21
|
+
import uuid
|
|
22
|
+
from typing import Any, Iterator, Mapping
|
|
23
|
+
from urllib.parse import urlparse
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
from . import errors
|
|
28
|
+
from .__about__ import __version__
|
|
29
|
+
|
|
30
|
+
DEFAULT_BASE_URL = "https://api.voxcore.net"
|
|
31
|
+
DEFAULT_TIMEOUT = 30.0
|
|
32
|
+
DEFAULT_MAX_RETRIES = 2
|
|
33
|
+
|
|
34
|
+
# Repeated with backoff. 408 and 409 are absent on purpose: a timeout the
|
|
35
|
+
# server reported may have completed, and a conflict is a state problem that a
|
|
36
|
+
# second identical request cannot fix.
|
|
37
|
+
RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
38
|
+
|
|
39
|
+
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Transport:
|
|
43
|
+
"""HTTP, auth, retries and error mapping. Not part of the public API."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
*,
|
|
48
|
+
api_key: str,
|
|
49
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
50
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
51
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
52
|
+
http_client: httpx.Client | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
if not api_key:
|
|
55
|
+
raise errors.VoxcoreError(
|
|
56
|
+
"An API key is required. Pass api_key=..., or set VOXCORE_API_KEY."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
self._api_key = api_key
|
|
60
|
+
self._base_url = base_url.rstrip("/")
|
|
61
|
+
self._max_retries = max(0, int(max_retries))
|
|
62
|
+
self._owns_client = http_client is None
|
|
63
|
+
self._client = http_client or httpx.Client(timeout=timeout)
|
|
64
|
+
|
|
65
|
+
# ---- lifecycle -------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def close(self) -> None:
|
|
68
|
+
# Only what we opened. A caller who passed their own pooled client
|
|
69
|
+
# expects it to still work after this object goes away.
|
|
70
|
+
if self._owns_client:
|
|
71
|
+
self._client.close()
|
|
72
|
+
|
|
73
|
+
# ---- the request -----------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def request(
|
|
76
|
+
self,
|
|
77
|
+
method: str,
|
|
78
|
+
path: str,
|
|
79
|
+
*,
|
|
80
|
+
params: Mapping[str, Any] | None = None,
|
|
81
|
+
json: Any = None,
|
|
82
|
+
idempotency_key: str | None = None,
|
|
83
|
+
) -> Any:
|
|
84
|
+
url = path if path.startswith("http") else f"{self._base_url}{path}"
|
|
85
|
+
method = method.upper()
|
|
86
|
+
|
|
87
|
+
headers = {
|
|
88
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
89
|
+
"Accept": "application/json",
|
|
90
|
+
"User-Agent": f"voxcore-python/{__version__}",
|
|
91
|
+
}
|
|
92
|
+
if idempotency_key:
|
|
93
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
94
|
+
|
|
95
|
+
# A keyed write is safe to repeat; an unkeyed one is not. See the
|
|
96
|
+
# module docstring -- this single line is the whole policy.
|
|
97
|
+
retryable = method in SAFE_METHODS or bool(idempotency_key)
|
|
98
|
+
attempts = self._max_retries + 1 if retryable else 1
|
|
99
|
+
|
|
100
|
+
last_error: Exception | None = None
|
|
101
|
+
|
|
102
|
+
for attempt in range(attempts):
|
|
103
|
+
if attempt:
|
|
104
|
+
time.sleep(self._backoff(attempt, last_error))
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
response = self._client.request(
|
|
108
|
+
method,
|
|
109
|
+
url,
|
|
110
|
+
params=self._clean(params),
|
|
111
|
+
json=json,
|
|
112
|
+
headers=headers,
|
|
113
|
+
)
|
|
114
|
+
except httpx.TimeoutException as failure:
|
|
115
|
+
last_error = errors.ConnectionError(
|
|
116
|
+
f"The request to {url} timed out."
|
|
117
|
+
)
|
|
118
|
+
last_error.__cause__ = failure
|
|
119
|
+
continue
|
|
120
|
+
except httpx.HTTPError as failure:
|
|
121
|
+
last_error = errors.ConnectionError(f"Could not reach {url}: {failure}")
|
|
122
|
+
last_error.__cause__ = failure
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
if response.status_code in RETRY_STATUSES and attempt < attempts - 1:
|
|
126
|
+
last_error = self._to_error(response)
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
if response.status_code >= 400:
|
|
130
|
+
raise self._to_error(response)
|
|
131
|
+
|
|
132
|
+
if response.status_code == 204 or not response.content:
|
|
133
|
+
return None
|
|
134
|
+
try:
|
|
135
|
+
return response.json()
|
|
136
|
+
except ValueError as failure:
|
|
137
|
+
raise errors.APIError(
|
|
138
|
+
"The API returned a response that was not JSON.",
|
|
139
|
+
status=response.status_code,
|
|
140
|
+
request_id=response.headers.get("X-Request-Id", ""),
|
|
141
|
+
body=response.text[:500],
|
|
142
|
+
) from failure
|
|
143
|
+
|
|
144
|
+
raise last_error or errors.ConnectionError(f"Could not reach {url}.")
|
|
145
|
+
|
|
146
|
+
# ---- pagination ------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def paginate(
|
|
149
|
+
self, path: str, *, params: Mapping[str, Any] | None = None
|
|
150
|
+
) -> Iterator[dict]:
|
|
151
|
+
"""
|
|
152
|
+
Every item across every page, one at a time.
|
|
153
|
+
|
|
154
|
+
Follows ``page.next``, which is a full URL the API builds, rather than
|
|
155
|
+
incrementing an offset. VOXCORE paginates on a cursor precisely because
|
|
156
|
+
an offset shifts when a row is added mid-read, and reconstructing the
|
|
157
|
+
next request here instead of following the given link would put that
|
|
158
|
+
bug back.
|
|
159
|
+
"""
|
|
160
|
+
query: Mapping[str, Any] | None = params
|
|
161
|
+
url = path
|
|
162
|
+
|
|
163
|
+
while url:
|
|
164
|
+
payload = self.request("GET", url, params=query)
|
|
165
|
+
if not isinstance(payload, dict):
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
for item in payload.get("data") or []:
|
|
169
|
+
yield item
|
|
170
|
+
|
|
171
|
+
page = payload.get("page") or {}
|
|
172
|
+
url = page.get("next") or ""
|
|
173
|
+
# The next link already carries the cursor and the limit; sending
|
|
174
|
+
# the original params again would override the cursor with the
|
|
175
|
+
# first page's and loop forever.
|
|
176
|
+
query = None
|
|
177
|
+
|
|
178
|
+
# ---- helpers ---------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def _to_error(self, response: httpx.Response) -> errors.APIError:
|
|
181
|
+
try:
|
|
182
|
+
body = response.json()
|
|
183
|
+
except ValueError:
|
|
184
|
+
body = response.text[:500]
|
|
185
|
+
|
|
186
|
+
failure = errors.from_response(
|
|
187
|
+
response.status_code, body, response.headers.get("X-Request-Id", "")
|
|
188
|
+
)
|
|
189
|
+
if isinstance(failure, errors.RateLimitError):
|
|
190
|
+
header = response.headers.get("Retry-After")
|
|
191
|
+
if header:
|
|
192
|
+
try:
|
|
193
|
+
failure.retry_after = float(header)
|
|
194
|
+
except ValueError:
|
|
195
|
+
pass
|
|
196
|
+
return failure
|
|
197
|
+
|
|
198
|
+
def _backoff(self, attempt: int, last_error: Exception | None) -> float:
|
|
199
|
+
"""
|
|
200
|
+
Exponential, with jitter, and the server's own number when it gave one.
|
|
201
|
+
|
|
202
|
+
The jitter is not decoration. Clients that back off on identical
|
|
203
|
+
schedules retry in lockstep and arrive together, which is how a service
|
|
204
|
+
that was recovering is knocked over by its own users.
|
|
205
|
+
"""
|
|
206
|
+
if isinstance(last_error, errors.RateLimitError) and last_error.retry_after:
|
|
207
|
+
return min(float(last_error.retry_after), 60.0)
|
|
208
|
+
return min(0.5 * (2 ** (attempt - 1)), 8.0) * (1 + random.random() * 0.25)
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def _clean(params: Mapping[str, Any] | None) -> dict | None:
|
|
212
|
+
"""
|
|
213
|
+
Drop None, and render booleans the way the API reads them.
|
|
214
|
+
|
|
215
|
+
``None`` means "the caller did not pass this", and forwarding it as the
|
|
216
|
+
string "None" turns an omitted filter into a filter for the literal
|
|
217
|
+
word. Python's ``True`` would go over as "True", which Django's query
|
|
218
|
+
parsing does not read as true.
|
|
219
|
+
"""
|
|
220
|
+
if not params:
|
|
221
|
+
return None
|
|
222
|
+
out = {}
|
|
223
|
+
for key, value in params.items():
|
|
224
|
+
if value is None:
|
|
225
|
+
continue
|
|
226
|
+
if isinstance(value, bool):
|
|
227
|
+
out[key] = "true" if value else "false"
|
|
228
|
+
elif isinstance(value, (list, tuple)):
|
|
229
|
+
out[key] = ",".join(str(v) for v in value)
|
|
230
|
+
else:
|
|
231
|
+
out[key] = value
|
|
232
|
+
return out or None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def new_idempotency_key() -> str:
|
|
236
|
+
"""A key for a write the caller wants to be safe to retry."""
|
|
237
|
+
return f"vox-{uuid.uuid4()}"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def looks_like_production(base_url: str) -> bool:
|
|
241
|
+
host = (urlparse(base_url).hostname or "").lower()
|
|
242
|
+
return host.endswith("voxcore.net")
|
voxcore/client.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The client: a key, an organization, and the collections underneath it.
|
|
3
|
+
|
|
4
|
+
Every tenant-owned resource on this API is scoped to an organization, so the
|
|
5
|
+
client holds one rather than making every call repeat it. A key belongs to
|
|
6
|
+
exactly one organization anyway -- so passing an org id that is not the key's
|
|
7
|
+
is not a thing this library should let you express, and it does not.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from . import errors, resources
|
|
18
|
+
from ._http import DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, Transport
|
|
19
|
+
|
|
20
|
+
ENV_API_KEY = "VOXCORE_API_KEY"
|
|
21
|
+
ENV_ORG = "VOXCORE_ORG"
|
|
22
|
+
ENV_BASE_URL = "VOXCORE_BASE_URL"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Voxcore:
|
|
26
|
+
"""
|
|
27
|
+
Talk to VOXCORE.
|
|
28
|
+
|
|
29
|
+
from voxcore import Voxcore
|
|
30
|
+
|
|
31
|
+
vox = Voxcore() # reads VOXCORE_API_KEY and VOXCORE_ORG
|
|
32
|
+
for contact in vox.contacts.iterate():
|
|
33
|
+
print(contact["display_name"])
|
|
34
|
+
|
|
35
|
+
The key and organization are read from the environment when not passed,
|
|
36
|
+
because the alternative is a key written into a file that ends up in a
|
|
37
|
+
commit. Nothing here ever writes a key anywhere.
|
|
38
|
+
|
|
39
|
+
Usable as a context manager, which closes the connection pool::
|
|
40
|
+
|
|
41
|
+
with Voxcore() as vox:
|
|
42
|
+
vox.agents.list()
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
api_key: str | None = None,
|
|
48
|
+
*,
|
|
49
|
+
org: str | None = None,
|
|
50
|
+
base_url: str | None = None,
|
|
51
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
52
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
53
|
+
http_client: httpx.Client | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
api_key = api_key or os.environ.get(ENV_API_KEY, "")
|
|
56
|
+
org = org or os.environ.get(ENV_ORG, "")
|
|
57
|
+
base_url = base_url or os.environ.get(ENV_BASE_URL) or DEFAULT_BASE_URL
|
|
58
|
+
|
|
59
|
+
if not api_key:
|
|
60
|
+
raise errors.VoxcoreError(
|
|
61
|
+
"No API key. Pass Voxcore(api_key=...) or set "
|
|
62
|
+
f"{ENV_API_KEY} in the environment."
|
|
63
|
+
)
|
|
64
|
+
if not org:
|
|
65
|
+
raise errors.VoxcoreError(
|
|
66
|
+
"No organization. Pass Voxcore(org=...) or set "
|
|
67
|
+
f"{ENV_ORG}. It looks like 'org__...' and is in the URL of "
|
|
68
|
+
"your dashboard."
|
|
69
|
+
)
|
|
70
|
+
# Caught here rather than as a 401 three lines later, because the
|
|
71
|
+
# mistake it catches -- reaching for a publishable key on a server --
|
|
72
|
+
# produces an authentication error that reads like a bad key.
|
|
73
|
+
if api_key.startswith("vox_pk_"):
|
|
74
|
+
raise errors.VoxcoreError(
|
|
75
|
+
"That is a publishable key. It is safe in a browser and "
|
|
76
|
+
"cannot read this API. Use a secret key (vox_sk_...)."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
self._transport = Transport(
|
|
80
|
+
api_key=api_key,
|
|
81
|
+
base_url=base_url,
|
|
82
|
+
timeout=timeout,
|
|
83
|
+
max_retries=max_retries,
|
|
84
|
+
http_client=http_client,
|
|
85
|
+
)
|
|
86
|
+
self.org = org
|
|
87
|
+
|
|
88
|
+
self.agents = resources.Agents(self._transport, org)
|
|
89
|
+
self.calls = resources.Calls(self._transport, org)
|
|
90
|
+
self.campaigns = resources.Campaigns(self._transport, org)
|
|
91
|
+
self.chat_sessions = resources.ChatSessions(self._transport, org)
|
|
92
|
+
self.contacts = resources.Contacts(self._transport, org)
|
|
93
|
+
self.contact_batches = resources.ContactBatches(self._transport, org)
|
|
94
|
+
self.flows = resources.Flows(self._transport, org)
|
|
95
|
+
self.forms = resources.Forms(self._transport, org)
|
|
96
|
+
self.keys = resources.ApiKeys(self._transport, org)
|
|
97
|
+
self.meetings = resources.Meetings(self._transport, org)
|
|
98
|
+
self.meeting_types = resources.MeetingTypes(self._transport, org)
|
|
99
|
+
self.phone_numbers = resources.PhoneNumbers(self._transport, org)
|
|
100
|
+
self.threads = resources.Threads(self._transport, org)
|
|
101
|
+
self.widgets = resources.Widgets(self._transport, org)
|
|
102
|
+
|
|
103
|
+
# ---- escape hatch ----------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
106
|
+
"""
|
|
107
|
+
Call an endpoint this library does not wrap yet.
|
|
108
|
+
|
|
109
|
+
Deliberately public. An SDK that lags the API by one release is an SDK
|
|
110
|
+
that blocks people, and the honest answer is a documented way through
|
|
111
|
+
rather than a fork. ``path`` is joined to the base URL as given, so
|
|
112
|
+
pass the full "/api/v1/..." path.
|
|
113
|
+
"""
|
|
114
|
+
return self._transport.request(method, path, **kwargs)
|
|
115
|
+
|
|
116
|
+
# ---- lifecycle -------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def close(self) -> None:
|
|
119
|
+
self._transport.close()
|
|
120
|
+
|
|
121
|
+
def __enter__(self) -> "Voxcore":
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
def __exit__(self, *_: object) -> None:
|
|
125
|
+
self.close()
|
|
126
|
+
|
|
127
|
+
def __repr__(self) -> str:
|
|
128
|
+
# The key is never rendered, not even truncated. A repr goes into
|
|
129
|
+
# tracebacks, logs and bug reports.
|
|
130
|
+
return f"<Voxcore org={self.org!r}>"
|
voxcore/errors.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""
|
|
2
|
+
What went wrong, as types a caller can branch on.
|
|
3
|
+
|
|
4
|
+
One exception per thing you would actually do differently. ``VoxcoreError`` is
|
|
5
|
+
the base, so ``except VoxcoreError`` catches everything this library raises and
|
|
6
|
+
nothing it does not -- a caller should never have to catch ``httpx`` types to
|
|
7
|
+
use this.
|
|
8
|
+
|
|
9
|
+
The distinction that earns its keep is ``AuthenticationError`` against
|
|
10
|
+
``PermissionDenied``. The first means the key is wrong, expired or revoked, and
|
|
11
|
+
retrying will never help; the second means the key is fine and lacks a scope,
|
|
12
|
+
which is a fix in the dashboard rather than in the code. Collapsing them into
|
|
13
|
+
one "not allowed" sends people to re-read their key when the key was correct.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class VoxcoreError(Exception):
|
|
20
|
+
"""Base for everything this library raises."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class APIError(VoxcoreError):
|
|
24
|
+
"""
|
|
25
|
+
The API answered, and the answer was an error.
|
|
26
|
+
|
|
27
|
+
``code`` is VOXCORE's own machine-readable string when it sent one, which
|
|
28
|
+
is more stable than the HTTP status and much more stable than the message.
|
|
29
|
+
Branch on ``code``; show ``message``.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
message: str,
|
|
35
|
+
*,
|
|
36
|
+
status: int | None = None,
|
|
37
|
+
code: str = "",
|
|
38
|
+
request_id: str = "",
|
|
39
|
+
body: object = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
super().__init__(message)
|
|
42
|
+
self.message = message
|
|
43
|
+
self.status = status
|
|
44
|
+
self.code = code
|
|
45
|
+
# Echoed back on every response. Quote it in a support conversation --
|
|
46
|
+
# it is how a specific request is found in the logs.
|
|
47
|
+
self.request_id = request_id
|
|
48
|
+
self.body = body
|
|
49
|
+
|
|
50
|
+
def __str__(self) -> str:
|
|
51
|
+
parts = [self.message]
|
|
52
|
+
if self.code:
|
|
53
|
+
parts.append(f"[{self.code}]")
|
|
54
|
+
if self.request_id:
|
|
55
|
+
parts.append(f"(request {self.request_id})")
|
|
56
|
+
return " ".join(parts)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AuthenticationError(APIError):
|
|
60
|
+
"""The key is missing, malformed, expired or revoked. 401."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class PermissionDenied(APIError):
|
|
64
|
+
"""The key is valid and not allowed to do this. 403."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class NotFoundError(APIError):
|
|
68
|
+
"""No such resource, or not one this key's organization can see. 404."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ValidationError(APIError):
|
|
72
|
+
"""
|
|
73
|
+
The request was refused as malformed. 400 or 422.
|
|
74
|
+
|
|
75
|
+
``fields`` maps a field name to what was wrong with it, when the API said.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, message: str, *, fields: dict | None = None, **kwargs) -> None:
|
|
79
|
+
super().__init__(message, **kwargs)
|
|
80
|
+
self.fields = fields or {}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class RateLimitError(APIError):
|
|
84
|
+
"""
|
|
85
|
+
Too many requests. 429.
|
|
86
|
+
|
|
87
|
+
``retry_after`` is seconds, from the header, when the API sent one. The
|
|
88
|
+
client already retries these; seeing one means the retries were exhausted.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(self, message: str, *, retry_after: float | None = None, **kwargs):
|
|
92
|
+
super().__init__(message, **kwargs)
|
|
93
|
+
self.retry_after = retry_after
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ServerError(APIError):
|
|
97
|
+
"""Something broke on our side. 5xx. Safe to retry."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ConnectionError(VoxcoreError):
|
|
101
|
+
"""The request never reached the API, or the answer never came back."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def from_response(status: int, body: object, request_id: str = "") -> APIError:
|
|
105
|
+
"""
|
|
106
|
+
Turn an error response into the narrowest exception that fits.
|
|
107
|
+
|
|
108
|
+
VOXCORE errors arrive as ``{"error": {"code": ..., "message": ...}}``. A
|
|
109
|
+
body that does not match -- a proxy's HTML error page, a gateway timeout --
|
|
110
|
+
still has to produce a usable exception, so the shape is probed rather
|
|
111
|
+
than assumed.
|
|
112
|
+
"""
|
|
113
|
+
code = ""
|
|
114
|
+
message = ""
|
|
115
|
+
fields: dict = {}
|
|
116
|
+
|
|
117
|
+
if isinstance(body, dict):
|
|
118
|
+
error = body.get("error")
|
|
119
|
+
if isinstance(error, dict):
|
|
120
|
+
code = str(error.get("code") or "")
|
|
121
|
+
message = str(error.get("message") or "")
|
|
122
|
+
found = error.get("fields")
|
|
123
|
+
if isinstance(found, dict):
|
|
124
|
+
fields = found
|
|
125
|
+
elif isinstance(error, str):
|
|
126
|
+
message = error
|
|
127
|
+
if not message:
|
|
128
|
+
# DRF's own shape, which some endpoints still return directly.
|
|
129
|
+
detail = body.get("detail")
|
|
130
|
+
if detail:
|
|
131
|
+
message = str(detail)
|
|
132
|
+
else:
|
|
133
|
+
fields = {
|
|
134
|
+
key: value
|
|
135
|
+
for key, value in body.items()
|
|
136
|
+
if isinstance(value, (list, str))
|
|
137
|
+
}
|
|
138
|
+
if fields:
|
|
139
|
+
message = "The request was refused as invalid."
|
|
140
|
+
|
|
141
|
+
if not message:
|
|
142
|
+
message = f"The API returned HTTP {status}."
|
|
143
|
+
|
|
144
|
+
shared = {"status": status, "code": code, "request_id": request_id, "body": body}
|
|
145
|
+
|
|
146
|
+
if status == 401:
|
|
147
|
+
return AuthenticationError(message, **shared)
|
|
148
|
+
if status == 403:
|
|
149
|
+
return PermissionDenied(message, **shared)
|
|
150
|
+
if status == 404:
|
|
151
|
+
return NotFoundError(message, **shared)
|
|
152
|
+
if status == 429:
|
|
153
|
+
return RateLimitError(message, **shared)
|
|
154
|
+
if status in (400, 422):
|
|
155
|
+
return ValidationError(message, fields=fields, **shared)
|
|
156
|
+
if status >= 500:
|
|
157
|
+
return ServerError(message, **shared)
|
|
158
|
+
return APIError(message, **shared)
|
voxcore/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The collections a key can reach, one class each.
|
|
3
|
+
|
|
4
|
+
Every one is a ``Resource`` subclass and most add nothing -- the API is uniform
|
|
5
|
+
enough that a name is the whole definition. Where a resource has an endpoint of
|
|
6
|
+
its own, it is a method with a docstring saying what it does that the five
|
|
7
|
+
standard verbs do not.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Iterator
|
|
13
|
+
|
|
14
|
+
from ._base import Resource
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Agents(Resource):
|
|
18
|
+
"""The agents in this organization: what they say and how they sound."""
|
|
19
|
+
|
|
20
|
+
name = "agents"
|
|
21
|
+
|
|
22
|
+
def publish(self, agent_id: str, *, notes: str = "") -> dict:
|
|
23
|
+
"""
|
|
24
|
+
Freeze the current configuration as a version and make it live.
|
|
25
|
+
|
|
26
|
+
Editing an agent is not publishing it. A live conversation pins the
|
|
27
|
+
agent's live version, so changes made through ``update`` do not reach
|
|
28
|
+
callers until this is called -- which is the single most common
|
|
29
|
+
surprise when driving an agent from a script.
|
|
30
|
+
"""
|
|
31
|
+
return self._action(agent_id, "publish", json={"notes": notes})
|
|
32
|
+
|
|
33
|
+
def versions(self, agent_id: str) -> list[dict]:
|
|
34
|
+
"""
|
|
35
|
+
Every published version of this agent, newest first.
|
|
36
|
+
|
|
37
|
+
Unwrapped here. The endpoint answers with the usual ``{"data": [...]}``
|
|
38
|
+
envelope, and returning that dict from a method annotated ``list[dict]``
|
|
39
|
+
meant iterating it yielded the string ``"data"`` -- which is not an
|
|
40
|
+
error anywhere, just a loop over one wrong thing.
|
|
41
|
+
"""
|
|
42
|
+
payload = self._action(agent_id, "versions", method="GET")
|
|
43
|
+
if isinstance(payload, dict):
|
|
44
|
+
return payload.get("data") or []
|
|
45
|
+
return payload or []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Calls(Resource):
|
|
49
|
+
"""
|
|
50
|
+
Calls, inbound and outbound.
|
|
51
|
+
|
|
52
|
+
Read-only in practice: a call is a record of something that happened, and
|
|
53
|
+
it is created by the phone ringing rather than by this library. To place
|
|
54
|
+
one, start a campaign.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
name = "calls"
|
|
58
|
+
|
|
59
|
+
def transcript(self, call_id: str) -> dict:
|
|
60
|
+
"""
|
|
61
|
+
What was said, if the transcript has not expired.
|
|
62
|
+
|
|
63
|
+
Retention is per organization and enforced by a deletion pass, so an
|
|
64
|
+
old call can legitimately have no transcript. That is a 404 and not a
|
|
65
|
+
failure of the call.
|
|
66
|
+
"""
|
|
67
|
+
return self._action(call_id, "transcript", method="GET")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Contacts(Resource):
|
|
71
|
+
"""
|
|
72
|
+
The people this organization talks to.
|
|
73
|
+
|
|
74
|
+
Consent fields are readable and NOT writable, deliberately: they record
|
|
75
|
+
what a customer said, and a field a script can set is not evidence that
|
|
76
|
+
they said it. The opt-out flags (``do_not_call``, ``do_not_message``) ARE
|
|
77
|
+
writable, because somebody ringing to ask to be removed has to be
|
|
78
|
+
recordable by whoever takes the call.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
name = "contacts"
|
|
82
|
+
|
|
83
|
+
def find_by_phone(self, phone: str) -> dict | None:
|
|
84
|
+
"""
|
|
85
|
+
The contact on this number, or None.
|
|
86
|
+
|
|
87
|
+
Searched with ``q``, which is the parameter the directory actually
|
|
88
|
+
reads. This used to pass ``phone=``, and the contact list IGNORES
|
|
89
|
+
parameters it does not know -- so the filter did nothing, the first
|
|
90
|
+
page of every contact came back, and the first row of it was returned
|
|
91
|
+
as the match. It never raised and it was almost never right.
|
|
92
|
+
|
|
93
|
+
The result is checked before it is handed back, because ``q`` is a
|
|
94
|
+
search and not a lookup: it matches a number by its last seven or more
|
|
95
|
+
digits, so "+18175550147" and "5550147" find each other. That is the
|
|
96
|
+
behaviour you want when a person types into a box and the wrong one to
|
|
97
|
+
trust silently, so anything whose number does not actually match is
|
|
98
|
+
dropped here rather than returned as a near miss.
|
|
99
|
+
"""
|
|
100
|
+
digits = "".join(character for character in phone if character.isdigit())
|
|
101
|
+
for contact in self.list(q=phone):
|
|
102
|
+
found = "".join(c for c in (contact.get("phone") or "") if c.isdigit())
|
|
103
|
+
if found and digits and (found.endswith(digits) or digits.endswith(found)):
|
|
104
|
+
return contact
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ContactBatches(Resource):
|
|
109
|
+
"""
|
|
110
|
+
Named audiences: a list somebody made, or a saved question.
|
|
111
|
+
|
|
112
|
+
A **static** batch is a list -- membership changes only when a person
|
|
113
|
+
changes it. A **dynamic** batch is a rule, answered against the contact
|
|
114
|
+
table whenever it is asked, so its membership moves as the contacts do.
|
|
115
|
+
``kind`` cannot be changed after creation.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
name = "contact-batches"
|
|
119
|
+
|
|
120
|
+
def members(self, batch_id: str, **filters: Any) -> Iterator[dict]:
|
|
121
|
+
"""
|
|
122
|
+
Everybody in the batch, including anyone opted out.
|
|
123
|
+
|
|
124
|
+
For looking at, never for sending. Suppressed contacts are included
|
|
125
|
+
here on purpose so a preview can say "1,204 people, 37 of whom have
|
|
126
|
+
opted out" -- the API applies do-not-contact when a campaign resolves
|
|
127
|
+
the batch, and there is no way to switch that off.
|
|
128
|
+
"""
|
|
129
|
+
return self._transport.paginate(
|
|
130
|
+
f"{self._detail(batch_id)}members/", params=filters
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def add(self, batch_id: str, contact_ids: list[str]) -> dict:
|
|
134
|
+
"""Pin contacts into a static batch. Refused on a dynamic one."""
|
|
135
|
+
return self._action(batch_id, "members", json={"contacts": contact_ids})
|
|
136
|
+
|
|
137
|
+
def exclude(
|
|
138
|
+
self, batch_id: str, contact_ids: list[str], *, reason: str = ""
|
|
139
|
+
) -> dict:
|
|
140
|
+
"""
|
|
141
|
+
Hold people out of this batch without touching them anywhere else.
|
|
142
|
+
|
|
143
|
+
The alternative to editing their tags, which changes every other batch
|
|
144
|
+
too, or setting do-not-call, which is a legal flag being used as a
|
|
145
|
+
filing device. An exclusion is local, reversible, and survives the next
|
|
146
|
+
import that would otherwise put them back.
|
|
147
|
+
|
|
148
|
+
**A POST, not a DELETE, and this was wrong in two ways at once.** It
|
|
149
|
+
sent ``{"contact": id}`` where the endpoint reads a ``contacts`` list,
|
|
150
|
+
so it was refused as malformed; and it sent DELETE, which REMOVES a
|
|
151
|
+
membership row rather than excluding anyone. Those are opposite
|
|
152
|
+
outcomes on a static batch. Removing somebody lets the next import put
|
|
153
|
+
them straight back, which is the precise failure an exclusion exists
|
|
154
|
+
to prevent.
|
|
155
|
+
"""
|
|
156
|
+
return self._action(
|
|
157
|
+
batch_id,
|
|
158
|
+
"members",
|
|
159
|
+
json={"contacts": contact_ids, "state": "excluded", "reason": reason},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def remove(self, batch_id: str, contact_ids: list[str]) -> dict:
|
|
163
|
+
"""
|
|
164
|
+
Take membership rows out of this batch entirely.
|
|
165
|
+
|
|
166
|
+
Not the same thing as ``exclude``. This forgets that the batch ever
|
|
167
|
+
had an opinion about these people, so a later import can add them
|
|
168
|
+
again. On a dynamic batch it lifts an existing exclusion, which can
|
|
169
|
+
put somebody back into the audience. Reach for ``exclude`` when you
|
|
170
|
+
mean "not these, from this list".
|
|
171
|
+
"""
|
|
172
|
+
return self._action(
|
|
173
|
+
batch_id, "members", method="DELETE", json={"contacts": contact_ids}
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class Campaigns(Resource):
|
|
178
|
+
"""
|
|
179
|
+
Outbound calling campaigns: a standing instruction to ring a batch.
|
|
180
|
+
|
|
181
|
+
Created in draft and dialling nobody. Starting one is a separate call,
|
|
182
|
+
because a campaign that began the moment it was saved is a campaign that
|
|
183
|
+
rings twenty thousand people before anybody read it back.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
name = "campaigns"
|
|
187
|
+
|
|
188
|
+
def start(self, campaign_id: str) -> dict:
|
|
189
|
+
return self._action(campaign_id, "start")
|
|
190
|
+
|
|
191
|
+
def pause(self, campaign_id: str) -> dict:
|
|
192
|
+
return self._action(campaign_id, "pause")
|
|
193
|
+
|
|
194
|
+
def attempts(self, campaign_id: str, **filters: Any) -> Iterator[dict]:
|
|
195
|
+
"""The dial ledger: one row per call this campaign put on a carrier."""
|
|
196
|
+
return self._transport.paginate(
|
|
197
|
+
f"{self._detail(campaign_id)}attempts/", params=filters
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class PhoneNumbers(Resource):
|
|
202
|
+
name = "phone-numbers"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class Threads(Resource):
|
|
206
|
+
"""Texting conversations, on SMS or iMessage."""
|
|
207
|
+
|
|
208
|
+
name = "threads"
|
|
209
|
+
|
|
210
|
+
def send(self, thread_id: str, text: str, *, idempotency_key: Any = True) -> dict:
|
|
211
|
+
"""
|
|
212
|
+
Send a message into this thread.
|
|
213
|
+
|
|
214
|
+
Keyed by default. A dropped connection on an unkeyed send leaves you
|
|
215
|
+
unable to tell whether it went, and the honest recoveries are "text
|
|
216
|
+
them twice" or "never text them" -- so this one defaults to safe.
|
|
217
|
+
"""
|
|
218
|
+
return self._action(
|
|
219
|
+
thread_id, "messages", json={"text": text}, idempotency_key=idempotency_key
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class ChatSessions(Resource):
|
|
224
|
+
name = "chat-sessions"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class Widgets(Resource):
|
|
228
|
+
name = "widgets"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class Forms(Resource):
|
|
232
|
+
name = "forms"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class Flows(Resource):
|
|
236
|
+
"""Messaging sequences. These do not run on a voice call."""
|
|
237
|
+
|
|
238
|
+
name = "flows"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class Meetings(Resource):
|
|
242
|
+
name = "meetings"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class MeetingTypes(Resource):
|
|
246
|
+
name = "meeting-types"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class ApiKeys(Resource):
|
|
250
|
+
"""
|
|
251
|
+
The keys for this organization.
|
|
252
|
+
|
|
253
|
+
A secret is returned exactly once, when it is created, and is never
|
|
254
|
+
stored -- only a hash and a display prefix. There is no way to read one
|
|
255
|
+
back, so a lost key is rotated rather than recovered.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
name = "keys"
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
__all__ = [
|
|
262
|
+
"Resource",
|
|
263
|
+
"Agents",
|
|
264
|
+
"ApiKeys",
|
|
265
|
+
"Calls",
|
|
266
|
+
"Campaigns",
|
|
267
|
+
"ChatSessions",
|
|
268
|
+
"ContactBatches",
|
|
269
|
+
"Contacts",
|
|
270
|
+
"Flows",
|
|
271
|
+
"Forms",
|
|
272
|
+
"Meetings",
|
|
273
|
+
"MeetingTypes",
|
|
274
|
+
"PhoneNumbers",
|
|
275
|
+
"Threads",
|
|
276
|
+
"Widgets",
|
|
277
|
+
]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The shape every resource shares.
|
|
3
|
+
|
|
4
|
+
VOXCORE's API is uniform -- every tenant-owned collection lives at
|
|
5
|
+
``/api/v1/orgs/<org>/<name>``, lists through the same cursor envelope and
|
|
6
|
+
returns the same error shape -- so the resources are a base class plus a
|
|
7
|
+
string, rather than twenty near-copies that drift apart one endpoint at a time.
|
|
8
|
+
|
|
9
|
+
Anything a resource does differently is written on that resource and nowhere
|
|
10
|
+
else, which makes the exceptions easy to see.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Iterator, Mapping
|
|
16
|
+
|
|
17
|
+
from .._http import Transport, new_idempotency_key
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Resource:
|
|
21
|
+
"""One collection under an organization."""
|
|
22
|
+
|
|
23
|
+
#: The path segment, e.g. "contacts". Set by each subclass.
|
|
24
|
+
name: str = ""
|
|
25
|
+
|
|
26
|
+
def __init__(self, transport: Transport, org_id: str) -> None:
|
|
27
|
+
self._transport = transport
|
|
28
|
+
self._org = org_id
|
|
29
|
+
|
|
30
|
+
# ---- paths -----------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
# ---- Every path ends in a slash, and that is not cosmetic ----------
|
|
33
|
+
#
|
|
34
|
+
# The API runs Django with APPEND_SLASH on. Without the trailing slash:
|
|
35
|
+
#
|
|
36
|
+
# GET /contacts -> 301 to /contacts/. httpx does not follow redirects
|
|
37
|
+
# by default, so the client read an empty body and
|
|
38
|
+
# returned an EMPTY LIST. Not an error. Just no
|
|
39
|
+
# contacts, quietly, forever.
|
|
40
|
+
# POST /contacts -> Django cannot redirect a POST without dropping the
|
|
41
|
+
# body, so it raises, and the caller gets a 500.
|
|
42
|
+
#
|
|
43
|
+
# Mocked tests cannot catch this: a mock transport answers whatever URL it
|
|
44
|
+
# is handed. It took one request against the real API.
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def _collection(self) -> str:
|
|
48
|
+
return f"/api/v1/orgs/{self._org}/{self.name}/"
|
|
49
|
+
|
|
50
|
+
def _detail(self, resource_id: str) -> str:
|
|
51
|
+
return f"{self._collection}{resource_id}/"
|
|
52
|
+
|
|
53
|
+
# ---- the five verbs --------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def list(self, **filters: Any) -> list[dict]:
|
|
56
|
+
"""
|
|
57
|
+
The first page only. Use ``iterate`` for everything.
|
|
58
|
+
|
|
59
|
+
Returning one page rather than silently fetching all of them is the
|
|
60
|
+
safer default: a tenant with forty thousand contacts should not
|
|
61
|
+
discover that ``list()`` makes sixteen hundred requests.
|
|
62
|
+
"""
|
|
63
|
+
payload = self._transport.request("GET", self._collection, params=filters)
|
|
64
|
+
if isinstance(payload, dict):
|
|
65
|
+
return payload.get("data") or []
|
|
66
|
+
return payload or []
|
|
67
|
+
|
|
68
|
+
def iterate(self, **filters: Any) -> Iterator[dict]:
|
|
69
|
+
"""Every item, following the cursor. Lazy -- stop and it stops."""
|
|
70
|
+
return self._transport.paginate(self._collection, params=filters)
|
|
71
|
+
|
|
72
|
+
def retrieve(self, resource_id: str) -> dict:
|
|
73
|
+
return self._transport.request("GET", self._detail(resource_id))
|
|
74
|
+
|
|
75
|
+
def create(self, *, idempotency_key: str | None = None, **fields: Any) -> dict:
|
|
76
|
+
"""
|
|
77
|
+
Make one.
|
|
78
|
+
|
|
79
|
+
Pass ``idempotency_key`` to make the write safe to retry -- without it
|
|
80
|
+
a dropped connection leaves you unable to tell whether the thing was
|
|
81
|
+
created, and the transport will not repeat the request. ``True``
|
|
82
|
+
generates one for you.
|
|
83
|
+
"""
|
|
84
|
+
return self._transport.request(
|
|
85
|
+
"POST",
|
|
86
|
+
self._collection,
|
|
87
|
+
json=fields,
|
|
88
|
+
idempotency_key=_resolve_key(idempotency_key),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def update(self, resource_id: str, **fields: Any) -> dict:
|
|
92
|
+
"""Change some fields. A PATCH -- omitted fields are left alone."""
|
|
93
|
+
return self._transport.request(
|
|
94
|
+
"PATCH", self._detail(resource_id), json=fields
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def delete(self, resource_id: str) -> None:
|
|
98
|
+
"""
|
|
99
|
+
Remove it.
|
|
100
|
+
|
|
101
|
+
Most things here are soft-deleted by the API: a contact, a batch or an
|
|
102
|
+
agent that a call or a campaign refers to stays resolvable, because a
|
|
103
|
+
hard delete turns "who did we ring?" into an id that resolves to
|
|
104
|
+
nothing. What you get back is the same either way -- nothing.
|
|
105
|
+
"""
|
|
106
|
+
self._transport.request("DELETE", self._detail(resource_id))
|
|
107
|
+
|
|
108
|
+
# ---- for subclasses --------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def _action(
|
|
111
|
+
self,
|
|
112
|
+
resource_id: str,
|
|
113
|
+
action: str,
|
|
114
|
+
*,
|
|
115
|
+
method: str = "POST",
|
|
116
|
+
json: Any = None,
|
|
117
|
+
params: Mapping[str, Any] | None = None,
|
|
118
|
+
idempotency_key: str | None = None,
|
|
119
|
+
) -> Any:
|
|
120
|
+
return self._transport.request(
|
|
121
|
+
method,
|
|
122
|
+
f"{self._detail(resource_id)}{action}/",
|
|
123
|
+
json=json,
|
|
124
|
+
params=params,
|
|
125
|
+
idempotency_key=_resolve_key(idempotency_key),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _resolve_key(value: str | bool | None) -> str | None:
|
|
130
|
+
if value is True:
|
|
131
|
+
return new_idempotency_key()
|
|
132
|
+
if value in (False, None):
|
|
133
|
+
return None
|
|
134
|
+
return str(value)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: voxcore-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The official Python client for VOXCORE — AI agents that answer your phone, text your customers, and talk to your website visitors.
|
|
5
|
+
Project-URL: Homepage, https://voxcore.net
|
|
6
|
+
Project-URL: Documentation, https://voxcore.net/docs
|
|
7
|
+
Project-URL: Source, https://github.com/CodeCraftStudios/voxcore-python
|
|
8
|
+
Author-email: CodeCraft Studios <support@voxcore.net>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: agents,ai,sms,telephony,voice,voxcore
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Communications :: Telephony
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Requires-Dist: httpx<1,>=0.24
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: mypy>=1.5; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
27
|
+
Requires-Dist: respx>=0.20; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# VOXCORE for Python
|
|
32
|
+
|
|
33
|
+
The official Python client for [VOXCORE](https://voxcore.net) — AI agents that answer your phone, text your customers, and talk to visitors on your website.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install voxcore-sdk
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The distribution is `voxcore-sdk`; what you import is `voxcore`.
|
|
40
|
+
|
|
41
|
+
## Getting started
|
|
42
|
+
|
|
43
|
+
You need a **secret key** and your **organization id**. Both come from your dashboard: keys under Settings → API keys, and the org id (`org__…`) from the URL.
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from voxcore import Voxcore
|
|
47
|
+
|
|
48
|
+
vox = Voxcore(api_key="vox_sk_live_…", org="org__…")
|
|
49
|
+
|
|
50
|
+
for contact in vox.contacts.iterate():
|
|
51
|
+
print(contact["display_name"])
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Or leave them out and set `VOXCORE_API_KEY` and `VOXCORE_ORG` in the environment, which is where a key belongs — not in a file that ends up in a commit:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
vox = Voxcore()
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The client holds a connection pool, so reuse one rather than making a new one per call. It works as a context manager if you want the pool closed:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
with Voxcore() as vox:
|
|
64
|
+
vox.agents.list()
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Listing things
|
|
68
|
+
|
|
69
|
+
Every collection has the same five verbs — `list`, `iterate`, `retrieve`, `create`, `update`, `delete` — and two ways to read:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
vox.contacts.list(status="customer") # the first page, as a list
|
|
73
|
+
vox.contacts.iterate(status="customer") # every page, as a lazy iterator
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`list()` returns **one page**. That is deliberate: a tenant with forty thousand contacts should not discover that `list()` quietly made sixteen hundred requests. Use `iterate()` when you want all of them — it follows the API's cursor, so rows arriving while you read do not shift the pages under you.
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
consented = [
|
|
80
|
+
c for c in vox.contacts.iterate()
|
|
81
|
+
if c["marketing_consent_message"]
|
|
82
|
+
]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Writing things
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
contact = vox.contacts.create(
|
|
89
|
+
first_name="Dana",
|
|
90
|
+
phone="+18175550147", # E.164, always
|
|
91
|
+
status="lead",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
vox.contacts.update(contact["id"], status="customer")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Retries and idempotency
|
|
98
|
+
|
|
99
|
+
`GET`s are retried automatically on connection failures, 429s and 5xx, with backoff.
|
|
100
|
+
|
|
101
|
+
**Writes are not**, unless you make them safe. Repeating a `POST` can create a second contact or place a second call, so the client will not do it on its own. Pass an idempotency key and it will:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
vox.contacts.create(first_name="Dana", idempotency_key=True) # generated
|
|
105
|
+
vox.contacts.create(first_name="Dana", idempotency_key="order-8127") # yours
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Use your own key when you have a natural one — an order id, a row id — because that is what makes the retry safe across process restarts too.
|
|
109
|
+
|
|
110
|
+
Sending a text is keyed by default. Texting somebody twice is worse than the alternative:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
vox.threads.send("thr__…", "Your order is ready.")
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Errors
|
|
117
|
+
|
|
118
|
+
Everything inherits from `VoxcoreError`, so one `except` catches everything this library raises and nothing it does not.
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from voxcore import Voxcore, AuthenticationError, PermissionDenied, RateLimitError
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
vox.contacts.list()
|
|
125
|
+
except AuthenticationError:
|
|
126
|
+
... # the key is wrong, expired or revoked — retrying will never help
|
|
127
|
+
except PermissionDenied:
|
|
128
|
+
... # the key is fine and lacks a scope — fix it in the dashboard
|
|
129
|
+
except RateLimitError as e:
|
|
130
|
+
... # retries were already exhausted; e.retry_after is seconds
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Every error carries `.code` (VOXCORE's machine-readable string — branch on this, it is more stable than the HTTP status), `.message`, `.status` and `.request_id`. Quote the request id in a support conversation; it is how a specific request is found in the logs.
|
|
134
|
+
|
|
135
|
+
## What you can reach
|
|
136
|
+
|
|
137
|
+
| | |
|
|
138
|
+
|---|---|
|
|
139
|
+
| `vox.agents` | Agents — what they say, how they sound. Also `.publish()` |
|
|
140
|
+
| `vox.calls` | Calls in and out, and `.transcript()` |
|
|
141
|
+
| `vox.campaigns` | Outbound calling campaigns. `.start()`, `.pause()`, `.attempts()` |
|
|
142
|
+
| `vox.contacts` | The people you talk to. `.find_by_phone()` |
|
|
143
|
+
| `vox.contact_batches` | Named audiences. `.members()`, `.add()`, `.exclude()` |
|
|
144
|
+
| `vox.threads` | Texting conversations. `.send()` |
|
|
145
|
+
| `vox.phone_numbers` | Your lines |
|
|
146
|
+
| `vox.widgets`, `vox.forms`, `vox.flows` | Website widgets, forms, messaging sequences |
|
|
147
|
+
| `vox.meetings`, `vox.meeting_types` | Booking |
|
|
148
|
+
| `vox.keys` | API keys |
|
|
149
|
+
|
|
150
|
+
Anything not wrapped yet is still reachable, and that is on purpose — an SDK that lags the API by a release is an SDK that blocks you:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
vox.request("GET", "/api/v1/orgs/org__…/something-new")
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Two things worth knowing
|
|
157
|
+
|
|
158
|
+
**Consent is read-only.** `marketing_consent_call`, `marketing_consent_message` and `marketing_consent_email` can be read but never written through the API. They record what a customer actually said — with a timestamp, the call it came from, and a verbatim quote — and a field a script can set is not evidence that anybody said anything. The opt-out flags (`do_not_call`, `do_not_message`) *are* writable, because someone ringing to ask to be removed has to be recordable by whoever takes the call.
|
|
159
|
+
|
|
160
|
+
**Publishing is not saving.** Updating an agent does not change what callers hear. A live conversation pins the agent's published version, so changes reach the phone only when you publish:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
vox.agents.update(agent_id, system_prompt="…")
|
|
164
|
+
vox.agents.publish(agent_id, notes="new opening line")
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Requirements
|
|
168
|
+
|
|
169
|
+
Python 3.9+. One dependency: `httpx`.
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
voxcore/__about__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
voxcore/__init__.py,sha256=sb7tLHwxcbG8BGsyUEzOeRI8yMhc2bXtSrr83u8XRN8,861
|
|
3
|
+
voxcore/_http.py,sha256=1FuoWYBgQQH4_3dpoprDkXAD_HZd7NeBq49tmDFHyPo,8847
|
|
4
|
+
voxcore/client.py,sha256=Ii7E1MSIFMynNXP5hL7LWPcpjfYWPiOH9tv6NX2AAyQ,4858
|
|
5
|
+
voxcore/errors.py,sha256=aQB4ztLEIRXOUejAcxWgt7Im8nwZMCwfPDqNEK_GB8I,5141
|
|
6
|
+
voxcore/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
voxcore/resources/__init__.py,sha256=mYhYjpxM0WYDSk1W9xEF20uzPZEoFLqSkTbzFcwtpU8,9739
|
|
8
|
+
voxcore/resources/_base.py,sha256=_MRMYAeefZcjBbbYev9cK2oxgl43Wc_bd4v9QQf0IYU,5024
|
|
9
|
+
voxcore_sdk-0.1.0.dist-info/METADATA,sha256=osHbZEiIiTFgPCuQns45CPdvN-2iWBn0kZyaOePqHWs,6667
|
|
10
|
+
voxcore_sdk-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
11
|
+
voxcore_sdk-0.1.0.dist-info/RECORD,,
|