nixflex 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nixflex-0.1.0/.github/workflows/ci.yml +14 -0
- nixflex-0.1.0/.gitignore +6 -0
- nixflex-0.1.0/CHANGELOG.md +2 -0
- nixflex-0.1.0/LICENSE +9 -0
- nixflex-0.1.0/PKG-INFO +67 -0
- nixflex-0.1.0/README.md +37 -0
- nixflex-0.1.0/pyproject.toml +43 -0
- nixflex-0.1.0/src/nixflex/__init__.py +94 -0
- nixflex-0.1.0/src/nixflex/_http.py +147 -0
- nixflex-0.1.0/src/nixflex/errors.py +78 -0
- nixflex-0.1.0/src/nixflex/py.typed +0 -0
- nixflex-0.1.0/src/nixflex/resources/__init__.py +3 -0
- nixflex-0.1.0/src/nixflex/resources/account.py +70 -0
- nixflex-0.1.0/src/nixflex/resources/agents.py +24 -0
- nixflex-0.1.0/src/nixflex/resources/callers.py +26 -0
- nixflex-0.1.0/src/nixflex/resources/calls.py +44 -0
- nixflex-0.1.0/src/nixflex/resources/phone_numbers.py +36 -0
- nixflex-0.1.0/src/nixflex/resources/sms.py +34 -0
- nixflex-0.1.0/src/nixflex/webhook.py +51 -0
- nixflex-0.1.0/tests/test_client.py +187 -0
- nixflex-0.1.0/tests/test_webhook.py +40 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
on: [push, pull_request]
|
|
3
|
+
jobs:
|
|
4
|
+
test:
|
|
5
|
+
runs-on: ubuntu-latest
|
|
6
|
+
strategy:
|
|
7
|
+
matrix:
|
|
8
|
+
python-version: ["3.9", "3.12", "3.13"]
|
|
9
|
+
steps:
|
|
10
|
+
- uses: actions/checkout@v4
|
|
11
|
+
- uses: actions/setup-python@v5
|
|
12
|
+
with: { python-version: "${{ matrix.python-version }}" }
|
|
13
|
+
- run: pip install -e ".[dev]"
|
|
14
|
+
- run: python -m pytest -q
|
nixflex-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
## 0.1.0
|
|
2
|
+
- First release. Sync (`Nixflex`) and async (`AsyncNixflex`) clients covering every endpoint - 46 methods mirroring the Node SDK. Retry policy identical to Node (429 with Retry-After, network once, 5xx only on GET/DELETE, POST never blind-retried). Eight typed error classes. `verify_webhook_signature`. Python 3.9+, `httpx` only.
|
nixflex-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nixflex Enterprises LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
nixflex-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: nixflex
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Nixflex voice AI platform - AI phone agents, outbound campaigns, SMS and caller context.
|
|
5
|
+
Project-URL: Homepage, https://nixflex.com
|
|
6
|
+
Project-URL: Documentation, https://docs.nixflex.com/sdks/python
|
|
7
|
+
Project-URL: Repository, https://github.com/nixflex/nixflex-python
|
|
8
|
+
Project-URL: Changelog, https://github.com/nixflex/nixflex-python/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Nixflex <stackadvisor.app@gmail.com>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai-receptionist,nixflex,phone,sdk,sms,telephony,telnyx,twilio,voice-agent,voice-ai
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Communications :: Telephony
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Requires-Dist: httpx<2,>=0.27
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# nixflex
|
|
32
|
+
|
|
33
|
+
Official Python SDK for the [Nixflex](https://nixflex.com) voice AI platform - AI phone agents, outbound campaigns, SMS and caller context.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install nixflex
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from nixflex import Nixflex
|
|
41
|
+
|
|
42
|
+
client = Nixflex(api_key="nxf_xxx:nxfs_xxx") # both halves, joined by a colon
|
|
43
|
+
|
|
44
|
+
call = client.calls.create(
|
|
45
|
+
agent_id="agent_15d1a9ee16294087",
|
|
46
|
+
to_number="+447700900123",
|
|
47
|
+
prompt="Call Sam to confirm his appointment on Tuesday at 11.",
|
|
48
|
+
)
|
|
49
|
+
print(call["call_id"])
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Async:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from nixflex import AsyncNixflex
|
|
56
|
+
|
|
57
|
+
async with AsyncNixflex(api_key="nxf_xxx:nxfs_xxx") as client:
|
|
58
|
+
agents = await client.agents.list()
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- Every endpoint: `agents`, `calls`, `campaigns`, `phone_numbers`, `callers` (caller context), `sms` (+ `sms.campaigns`), `keys`, `usage`, `webhooks`, `storage`, `llm`, `tts` - the same names as the Node SDK and the CLI.
|
|
62
|
+
- Retries: a 429 is retried once honouring `Retry-After`; network failures retry once; 5xx retries only `GET`/`DELETE`. A `POST` is never blind-retried - a call is never dialled twice.
|
|
63
|
+
- Typed errors: `NixflexAuthenticationError`, `NixflexRateLimitError` (`.retry_after_seconds`), `NixflexNotFoundError`, ...
|
|
64
|
+
- `verify_webhook_signature(raw_body, header, key_secret)` for signed webhooks.
|
|
65
|
+
- Python 3.9+, one dependency (`httpx`).
|
|
66
|
+
|
|
67
|
+
Docs: https://docs.nixflex.com/sdks/python
|
nixflex-0.1.0/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# nixflex
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the [Nixflex](https://nixflex.com) voice AI platform - AI phone agents, outbound campaigns, SMS and caller context.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install nixflex
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from nixflex import Nixflex
|
|
11
|
+
|
|
12
|
+
client = Nixflex(api_key="nxf_xxx:nxfs_xxx") # both halves, joined by a colon
|
|
13
|
+
|
|
14
|
+
call = client.calls.create(
|
|
15
|
+
agent_id="agent_15d1a9ee16294087",
|
|
16
|
+
to_number="+447700900123",
|
|
17
|
+
prompt="Call Sam to confirm his appointment on Tuesday at 11.",
|
|
18
|
+
)
|
|
19
|
+
print(call["call_id"])
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Async:
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from nixflex import AsyncNixflex
|
|
26
|
+
|
|
27
|
+
async with AsyncNixflex(api_key="nxf_xxx:nxfs_xxx") as client:
|
|
28
|
+
agents = await client.agents.list()
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- Every endpoint: `agents`, `calls`, `campaigns`, `phone_numbers`, `callers` (caller context), `sms` (+ `sms.campaigns`), `keys`, `usage`, `webhooks`, `storage`, `llm`, `tts` - the same names as the Node SDK and the CLI.
|
|
32
|
+
- Retries: a 429 is retried once honouring `Retry-After`; network failures retry once; 5xx retries only `GET`/`DELETE`. A `POST` is never blind-retried - a call is never dialled twice.
|
|
33
|
+
- Typed errors: `NixflexAuthenticationError`, `NixflexRateLimitError` (`.retry_after_seconds`), `NixflexNotFoundError`, ...
|
|
34
|
+
- `verify_webhook_signature(raw_body, header, key_secret)` for signed webhooks.
|
|
35
|
+
- Python 3.9+, one dependency (`httpx`).
|
|
36
|
+
|
|
37
|
+
Docs: https://docs.nixflex.com/sdks/python
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nixflex"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for the Nixflex voice AI platform - AI phone agents, outbound campaigns, SMS and caller context."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Nixflex", email = "stackadvisor.app@gmail.com" }]
|
|
13
|
+
keywords = ["nixflex", "voice-ai", "voice-agent", "phone", "telephony", "ai-receptionist", "twilio", "telnyx", "sms", "sdk"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Communications :: Telephony",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = ["httpx>=0.27,<2"]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://nixflex.com"
|
|
31
|
+
Documentation = "https://docs.nixflex.com/sdks/python"
|
|
32
|
+
Repository = "https://github.com/nixflex/nixflex-python"
|
|
33
|
+
Changelog = "https://github.com/nixflex/nixflex-python/blob/main/CHANGELOG.md"
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23"]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/nixflex"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
asyncio_mode = "auto"
|
|
43
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Official Nixflex SDK for Python.
|
|
2
|
+
|
|
3
|
+
from nixflex import Nixflex
|
|
4
|
+
client = Nixflex(api_key="nxf_xxx:nxfs_xxx")
|
|
5
|
+
call = client.calls.create(agent_id="agent_x", to_number="+447700900123", prompt="...")
|
|
6
|
+
|
|
7
|
+
Async:
|
|
8
|
+
from nixflex import AsyncNixflex
|
|
9
|
+
async with AsyncNixflex(api_key=...) as client:
|
|
10
|
+
agents = await client.agents.list()
|
|
11
|
+
|
|
12
|
+
Every method mirrors the Node SDK and the CLI - same names in snake_case. The docs
|
|
13
|
+
(docs.nixflex.com) are the source of truth; this SDK changes in the same session as the API.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from ._http import AsyncHttp, SyncHttp, __version__
|
|
22
|
+
from .errors import (
|
|
23
|
+
NixflexAuthenticationError,
|
|
24
|
+
NixflexConnectionError,
|
|
25
|
+
NixflexError,
|
|
26
|
+
NixflexInvalidRequestError,
|
|
27
|
+
NixflexNotFoundError,
|
|
28
|
+
NixflexPaymentRequiredError,
|
|
29
|
+
NixflexRateLimitError,
|
|
30
|
+
NixflexServerError,
|
|
31
|
+
)
|
|
32
|
+
from .resources.account import Keys, Llm, Storage, Tts, Usage, Webhooks
|
|
33
|
+
from .resources.agents import Agents
|
|
34
|
+
from .resources.callers import Callers
|
|
35
|
+
from .resources.calls import Calls, Campaigns
|
|
36
|
+
from .resources.phone_numbers import PhoneNumbers
|
|
37
|
+
from .resources.sms import Sms
|
|
38
|
+
from .webhook import verify_webhook_signature
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"Nixflex", "AsyncNixflex", "verify_webhook_signature", "__version__",
|
|
42
|
+
"NixflexError", "NixflexAuthenticationError", "NixflexPaymentRequiredError", "NixflexNotFoundError",
|
|
43
|
+
"NixflexRateLimitError", "NixflexInvalidRequestError", "NixflexServerError", "NixflexConnectionError",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _Resources:
|
|
48
|
+
def _attach(self, http) -> None:
|
|
49
|
+
self.agents = Agents(http)
|
|
50
|
+
self.calls = Calls(http)
|
|
51
|
+
self.campaigns = Campaigns(http)
|
|
52
|
+
self.phone_numbers = PhoneNumbers(http)
|
|
53
|
+
self.callers = Callers(http)
|
|
54
|
+
self.sms = Sms(http)
|
|
55
|
+
self.keys = Keys(http)
|
|
56
|
+
self.usage = Usage(http)
|
|
57
|
+
self.webhooks = Webhooks(http)
|
|
58
|
+
self.storage = Storage(http)
|
|
59
|
+
self.llm = Llm(http)
|
|
60
|
+
self.tts = Tts(http)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Nixflex(_Resources):
|
|
64
|
+
"""Synchronous client. Use as a context manager or call close() when done."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, api_key: str, base_url: Optional[str] = None, timeout: Optional[float] = None, max_retries: Optional[int] = None, transport: Optional[httpx.BaseTransport] = None):
|
|
67
|
+
self._http = SyncHttp(api_key, base_url, timeout, max_retries, transport)
|
|
68
|
+
self._attach(self._http)
|
|
69
|
+
|
|
70
|
+
def close(self) -> None:
|
|
71
|
+
self._http.close()
|
|
72
|
+
|
|
73
|
+
def __enter__(self) -> "Nixflex":
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
def __exit__(self, *exc) -> None:
|
|
77
|
+
self.close()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class AsyncNixflex(_Resources):
|
|
81
|
+
"""Asynchronous client - every method returns an awaitable."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, api_key: str, base_url: Optional[str] = None, timeout: Optional[float] = None, max_retries: Optional[int] = None, transport: Optional[httpx.AsyncBaseTransport] = None):
|
|
84
|
+
self._http = AsyncHttp(api_key, base_url, timeout, max_retries, transport)
|
|
85
|
+
self._attach(self._http)
|
|
86
|
+
|
|
87
|
+
async def close(self) -> None:
|
|
88
|
+
await self._http.close()
|
|
89
|
+
|
|
90
|
+
async def __aenter__(self) -> "AsyncNixflex":
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
async def __aexit__(self, *exc) -> None:
|
|
94
|
+
await self.close()
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""The core HTTP layer every resource uses - sync and async, one retry policy.
|
|
2
|
+
|
|
3
|
+
Behaviours (identical to the Node SDK, both verified against the live API):
|
|
4
|
+
- Bearer auth with the full key "key_id:key_secret".
|
|
5
|
+
- Per-request timeout (default 30 s).
|
|
6
|
+
- RETRY: a 429 honours the API's Retry-After header (capped 30 s). 5xx and network
|
|
7
|
+
failures retry once with a small backoff. GET/DELETE always retry; POST/PUT/PATCH
|
|
8
|
+
retry ONLY on 429 or a network failure before any response - never after a 5xx,
|
|
9
|
+
which may already have acted (no double calls, no double sends).
|
|
10
|
+
- Every non-2xx becomes a typed error (errors.py); the request id is surfaced.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any, Dict, Mapping, Optional
|
|
18
|
+
from urllib.parse import quote
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from .errors import NixflexConnectionError, error_from_response
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
DEFAULT_BASE_URL = "https://api.nixflex.com"
|
|
26
|
+
DEFAULT_TIMEOUT = 30.0
|
|
27
|
+
_IDEMPOTENT = {"GET", "DELETE"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def enc(number: str) -> str:
|
|
31
|
+
"""E.164 numbers go in URL paths - the + MUST be encoded or routing breaks."""
|
|
32
|
+
return quote(number, safe="")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _check_key(api_key: Any) -> str:
|
|
36
|
+
if not isinstance(api_key, str) or ":" not in api_key:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
'Nixflex: api_key is required in the form "key_id:key_secret" (both parts, joined by a colon). '
|
|
39
|
+
"Find yours at https://dashboard.nixflex.com under API Keys."
|
|
40
|
+
)
|
|
41
|
+
return api_key
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _clean_query(query: Optional[Mapping[str, Any]]) -> Dict[str, str]:
|
|
45
|
+
if not query:
|
|
46
|
+
return {}
|
|
47
|
+
return {k: str(v) for k, v in query.items() if v is not None}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _retry_plan(status: int, method: str, retry_after: int, attempt: int, max_retries: int) -> Optional[float]:
|
|
51
|
+
"""Seconds to sleep before retrying, or None = do not retry."""
|
|
52
|
+
if attempt > max_retries:
|
|
53
|
+
return None
|
|
54
|
+
if status == 429:
|
|
55
|
+
return float(min(retry_after, 30)) or 1.0
|
|
56
|
+
if status >= 500 and method in _IDEMPOTENT:
|
|
57
|
+
return 0.5 * attempt
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parse_error(res: httpx.Response):
|
|
62
|
+
request_id = res.headers.get("x-railway-request-id")
|
|
63
|
+
try:
|
|
64
|
+
retry_after = int(res.headers.get("retry-after") or "0")
|
|
65
|
+
except ValueError:
|
|
66
|
+
retry_after = 0
|
|
67
|
+
try:
|
|
68
|
+
body = res.json()
|
|
69
|
+
except Exception:
|
|
70
|
+
body = None
|
|
71
|
+
return request_id, retry_after, body
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _Base:
|
|
75
|
+
def __init__(self, api_key: str, base_url: Optional[str], timeout: Optional[float], max_retries: Optional[int]):
|
|
76
|
+
self._api_key = _check_key(api_key)
|
|
77
|
+
self._base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
78
|
+
self._timeout = DEFAULT_TIMEOUT if timeout is None else timeout
|
|
79
|
+
self._max_retries = 1 if max_retries is None else max_retries
|
|
80
|
+
self._headers = {
|
|
81
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
82
|
+
"Content-Type": "application/json",
|
|
83
|
+
"User-Agent": f"nixflex-python/{__version__}",
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
def _url(self, path: str) -> str:
|
|
87
|
+
return self._base_url + "/v1" + path
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class SyncHttp(_Base):
|
|
91
|
+
def __init__(self, api_key: str, base_url: Optional[str] = None, timeout: Optional[float] = None, max_retries: Optional[int] = None, transport: Optional[httpx.BaseTransport] = None):
|
|
92
|
+
super().__init__(api_key, base_url, timeout, max_retries)
|
|
93
|
+
self._client = httpx.Client(timeout=self._timeout, transport=transport)
|
|
94
|
+
|
|
95
|
+
def close(self) -> None:
|
|
96
|
+
self._client.close()
|
|
97
|
+
|
|
98
|
+
def request(self, method: str, path: str, body: Any = None, query: Optional[Mapping[str, Any]] = None, timeout: Optional[float] = None) -> Any:
|
|
99
|
+
attempt = 0
|
|
100
|
+
content = None if body is None else json.dumps(body).encode("utf-8")
|
|
101
|
+
while True:
|
|
102
|
+
attempt += 1
|
|
103
|
+
try:
|
|
104
|
+
res = self._client.request(method, self._url(path), params=_clean_query(query), content=content, headers=self._headers, timeout=self._timeout if timeout is None else timeout)
|
|
105
|
+
except (httpx.TransportError, httpx.TimeoutException) as err:
|
|
106
|
+
if attempt <= self._max_retries:
|
|
107
|
+
time.sleep(0.3 * attempt)
|
|
108
|
+
continue
|
|
109
|
+
raise NixflexConnectionError(f"Could not reach the Nixflex API ({err.__class__.__name__}: {err}). Check connectivity and https://nixflex.com/status") from None
|
|
110
|
+
if res.is_success:
|
|
111
|
+
return None if res.status_code == 204 else res.json()
|
|
112
|
+
request_id, retry_after, err_body = _parse_error(res)
|
|
113
|
+
wait = _retry_plan(res.status_code, method, retry_after, attempt, self._max_retries)
|
|
114
|
+
if wait is not None:
|
|
115
|
+
time.sleep(wait)
|
|
116
|
+
continue
|
|
117
|
+
raise error_from_response(res.status_code, err_body, request_id, retry_after)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class AsyncHttp(_Base):
|
|
121
|
+
def __init__(self, api_key: str, base_url: Optional[str] = None, timeout: Optional[float] = None, max_retries: Optional[int] = None, transport: Optional[httpx.AsyncBaseTransport] = None):
|
|
122
|
+
super().__init__(api_key, base_url, timeout, max_retries)
|
|
123
|
+
self._client = httpx.AsyncClient(timeout=self._timeout, transport=transport)
|
|
124
|
+
|
|
125
|
+
async def close(self) -> None:
|
|
126
|
+
await self._client.aclose()
|
|
127
|
+
|
|
128
|
+
async def request(self, method: str, path: str, body: Any = None, query: Optional[Mapping[str, Any]] = None, timeout: Optional[float] = None) -> Any:
|
|
129
|
+
attempt = 0
|
|
130
|
+
content = None if body is None else json.dumps(body).encode("utf-8")
|
|
131
|
+
while True:
|
|
132
|
+
attempt += 1
|
|
133
|
+
try:
|
|
134
|
+
res = await self._client.request(method, self._url(path), params=_clean_query(query), content=content, headers=self._headers, timeout=self._timeout if timeout is None else timeout)
|
|
135
|
+
except (httpx.TransportError, httpx.TimeoutException) as err:
|
|
136
|
+
if attempt <= self._max_retries:
|
|
137
|
+
await asyncio.sleep(0.3 * attempt)
|
|
138
|
+
continue
|
|
139
|
+
raise NixflexConnectionError(f"Could not reach the Nixflex API ({err.__class__.__name__}: {err}). Check connectivity and https://nixflex.com/status") from None
|
|
140
|
+
if res.is_success:
|
|
141
|
+
return None if res.status_code == 204 else res.json()
|
|
142
|
+
request_id, retry_after, err_body = _parse_error(res)
|
|
143
|
+
wait = _retry_plan(res.status_code, method, retry_after, attempt, self._max_retries)
|
|
144
|
+
if wait is not None:
|
|
145
|
+
await asyncio.sleep(wait)
|
|
146
|
+
continue
|
|
147
|
+
raise error_from_response(res.status_code, err_body, request_id, retry_after)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Typed error family. Mirrors the API's error shape exactly:
|
|
2
|
+
{ "error": { "type", "code", "message", "doc_url", "details" } }
|
|
3
|
+
Every non-2xx response becomes one of these - catch by class:
|
|
4
|
+
try: ...
|
|
5
|
+
except NixflexRateLimitError as e: sleep(e.retry_after_seconds)
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NixflexError(Exception):
|
|
13
|
+
"""Base class. `status` is the HTTP status (0 for network/timeout failures)."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, status: int, body: Optional[Dict[str, Any]], request_id: Optional[str] = None, fallback_message: Optional[str] = None):
|
|
16
|
+
e = (body or {}).get("error") or {}
|
|
17
|
+
super().__init__(e.get("message") or fallback_message or f"Nixflex API error (HTTP {status})")
|
|
18
|
+
self.status: int = status
|
|
19
|
+
self.code: str = e.get("code") or "unknown_error"
|
|
20
|
+
self.type: str = e.get("type") or "error"
|
|
21
|
+
self.doc_url: Optional[str] = e.get("doc_url")
|
|
22
|
+
self.details: Dict[str, Any] = e.get("details") or {}
|
|
23
|
+
self.request_id: Optional[str] = request_id
|
|
24
|
+
|
|
25
|
+
def __repr__(self) -> str: # pragma: no cover
|
|
26
|
+
return f"{self.__class__.__name__}(status={self.status}, code={self.code!r}, message={str(self)!r})"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NixflexAuthenticationError(NixflexError):
|
|
30
|
+
"""401 - missing or invalid API key."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NixflexPaymentRequiredError(NixflexError):
|
|
34
|
+
"""402 - balance or credit exhausted."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NixflexNotFoundError(NixflexError):
|
|
38
|
+
"""404 - the resource does not exist (or is not yours)."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class NixflexInvalidRequestError(NixflexError):
|
|
42
|
+
"""400/422 - the request itself is malformed or invalid."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NixflexServerError(NixflexError):
|
|
46
|
+
"""5xx - something failed on Nixflex's side. GET/DELETE are retried once automatically."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class NixflexRateLimitError(NixflexError):
|
|
50
|
+
"""429 - rate limit hit. `retry_after_seconds` says when to try again."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, status: int, body: Optional[Dict[str, Any]], request_id: Optional[str], retry_after_seconds: int):
|
|
53
|
+
super().__init__(status, body, request_id)
|
|
54
|
+
self.retry_after_seconds: int = retry_after_seconds
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class NixflexConnectionError(NixflexError):
|
|
58
|
+
"""Network failure / timeout - the request never got an HTTP response."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, message: str):
|
|
61
|
+
super().__init__(0, None, None, message)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def error_from_response(status: int, body: Optional[Dict[str, Any]], request_id: Optional[str], retry_after_seconds: int) -> NixflexError:
|
|
65
|
+
"""Map a status + body to the right error class (same table as the Node SDK)."""
|
|
66
|
+
if status == 401:
|
|
67
|
+
return NixflexAuthenticationError(status, body, request_id)
|
|
68
|
+
if status == 402:
|
|
69
|
+
return NixflexPaymentRequiredError(status, body, request_id)
|
|
70
|
+
if status == 404:
|
|
71
|
+
return NixflexNotFoundError(status, body, request_id)
|
|
72
|
+
if status == 429:
|
|
73
|
+
return NixflexRateLimitError(status, body, request_id, retry_after_seconds)
|
|
74
|
+
if status in (400, 422):
|
|
75
|
+
return NixflexInvalidRequestError(status, body, request_id)
|
|
76
|
+
if status >= 500:
|
|
77
|
+
return NixflexServerError(status, body, request_id)
|
|
78
|
+
return NixflexError(status, body, request_id)
|
|
File without changes
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any
|
|
3
|
+
from .._http import enc
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Keys:
|
|
7
|
+
def __init__(self, http):
|
|
8
|
+
self._http = http
|
|
9
|
+
|
|
10
|
+
def rotate(self):
|
|
11
|
+
"""New secret. The current one stops working immediately - update every integration."""
|
|
12
|
+
return self._http.request("POST", "/keys/rotate")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Usage:
|
|
16
|
+
def __init__(self, http):
|
|
17
|
+
self._http = http
|
|
18
|
+
|
|
19
|
+
def get(self):
|
|
20
|
+
return self._http.request("GET", "/usage")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _ByoBase:
|
|
24
|
+
"""Bring your own storage / LLM / TTS. set() is verified by the API with a real probe before saving; get() never returns secrets."""
|
|
25
|
+
_path = ""
|
|
26
|
+
|
|
27
|
+
def __init__(self, http):
|
|
28
|
+
self._http = http
|
|
29
|
+
|
|
30
|
+
def set(self, **params: Any):
|
|
31
|
+
return self._http.request("PUT", self._path, params)
|
|
32
|
+
|
|
33
|
+
def get(self):
|
|
34
|
+
return self._http.request("GET", self._path)
|
|
35
|
+
|
|
36
|
+
def delete(self):
|
|
37
|
+
"""Disconnect - calls fall back to Nixflex."""
|
|
38
|
+
return self._http.request("DELETE", self._path)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Storage(_ByoBase):
|
|
42
|
+
_path = "/account/storage"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Llm(_ByoBase):
|
|
46
|
+
_path = "/account/llm"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Tts(_ByoBase):
|
|
50
|
+
_path = "/account/tts"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Webhooks:
|
|
54
|
+
"""Per-number post-call webhook (two slots). Verify deliveries with nixflex.verify_webhook_signature."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, http):
|
|
57
|
+
self._http = http
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def _base(slot: int) -> str:
|
|
61
|
+
return "webhook2" if slot == 2 else "webhook"
|
|
62
|
+
|
|
63
|
+
def set(self, phone_number: str, url: str, slot: int = 1):
|
|
64
|
+
return self._http.request("PUT", f"/integrations/{self._base(slot)}/number/{enc(phone_number)}", {"url": url})
|
|
65
|
+
|
|
66
|
+
def get(self, phone_number: str, slot: int = 1):
|
|
67
|
+
return self._http.request("GET", f"/integrations/{self._base(slot)}/number/{enc(phone_number)}")
|
|
68
|
+
|
|
69
|
+
def delete(self, phone_number: str, slot: int = 1):
|
|
70
|
+
return self._http.request("DELETE", f"/integrations/{self._base(slot)}/number/{enc(phone_number)}")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, Dict, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Agents:
|
|
6
|
+
def __init__(self, http):
|
|
7
|
+
self._http = http
|
|
8
|
+
|
|
9
|
+
def create(self, **params: Any):
|
|
10
|
+
"""Create an agent. Any field the API accepts (name, system_prompt, voice_id, language, incall_sms_enabled, ...)."""
|
|
11
|
+
return self._http.request("POST", "/agents", params)
|
|
12
|
+
|
|
13
|
+
def list(self, limit: Optional[int] = None, offset: Optional[int] = None):
|
|
14
|
+
return self._http.request("GET", "/agents", None, {"limit": limit, "offset": offset})
|
|
15
|
+
|
|
16
|
+
def get(self, agent_id: str):
|
|
17
|
+
return self._http.request("GET", f"/agents/{agent_id}")
|
|
18
|
+
|
|
19
|
+
def update(self, agent_id: str, **params: Any):
|
|
20
|
+
"""Only the fields you pass change."""
|
|
21
|
+
return self._http.request("PUT", f"/agents/{agent_id}", params)
|
|
22
|
+
|
|
23
|
+
def delete(self, agent_id: str):
|
|
24
|
+
return self._http.request("DELETE", f"/agents/{agent_id}")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, Dict, List
|
|
3
|
+
from .._http import enc
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Callers:
|
|
7
|
+
"""Caller context - what the agent knows about a caller on one of your numbers.
|
|
8
|
+
phone_number is YOUR number; caller_number is the customer. Docs: /concepts/caller-context"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, http):
|
|
11
|
+
self._http = http
|
|
12
|
+
|
|
13
|
+
def get(self, phone_number: str, caller_number: str):
|
|
14
|
+
return self._http.request("GET", f"/phone-numbers/{enc(phone_number)}/callers/{enc(caller_number)}")
|
|
15
|
+
|
|
16
|
+
def set(self, phone_number: str, caller_number: str, **fields: Any):
|
|
17
|
+
"""name, email, phone, location, reference_id, preference. Omitted fields are kept; pass None to remove one. last_call / open_item are engine-only (400 engine_only_field)."""
|
|
18
|
+
return self._http.request("PUT", f"/phone-numbers/{enc(phone_number)}/callers/{enc(caller_number)}", fields)
|
|
19
|
+
|
|
20
|
+
def delete(self, phone_number: str, caller_number: str):
|
|
21
|
+
"""Erases the whole record - yours and the engine's."""
|
|
22
|
+
return self._http.request("DELETE", f"/phone-numbers/{enc(phone_number)}/callers/{enc(caller_number)}")
|
|
23
|
+
|
|
24
|
+
def import_(self, phone_number: str, callers: List[Dict[str, Any]]):
|
|
25
|
+
"""Up to 1,000 rows, each {"caller_number": "+44...", ...fields}. Every row is validated before any is written."""
|
|
26
|
+
return self._http.request("POST", f"/phone-numbers/{enc(phone_number)}/callers/import", {"callers": callers})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, Dict, List, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Calls:
|
|
6
|
+
def __init__(self, http):
|
|
7
|
+
self._http = http
|
|
8
|
+
|
|
9
|
+
def create(self, agent_id: str, to_number: str, prompt: str, from_number: Optional[str] = None, variables: Optional[Dict[str, str]] = None, **extra: Any):
|
|
10
|
+
"""Place an outbound call now. Never retried after a 5xx - a call is never dialled twice."""
|
|
11
|
+
body: Dict[str, Any] = {"agent_id": agent_id, "to_number": to_number, "prompt": prompt, **extra}
|
|
12
|
+
if from_number is not None:
|
|
13
|
+
body["from_number"] = from_number
|
|
14
|
+
if variables is not None:
|
|
15
|
+
body["variables"] = variables
|
|
16
|
+
return self._http.request("POST", "/calls/outbound", body)
|
|
17
|
+
|
|
18
|
+
def list(self, limit: Optional[int] = None, offset: Optional[int] = None, agent_id: Optional[str] = None):
|
|
19
|
+
return self._http.request("GET", "/calls", None, {"limit": limit, "offset": offset, "agent_id": agent_id})
|
|
20
|
+
|
|
21
|
+
def get(self, call_id: str):
|
|
22
|
+
return self._http.request("GET", f"/calls/{call_id}")
|
|
23
|
+
|
|
24
|
+
def delete(self, call_id: str):
|
|
25
|
+
"""Removes the record and its recording (GDPR)."""
|
|
26
|
+
return self._http.request("DELETE", f"/calls/{call_id}")
|
|
27
|
+
|
|
28
|
+
def delete_all(self):
|
|
29
|
+
"""Every call, recording and SMS record on the account. Cannot be undone."""
|
|
30
|
+
return self._http.request("DELETE", "/calls")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Campaigns:
|
|
34
|
+
"""Voice batch campaigns - many calls under one prompt."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, http):
|
|
37
|
+
self._http = http
|
|
38
|
+
|
|
39
|
+
def create(self, agent_id: str, from_number: str, prompt: str, recipients: List[Dict[str, Any]], **extra: Any):
|
|
40
|
+
"""recipients: [{"phone": "+44...", "variables": {...}, "prompt_override": "..."}, ...]. Dials immediately unless schedule_type="schedule"."""
|
|
41
|
+
return self._http.request("POST", "/calls/batch", {"agent_id": agent_id, "from_number": from_number, "prompt": prompt, "recipients": recipients, **extra})
|
|
42
|
+
|
|
43
|
+
def launch(self, campaign_id: str):
|
|
44
|
+
return self._http.request("POST", f"/calls/batch/{campaign_id}/launch")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
from .._http import enc
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PhoneNumbers:
|
|
7
|
+
def __init__(self, http):
|
|
8
|
+
self._http = http
|
|
9
|
+
|
|
10
|
+
def import_(self, phone_number: str, agent_id: str, **credentials: Any):
|
|
11
|
+
"""Attach a number you own. Twilio: twilio_account_sid + twilio_auth_token. Telnyx: telnyx_api_key + telnyx_connection_id. The carrier is inferred from which you send."""
|
|
12
|
+
return self._http.request("POST", "/phone-numbers", {"phone_number": phone_number, "agent_id": agent_id, **credentials})
|
|
13
|
+
|
|
14
|
+
def list(self, agent_id: Optional[str] = None):
|
|
15
|
+
return self._http.request("GET", "/phone-numbers", None, {"agent_id": agent_id})
|
|
16
|
+
|
|
17
|
+
def update(self, phone_number: str, **params: Any):
|
|
18
|
+
"""Per-number settings: custom_prompt, sms_prompt, sms_reply_enabled, web_prompt, dtmf_enabled, record_call, voice_id, speaking_rate, ... Only passed fields change; None clears where the API allows null."""
|
|
19
|
+
return self._http.request("PATCH", f"/phone-numbers/{enc(phone_number)}", params)
|
|
20
|
+
|
|
21
|
+
def delete(self, phone_number: str):
|
|
22
|
+
"""Disconnects from Nixflex. Your carrier keeps the number and keeps billing it."""
|
|
23
|
+
return self._http.request("DELETE", f"/phone-numbers/{enc(phone_number)}")
|
|
24
|
+
|
|
25
|
+
def set_monitor(self, phone_number: str, enabled: bool):
|
|
26
|
+
"""Live call monitoring. On bills that number's inbound calls at the premium rate."""
|
|
27
|
+
return self._http.request("PUT", f"/integrations/monitor/number/{enc(phone_number)}", {"enabled": enabled})
|
|
28
|
+
|
|
29
|
+
def get_monitor(self, phone_number: str):
|
|
30
|
+
return self._http.request("GET", f"/integrations/monitor/number/{enc(phone_number)}")
|
|
31
|
+
|
|
32
|
+
def set_web_calls(self, phone_number: str, enabled: bool):
|
|
33
|
+
return self._http.request("PUT", f"/integrations/web-calls/number/{enc(phone_number)}", {"enabled": enabled})
|
|
34
|
+
|
|
35
|
+
def get_web_calls(self, phone_number: str):
|
|
36
|
+
return self._http.request("GET", f"/integrations/web-calls/number/{enc(phone_number)}")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, Dict, List, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SmsCampaigns:
|
|
6
|
+
def __init__(self, http):
|
|
7
|
+
self._http = http
|
|
8
|
+
|
|
9
|
+
def create(self, agent_id: str, from_number: str, message: str, recipients: List[Dict[str, Any]], **extra: Any):
|
|
10
|
+
"""recipients: [{"phone": "+44...", "variables": {...}}, ...]. from_number may be on either carrier."""
|
|
11
|
+
return self._http.request("POST", "/sms/campaigns", {"agent_id": agent_id, "from_number": from_number, "message": message, "recipients": recipients, **extra})
|
|
12
|
+
|
|
13
|
+
def launch(self, campaign_id: str):
|
|
14
|
+
return self._http.request("POST", f"/sms/campaigns/{campaign_id}/launch")
|
|
15
|
+
|
|
16
|
+
def list(self, status: Optional[str] = None, limit: Optional[int] = None):
|
|
17
|
+
return self._http.request("GET", "/sms/campaigns", None, {"status": status, "limit": limit})
|
|
18
|
+
|
|
19
|
+
def get(self, campaign_id: str):
|
|
20
|
+
return self._http.request("GET", f"/sms/campaigns/{campaign_id}")
|
|
21
|
+
|
|
22
|
+
def delete(self, campaign_id: str):
|
|
23
|
+
"""Pending recipients are not messaged. Sent messages cannot be recalled."""
|
|
24
|
+
return self._http.request("DELETE", f"/sms/campaigns/{campaign_id}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Sms:
|
|
28
|
+
def __init__(self, http):
|
|
29
|
+
self._http = http
|
|
30
|
+
self.campaigns = SmsCampaigns(http)
|
|
31
|
+
|
|
32
|
+
def send(self, agent_id: str, from_number: str, to: str, message: str, **extra: Any):
|
|
33
|
+
"""One text from one of your numbers. Under 600 characters delivers reliably."""
|
|
34
|
+
return self._http.request("POST", "/sms", {"agent_id": agent_id, "from_number": from_number, "to": to, "message": message, **extra})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Verify Nixflex webhook signatures.
|
|
2
|
+
|
|
3
|
+
The engine signs every delivery:
|
|
4
|
+
header X-Nixflex-Signature: t=<unix_ts>,v1=<hmac_sha256_hex>
|
|
5
|
+
payload "<timestamp>.<raw_body>" secret: your key_secret
|
|
6
|
+
|
|
7
|
+
Use the RAW request body BYTES exactly as received - a re-serialised JSON body may
|
|
8
|
+
differ from what was signed and will fail verification. Never raises.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import hmac
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
from typing import Optional, Union
|
|
17
|
+
|
|
18
|
+
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def verify_webhook_signature(
|
|
22
|
+
raw_body: Union[bytes, str],
|
|
23
|
+
signature_header: Optional[str],
|
|
24
|
+
key_secret: str,
|
|
25
|
+
tolerance_seconds: int = 300,
|
|
26
|
+
now: Optional[int] = None,
|
|
27
|
+
) -> bool:
|
|
28
|
+
"""True only if the signature is authentic AND fresh (within `tolerance_seconds`)."""
|
|
29
|
+
try:
|
|
30
|
+
if not signature_header or not key_secret:
|
|
31
|
+
return False
|
|
32
|
+
parts = {}
|
|
33
|
+
for seg in signature_header.split(","):
|
|
34
|
+
i = seg.find("=")
|
|
35
|
+
if i > 0:
|
|
36
|
+
parts[seg[:i].strip()] = seg[i + 1:].strip()
|
|
37
|
+
try:
|
|
38
|
+
t = int(parts.get("t", ""))
|
|
39
|
+
except ValueError:
|
|
40
|
+
return False
|
|
41
|
+
v1 = parts.get("v1", "")
|
|
42
|
+
if not _HEX64.match(v1):
|
|
43
|
+
return False
|
|
44
|
+
current = int(time.time()) if now is None else now
|
|
45
|
+
if abs(current - t) > tolerance_seconds:
|
|
46
|
+
return False # stale or future-dated = replay risk
|
|
47
|
+
body = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
|
|
48
|
+
expected = hmac.new(key_secret.encode("utf-8"), f"{t}.".encode("utf-8") + body, hashlib.sha256).hexdigest()
|
|
49
|
+
return hmac.compare_digest(expected, v1) # constant-time
|
|
50
|
+
except Exception:
|
|
51
|
+
return False
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Behaviour tests against a mock transport - no network. Same cases as the Node SDK."""
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from nixflex import (
|
|
9
|
+
AsyncNixflex,
|
|
10
|
+
Nixflex,
|
|
11
|
+
NixflexAuthenticationError,
|
|
12
|
+
NixflexConnectionError,
|
|
13
|
+
NixflexInvalidRequestError,
|
|
14
|
+
NixflexNotFoundError,
|
|
15
|
+
NixflexPaymentRequiredError,
|
|
16
|
+
NixflexRateLimitError,
|
|
17
|
+
NixflexServerError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
KEY = "nxf_test:nxfs_test"
|
|
21
|
+
ERR = lambda code, msg="boom", typ="invalid_request": {"error": {"type": typ, "code": code, "message": msg, "doc_url": "https://docs.nixflex.com/errors/" + code, "details": {}}}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def client_with(handler, **kw):
|
|
25
|
+
return Nixflex(api_key=KEY, transport=httpx.MockTransport(handler), **kw)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_requires_full_key_pair():
|
|
29
|
+
with pytest.raises(ValueError):
|
|
30
|
+
Nixflex(api_key="nxf_only_the_id")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_auth_header_user_agent_and_path():
|
|
34
|
+
seen = {}
|
|
35
|
+
|
|
36
|
+
def handler(req: httpx.Request):
|
|
37
|
+
seen["auth"] = req.headers["authorization"]
|
|
38
|
+
seen["ua"] = req.headers["user-agent"]
|
|
39
|
+
seen["url"] = str(req.url)
|
|
40
|
+
return httpx.Response(200, json=[{"agent_id": "a1"}])
|
|
41
|
+
|
|
42
|
+
with client_with(handler) as c:
|
|
43
|
+
assert c.agents.list(limit=5) == [{"agent_id": "a1"}]
|
|
44
|
+
assert seen["auth"] == "Bearer " + KEY
|
|
45
|
+
assert seen["ua"].startswith("nixflex-python/")
|
|
46
|
+
assert seen["url"] == "https://api.nixflex.com/v1/agents?limit=5"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_phone_numbers_are_url_encoded():
|
|
50
|
+
seen = {}
|
|
51
|
+
|
|
52
|
+
def handler(req):
|
|
53
|
+
seen["path"] = req.url.raw_path.decode()
|
|
54
|
+
return httpx.Response(200, json={"caller": {}})
|
|
55
|
+
|
|
56
|
+
with client_with(handler) as c:
|
|
57
|
+
c.callers.get("+447450307843", "+447453573770")
|
|
58
|
+
assert seen["path"] == "/v1/phone-numbers/%2B447450307843/callers/%2B447453573770"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_error_classes_by_status():
|
|
62
|
+
cases = [(401, NixflexAuthenticationError), (402, NixflexPaymentRequiredError), (404, NixflexNotFoundError), (400, NixflexInvalidRequestError), (422, NixflexInvalidRequestError), (503, NixflexServerError)]
|
|
63
|
+
for status, cls in cases:
|
|
64
|
+
with client_with(lambda req, s=status: httpx.Response(s, json=ERR("x_code", "why"), headers={"x-railway-request-id": "req-1"}), max_retries=0) as c:
|
|
65
|
+
with pytest.raises(cls) as ei:
|
|
66
|
+
c.agents.get("a1")
|
|
67
|
+
e = ei.value
|
|
68
|
+
assert e.status == status and e.code == "x_code" and str(e) == "why" and e.request_id == "req-1"
|
|
69
|
+
assert e.doc_url.endswith("/x_code")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_429_retries_once_honouring_retry_after():
|
|
73
|
+
calls = []
|
|
74
|
+
|
|
75
|
+
def handler(req):
|
|
76
|
+
calls.append(time.monotonic())
|
|
77
|
+
if len(calls) == 1:
|
|
78
|
+
return httpx.Response(429, json=ERR("rate_limit_exceeded", typ="rate_limit"), headers={"retry-after": "1"})
|
|
79
|
+
return httpx.Response(200, json={"ok": True})
|
|
80
|
+
|
|
81
|
+
with client_with(handler) as c:
|
|
82
|
+
assert c.agents.get("a1") == {"ok": True}
|
|
83
|
+
assert len(calls) == 2 and (calls[1] - calls[0]) >= 0.9
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_429_exhausted_raises_with_retry_after_seconds():
|
|
87
|
+
with client_with(lambda req: httpx.Response(429, json=ERR("rate_limit_exceeded", typ="rate_limit"), headers={"retry-after": "7"}), max_retries=0) as c:
|
|
88
|
+
with pytest.raises(NixflexRateLimitError) as ei:
|
|
89
|
+
c.usage.get()
|
|
90
|
+
assert ei.value.retry_after_seconds == 7
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_5xx_retries_GET_but_never_POST():
|
|
94
|
+
n = {"get": 0, "post": 0}
|
|
95
|
+
|
|
96
|
+
def handler(req):
|
|
97
|
+
n[req.method.lower()] += 1
|
|
98
|
+
return httpx.Response(502, json=ERR("upstream", typ="server_error"))
|
|
99
|
+
|
|
100
|
+
with client_with(handler) as c:
|
|
101
|
+
with pytest.raises(NixflexServerError):
|
|
102
|
+
c.agents.get("a1")
|
|
103
|
+
with pytest.raises(NixflexServerError):
|
|
104
|
+
c.calls.create(agent_id="a1", to_number="+447700900123", prompt="hi")
|
|
105
|
+
assert n["get"] == 2, "GET must retry once on 5xx"
|
|
106
|
+
assert n["post"] == 1, "POST must NEVER be retried after a 5xx - a call could be dialled twice"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_network_failure_retries_then_connection_error():
|
|
110
|
+
n = {"c": 0}
|
|
111
|
+
|
|
112
|
+
def handler(req):
|
|
113
|
+
n["c"] += 1
|
|
114
|
+
raise httpx.ConnectError("refused")
|
|
115
|
+
|
|
116
|
+
with client_with(handler) as c:
|
|
117
|
+
with pytest.raises(NixflexConnectionError) as ei:
|
|
118
|
+
c.usage.get()
|
|
119
|
+
assert n["c"] == 2 and ei.value.status == 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_204_returns_none_and_none_query_values_are_dropped():
|
|
123
|
+
seen = {}
|
|
124
|
+
|
|
125
|
+
def handler(req):
|
|
126
|
+
seen["q"] = req.url.query.decode()
|
|
127
|
+
return httpx.Response(204)
|
|
128
|
+
|
|
129
|
+
with client_with(handler) as c:
|
|
130
|
+
assert c.agents.list() is None
|
|
131
|
+
assert seen["q"] == ""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_import_and_set_bodies():
|
|
135
|
+
seen = {}
|
|
136
|
+
|
|
137
|
+
def handler(req):
|
|
138
|
+
seen[req.url.raw_path.decode()] = json.loads(req.content)
|
|
139
|
+
return httpx.Response(200, json={})
|
|
140
|
+
|
|
141
|
+
with client_with(handler) as c:
|
|
142
|
+
c.callers.set("+447450307843", "+447453573770", name="Sam Carter", email=None)
|
|
143
|
+
c.callers.import_("+447450307843", [{"caller_number": "+447700900002", "name": "Priya"}])
|
|
144
|
+
c.webhooks.set("+447450307843", "https://x.example/hook", slot=2)
|
|
145
|
+
assert seen["/v1/phone-numbers/%2B447450307843/callers/%2B447453573770"] == {"name": "Sam Carter", "email": None}
|
|
146
|
+
assert seen["/v1/phone-numbers/%2B447450307843/callers/import"] == {"callers": [{"caller_number": "+447700900002", "name": "Priya"}]}
|
|
147
|
+
assert "/v1/integrations/webhook2/number/%2B447450307843" in seen
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def test_async_client_same_behaviour():
|
|
151
|
+
n = {"c": 0}
|
|
152
|
+
|
|
153
|
+
def handler(req):
|
|
154
|
+
n["c"] += 1
|
|
155
|
+
if n["c"] == 1:
|
|
156
|
+
return httpx.Response(429, json=ERR("rate_limit_exceeded", typ="rate_limit"), headers={"retry-after": "0"})
|
|
157
|
+
return httpx.Response(200, json={"plan": "pay_as_you_go"})
|
|
158
|
+
|
|
159
|
+
async with AsyncNixflex(api_key=KEY, transport=httpx.MockTransport(handler)) as c:
|
|
160
|
+
assert await c.usage.get() == {"plan": "pay_as_you_go"}
|
|
161
|
+
assert n["c"] == 2
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_every_resource_and_method_exists():
|
|
165
|
+
"""The Python surface must equal the Node SDK's API methods (snake_case; import -> import_). 44 API methods + verify_webhook_signature as a module function (Node has webhooks.verify)."""
|
|
166
|
+
expected = {
|
|
167
|
+
"agents": ["create", "list", "get", "update", "delete"],
|
|
168
|
+
"calls": ["create", "list", "get", "delete", "delete_all"],
|
|
169
|
+
"campaigns": ["create", "launch"],
|
|
170
|
+
"phone_numbers": ["import_", "list", "update", "delete", "set_monitor", "get_monitor", "set_web_calls", "get_web_calls"],
|
|
171
|
+
"callers": ["get", "set", "delete", "import_"],
|
|
172
|
+
"sms": ["send"], "sms.campaigns": ["create", "launch", "list", "get", "delete"],
|
|
173
|
+
"keys": ["rotate"], "usage": ["get"], "webhooks": ["set", "get", "delete"],
|
|
174
|
+
"storage": ["set", "get", "delete"], "llm": ["set", "get", "delete"], "tts": ["set", "get", "delete"],
|
|
175
|
+
}
|
|
176
|
+
c = Nixflex(api_key=KEY, transport=httpx.MockTransport(lambda r: httpx.Response(200, json={})))
|
|
177
|
+
total = 0
|
|
178
|
+
for res, methods in expected.items():
|
|
179
|
+
obj = c
|
|
180
|
+
for part in res.split("."):
|
|
181
|
+
obj = getattr(obj, part)
|
|
182
|
+
for m in methods:
|
|
183
|
+
assert callable(getattr(obj, m)), f"{res}.{m} missing"
|
|
184
|
+
total += 1
|
|
185
|
+
assert total == 44
|
|
186
|
+
from nixflex import verify_webhook_signature
|
|
187
|
+
assert callable(verify_webhook_signature)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
|
|
4
|
+
from nixflex import verify_webhook_signature
|
|
5
|
+
|
|
6
|
+
SECRET = "nxfs_test_secret"
|
|
7
|
+
BODY = b'{"event":"call.ended","call_id":"CA1"}'
|
|
8
|
+
T = 1_788_600_000
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def sig(t=T, body=BODY, secret=SECRET):
|
|
12
|
+
return "t=%d,v1=%s" % (t, hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest())
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_golden_vector_matches_node_scheme():
|
|
16
|
+
# Same inputs as the Node SDK's golden vector: HMAC-SHA256 of "<ts>.<raw_body>".
|
|
17
|
+
assert verify_webhook_signature(BODY, sig(), SECRET, now=T)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_str_body_and_bytes_body_agree():
|
|
21
|
+
assert verify_webhook_signature(BODY.decode(), sig(), SECRET, now=T)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_rejects_wrong_secret_tampered_body_and_bad_header():
|
|
25
|
+
assert not verify_webhook_signature(BODY, sig(), "other", now=T)
|
|
26
|
+
assert not verify_webhook_signature(BODY + b" ", sig(), SECRET, now=T)
|
|
27
|
+
assert not verify_webhook_signature(BODY, "garbage", SECRET, now=T)
|
|
28
|
+
assert not verify_webhook_signature(BODY, None, SECRET, now=T)
|
|
29
|
+
assert not verify_webhook_signature(BODY, sig(), "", now=T)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_replay_window():
|
|
33
|
+
assert verify_webhook_signature(BODY, sig(), SECRET, now=T + 299)
|
|
34
|
+
assert not verify_webhook_signature(BODY, sig(), SECRET, now=T + 301)
|
|
35
|
+
assert not verify_webhook_signature(BODY, sig(), SECRET, now=T - 301)
|
|
36
|
+
assert verify_webhook_signature(BODY, sig(), SECRET, now=T + 1000, tolerance_seconds=2000)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_never_raises():
|
|
40
|
+
assert verify_webhook_signature(None, sig(), SECRET, now=T) is False # type: ignore[arg-type]
|