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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any