ahs-cti-api-sdk 0.1.0a1__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.
- ahs_cti_api_sdk-0.1.0a1/PKG-INFO +156 -0
- ahs_cti_api_sdk-0.1.0a1/README.md +142 -0
- ahs_cti_api_sdk-0.1.0a1/ahs_cti/__init__.py +24 -0
- ahs_cti_api_sdk-0.1.0a1/ahs_cti/_auth.py +236 -0
- ahs_cti_api_sdk-0.1.0a1/ahs_cti/_endpoints.py +56 -0
- ahs_cti_api_sdk-0.1.0a1/ahs_cti/_http.py +277 -0
- ahs_cti_api_sdk-0.1.0a1/ahs_cti/client.py +197 -0
- ahs_cti_api_sdk-0.1.0a1/ahs_cti/exceptions.py +56 -0
- ahs_cti_api_sdk-0.1.0a1/ahs_cti/models.py +54 -0
- ahs_cti_api_sdk-0.1.0a1/ahs_cti/resources/__init__.py +0 -0
- ahs_cti_api_sdk-0.1.0a1/ahs_cti/resources/calls.py +535 -0
- ahs_cti_api_sdk-0.1.0a1/ahs_cti_api_sdk.egg-info/PKG-INFO +156 -0
- ahs_cti_api_sdk-0.1.0a1/ahs_cti_api_sdk.egg-info/SOURCES.txt +17 -0
- ahs_cti_api_sdk-0.1.0a1/ahs_cti_api_sdk.egg-info/dependency_links.txt +1 -0
- ahs_cti_api_sdk-0.1.0a1/ahs_cti_api_sdk.egg-info/requires.txt +6 -0
- ahs_cti_api_sdk-0.1.0a1/ahs_cti_api_sdk.egg-info/top_level.txt +1 -0
- ahs_cti_api_sdk-0.1.0a1/pyproject.toml +29 -0
- ahs_cti_api_sdk-0.1.0a1/setup.cfg +4 -0
- ahs_cti_api_sdk-0.1.0a1/tests/test_calls.py +315 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ahs-cti-api-sdk
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Python SDK for the AHS CTI Platform
|
|
5
|
+
License: Proprietary
|
|
6
|
+
Keywords: cti,telephony,contact-center,ahs
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: httpx>=0.25.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
13
|
+
Requires-Dist: respx>=0.20; extra == "dev"
|
|
14
|
+
|
|
15
|
+
# AHS CTI Python SDK
|
|
16
|
+
|
|
17
|
+
Python SDK for the AHS CTI Platform — initiate and control outbound calls via REST API.
|
|
18
|
+
|
|
19
|
+
**Requirements:** Python ≥ 3.9 · `httpx ≥ 0.25`
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install ahs-cti-api-sdk
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Quick Start
|
|
32
|
+
|
|
33
|
+
### API-Key Authentication (recommended for server integrations)
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from ahs_cti import CtiClient
|
|
37
|
+
|
|
38
|
+
with CtiClient(base_url="https://cti.example.com", api_key="ctsk_...", tenant_id=1) as client:
|
|
39
|
+
result = client.calls.dial("+919876543210")
|
|
40
|
+
print(result.call_id) # save this for end_call
|
|
41
|
+
|
|
42
|
+
end = client.calls.end_call(
|
|
43
|
+
result.call_id,
|
|
44
|
+
disposition="INTERESTED",
|
|
45
|
+
list_comments="Customer wants a demo next week",
|
|
46
|
+
)
|
|
47
|
+
print(end.duration)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Credential Authentication
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from ahs_cti import CtiClient
|
|
54
|
+
|
|
55
|
+
with CtiClient(base_url="https://cti.example.com", user_id="agent1", password="secret") as client:
|
|
56
|
+
result = client.calls.dial("+919876543210", caller_id="AH2007C")
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Async Client
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import asyncio
|
|
63
|
+
from ahs_cti import AsyncCtiClient
|
|
64
|
+
|
|
65
|
+
async def main():
|
|
66
|
+
async with AsyncCtiClient(base_url="https://cti.example.com", api_key="ctsk_...") as client:
|
|
67
|
+
result = await client.calls.dial("+919876543210")
|
|
68
|
+
print(result.call_id)
|
|
69
|
+
|
|
70
|
+
asyncio.run(main())
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## ClientConfig Reference
|
|
76
|
+
|
|
77
|
+
For full control over transport settings, use `ClientConfig`:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from ahs_cti import CtiClient, ClientConfig
|
|
81
|
+
|
|
82
|
+
config = ClientConfig(
|
|
83
|
+
base_url="https://cti.example.com",
|
|
84
|
+
timeout=15.0,
|
|
85
|
+
verify_ssl=False, # for self-signed certificates
|
|
86
|
+
max_retries=5,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
with CtiClient(config=config, api_key="ctsk_...") as client:
|
|
90
|
+
result = client.calls.dial("+919876543210")
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
| Parameter | Type | Default | Description |
|
|
94
|
+
|---|---|---|---|
|
|
95
|
+
| `base_url` | `str` | required | Root URL of the CTI platform |
|
|
96
|
+
| `timeout` | `float` | `30.0` | Request timeout in seconds |
|
|
97
|
+
| `verify_ssl` | `bool` | `True` | Set `False` for self-signed certs |
|
|
98
|
+
| `max_retries` | `int` | `3` | Retry attempts on 5xx / network errors |
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Error Handling
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from ahs_cti import CtiClient, CtiAuthError, CtiApiError, CtiRetryExhaustedError
|
|
106
|
+
|
|
107
|
+
with CtiClient(...) as client:
|
|
108
|
+
try:
|
|
109
|
+
result = client.calls.dial("+919876543210")
|
|
110
|
+
except CtiAuthError:
|
|
111
|
+
# Invalid or expired API key / credentials
|
|
112
|
+
print("Authentication failed")
|
|
113
|
+
except CtiRetryExhaustedError as e:
|
|
114
|
+
# All retry attempts failed (5xx or network)
|
|
115
|
+
print(f"Failed after {e.attempts} attempts: {e.last_exc}")
|
|
116
|
+
except CtiApiError as e:
|
|
117
|
+
# Any other API error (400, 404, 422 …)
|
|
118
|
+
print(f"API error [{e.status_code}]: {e}")
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Exception Hierarchy
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
CtiError
|
|
125
|
+
├── CtiAuthError — 401 / login failure
|
|
126
|
+
└── CtiApiError — 4xx / 5xx from the platform
|
|
127
|
+
└── CtiRetryExhaustedError — all retries exhausted
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Retry Behaviour
|
|
133
|
+
|
|
134
|
+
The SDK automatically retries on `500`, `502`, `503`, `504` responses and transient
|
|
135
|
+
network errors (`ConnectError`, `TimeoutException`). Retries use exponential backoff:
|
|
136
|
+
|
|
137
|
+
| Attempt | Delay |
|
|
138
|
+
|---|---|
|
|
139
|
+
| 1st (initial) | none |
|
|
140
|
+
| 2nd retry | 2 s |
|
|
141
|
+
| 3rd retry | 4 s |
|
|
142
|
+
| 4th retry | 8 s |
|
|
143
|
+
|
|
144
|
+
Set `max_retries=0` in `ClientConfig` to disable retries entirely.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Self-Signed Certificates
|
|
149
|
+
|
|
150
|
+
Internal / staging servers often use self-signed TLS certificates. Pass `verify_ssl=False`:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
client = CtiClient(base_url="https://192.168.1.10:8082", api_key="ctsk_...", verify_ssl=False)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
> Do **not** disable SSL verification in production.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# AHS CTI Python SDK
|
|
2
|
+
|
|
3
|
+
Python SDK for the AHS CTI Platform — initiate and control outbound calls via REST API.
|
|
4
|
+
|
|
5
|
+
**Requirements:** Python ≥ 3.9 · `httpx ≥ 0.25`
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install ahs-cti-api-sdk
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
### API-Key Authentication (recommended for server integrations)
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from ahs_cti import CtiClient
|
|
23
|
+
|
|
24
|
+
with CtiClient(base_url="https://cti.example.com", api_key="ctsk_...", tenant_id=1) as client:
|
|
25
|
+
result = client.calls.dial("+919876543210")
|
|
26
|
+
print(result.call_id) # save this for end_call
|
|
27
|
+
|
|
28
|
+
end = client.calls.end_call(
|
|
29
|
+
result.call_id,
|
|
30
|
+
disposition="INTERESTED",
|
|
31
|
+
list_comments="Customer wants a demo next week",
|
|
32
|
+
)
|
|
33
|
+
print(end.duration)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Credential Authentication
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from ahs_cti import CtiClient
|
|
40
|
+
|
|
41
|
+
with CtiClient(base_url="https://cti.example.com", user_id="agent1", password="secret") as client:
|
|
42
|
+
result = client.calls.dial("+919876543210", caller_id="AH2007C")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Async Client
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import asyncio
|
|
49
|
+
from ahs_cti import AsyncCtiClient
|
|
50
|
+
|
|
51
|
+
async def main():
|
|
52
|
+
async with AsyncCtiClient(base_url="https://cti.example.com", api_key="ctsk_...") as client:
|
|
53
|
+
result = await client.calls.dial("+919876543210")
|
|
54
|
+
print(result.call_id)
|
|
55
|
+
|
|
56
|
+
asyncio.run(main())
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## ClientConfig Reference
|
|
62
|
+
|
|
63
|
+
For full control over transport settings, use `ClientConfig`:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from ahs_cti import CtiClient, ClientConfig
|
|
67
|
+
|
|
68
|
+
config = ClientConfig(
|
|
69
|
+
base_url="https://cti.example.com",
|
|
70
|
+
timeout=15.0,
|
|
71
|
+
verify_ssl=False, # for self-signed certificates
|
|
72
|
+
max_retries=5,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
with CtiClient(config=config, api_key="ctsk_...") as client:
|
|
76
|
+
result = client.calls.dial("+919876543210")
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
| Parameter | Type | Default | Description |
|
|
80
|
+
|---|---|---|---|
|
|
81
|
+
| `base_url` | `str` | required | Root URL of the CTI platform |
|
|
82
|
+
| `timeout` | `float` | `30.0` | Request timeout in seconds |
|
|
83
|
+
| `verify_ssl` | `bool` | `True` | Set `False` for self-signed certs |
|
|
84
|
+
| `max_retries` | `int` | `3` | Retry attempts on 5xx / network errors |
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Error Handling
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from ahs_cti import CtiClient, CtiAuthError, CtiApiError, CtiRetryExhaustedError
|
|
92
|
+
|
|
93
|
+
with CtiClient(...) as client:
|
|
94
|
+
try:
|
|
95
|
+
result = client.calls.dial("+919876543210")
|
|
96
|
+
except CtiAuthError:
|
|
97
|
+
# Invalid or expired API key / credentials
|
|
98
|
+
print("Authentication failed")
|
|
99
|
+
except CtiRetryExhaustedError as e:
|
|
100
|
+
# All retry attempts failed (5xx or network)
|
|
101
|
+
print(f"Failed after {e.attempts} attempts: {e.last_exc}")
|
|
102
|
+
except CtiApiError as e:
|
|
103
|
+
# Any other API error (400, 404, 422 …)
|
|
104
|
+
print(f"API error [{e.status_code}]: {e}")
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Exception Hierarchy
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
CtiError
|
|
111
|
+
├── CtiAuthError — 401 / login failure
|
|
112
|
+
└── CtiApiError — 4xx / 5xx from the platform
|
|
113
|
+
└── CtiRetryExhaustedError — all retries exhausted
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Retry Behaviour
|
|
119
|
+
|
|
120
|
+
The SDK automatically retries on `500`, `502`, `503`, `504` responses and transient
|
|
121
|
+
network errors (`ConnectError`, `TimeoutException`). Retries use exponential backoff:
|
|
122
|
+
|
|
123
|
+
| Attempt | Delay |
|
|
124
|
+
|---|---|
|
|
125
|
+
| 1st (initial) | none |
|
|
126
|
+
| 2nd retry | 2 s |
|
|
127
|
+
| 3rd retry | 4 s |
|
|
128
|
+
| 4th retry | 8 s |
|
|
129
|
+
|
|
130
|
+
Set `max_retries=0` in `ClientConfig` to disable retries entirely.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Self-Signed Certificates
|
|
135
|
+
|
|
136
|
+
Internal / staging servers often use self-signed TLS certificates. Pass `verify_ssl=False`:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
client = CtiClient(base_url="https://192.168.1.10:8082", api_key="ctsk_...", verify_ssl=False)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
> Do **not** disable SSL verification in production.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""AHS CTI Platform — Python SDK."""
|
|
2
|
+
|
|
3
|
+
from .client import AsyncCtiClient, CtiClient
|
|
4
|
+
from ._http import ClientConfig
|
|
5
|
+
from .exceptions import CtiApiError, CtiAuthError, CtiError, CtiRetryExhaustedError
|
|
6
|
+
from .models import DialResult, EndCallResult
|
|
7
|
+
|
|
8
|
+
__version__ = "0.5.0"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
# Clients
|
|
12
|
+
"CtiClient",
|
|
13
|
+
"AsyncCtiClient",
|
|
14
|
+
# Configuration
|
|
15
|
+
"ClientConfig",
|
|
16
|
+
# Models
|
|
17
|
+
"DialResult",
|
|
18
|
+
"EndCallResult",
|
|
19
|
+
# Exceptions
|
|
20
|
+
"CtiError",
|
|
21
|
+
"CtiAuthError",
|
|
22
|
+
"CtiApiError",
|
|
23
|
+
"CtiRetryExhaustedError",
|
|
24
|
+
]
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Authentication strategies — sync and async, with shared-transport injection.
|
|
2
|
+
|
|
3
|
+
Design
|
|
4
|
+
------
|
|
5
|
+
Auth strategies satisfy the ``SyncAuthStrategy`` / ``AsyncAuthStrategy``
|
|
6
|
+
protocols — they expose only ``get_headers()``. The HTTP transport is
|
|
7
|
+
injected so that the same ``httpx.Client`` (and its ``verify`` / ``timeout``
|
|
8
|
+
settings) is used for both login calls and regular API calls.
|
|
9
|
+
|
|
10
|
+
Adding a new auth method (e.g. OAuth2) requires only implementing the
|
|
11
|
+
protocol — no changes to ``_http.py`` or ``client.py``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import time
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from typing import Dict, Optional
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from .exceptions import CtiAuthError
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("ahs_cti.auth")
|
|
24
|
+
|
|
25
|
+
_MIME_JSON = "application/json"
|
|
26
|
+
_EXPIRY_BUFFER_SECS = 60
|
|
27
|
+
_DEFAULT_TOKEN_TTL = 55 * 60
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Protocols (open for extension, no import of concrete classes needed)
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SyncAuthStrategy:
|
|
36
|
+
"""Interface for synchronous authentication strategies.
|
|
37
|
+
|
|
38
|
+
Any class that implements ``get_headers() -> Dict[str, str]``
|
|
39
|
+
satisfies this interface and can be used with ``_HttpClient``.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def get_headers(self) -> Dict[str, str]:
|
|
43
|
+
raise NotImplementedError
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AsyncAuthStrategy:
|
|
47
|
+
"""Interface for asynchronous authentication strategies.
|
|
48
|
+
|
|
49
|
+
Any class that implements ``async get_headers() -> Dict[str, str]``
|
|
50
|
+
satisfies this interface and can be used with ``_AsyncHttpClient``.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
async def get_headers(self) -> Dict[str, str]:
|
|
54
|
+
raise NotImplementedError
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Helpers
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _build_headers(auth_headers: Dict[str, str]) -> Dict[str, str]:
|
|
63
|
+
return {**auth_headers, "Content-Type": _MIME_JSON, "Accept": _MIME_JSON}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse_expiry(expiry_str: Optional[str]) -> float:
|
|
67
|
+
if expiry_str:
|
|
68
|
+
try:
|
|
69
|
+
dt = datetime.fromisoformat(expiry_str.replace("Z", "+00:00"))
|
|
70
|
+
return dt.astimezone(timezone.utc).timestamp()
|
|
71
|
+
except ValueError:
|
|
72
|
+
pass
|
|
73
|
+
return time.time() + _DEFAULT_TOKEN_TTL
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Sync strategies
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class _ApiKeyAuth(SyncAuthStrategy):
|
|
82
|
+
"""Static API-key auth. Headers are built in memory — no network calls."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, api_key: str, tenant_id: Optional[int] = None) -> None:
|
|
85
|
+
self._api_key = api_key
|
|
86
|
+
self._tenant_id = tenant_id
|
|
87
|
+
|
|
88
|
+
def get_headers(self) -> Dict[str, str]:
|
|
89
|
+
auth: Dict[str, str] = {"X-API-Key": self._api_key}
|
|
90
|
+
if self._tenant_id is not None:
|
|
91
|
+
auth["X-Tenant-ID"] = str(self._tenant_id)
|
|
92
|
+
return _build_headers(auth)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _CredentialAuth(SyncAuthStrategy):
|
|
96
|
+
"""Credential-based auth — lazy login, auto-refresh 60 s before expiry.
|
|
97
|
+
|
|
98
|
+
The caller injects the shared ``httpx.Client`` so that SSL and timeout
|
|
99
|
+
settings are consistent between the login call and regular API calls.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
login_url: Full URL of the login endpoint.
|
|
103
|
+
user_id: Agent username.
|
|
104
|
+
password: Agent password.
|
|
105
|
+
http_client: Shared ``httpx.Client`` instance (borrowed, not owned).
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
login_url: str,
|
|
111
|
+
user_id: str,
|
|
112
|
+
password: str,
|
|
113
|
+
http_client: httpx.Client,
|
|
114
|
+
) -> None:
|
|
115
|
+
self._login_url = login_url
|
|
116
|
+
self._user_id = user_id
|
|
117
|
+
self._password = password
|
|
118
|
+
self._http_client = http_client
|
|
119
|
+
self._access_token: Optional[str] = None
|
|
120
|
+
self._token_expiry: float = 0.0
|
|
121
|
+
|
|
122
|
+
def get_headers(self) -> Dict[str, str]:
|
|
123
|
+
if not self._is_token_valid():
|
|
124
|
+
self._login()
|
|
125
|
+
return _build_headers({"Authorization": f"Bearer {self._access_token}"})
|
|
126
|
+
|
|
127
|
+
def _is_token_valid(self) -> bool:
|
|
128
|
+
return (
|
|
129
|
+
self._access_token is not None
|
|
130
|
+
and time.time() < self._token_expiry - _EXPIRY_BUFFER_SECS
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def _login(self) -> None:
|
|
134
|
+
logger.debug("ahs_cti: acquiring token for user_id=%s", self._user_id)
|
|
135
|
+
try:
|
|
136
|
+
resp = self._http_client.post(
|
|
137
|
+
self._login_url,
|
|
138
|
+
json={"userId": self._user_id, "password": self._password},
|
|
139
|
+
)
|
|
140
|
+
except httpx.RequestError as exc:
|
|
141
|
+
raise CtiAuthError(f"Network error during login: {exc}") from exc
|
|
142
|
+
if resp.status_code != 200:
|
|
143
|
+
raise CtiAuthError(
|
|
144
|
+
f"Login failed (HTTP {resp.status_code}). "
|
|
145
|
+
"Verify user_id, password, and base_url."
|
|
146
|
+
)
|
|
147
|
+
data = resp.json()
|
|
148
|
+
token = data.get("accessToken")
|
|
149
|
+
if not token:
|
|
150
|
+
raise CtiAuthError("Login response did not contain an accessToken.")
|
|
151
|
+
self._access_token = token
|
|
152
|
+
self._token_expiry = _parse_expiry(data.get("expiration"))
|
|
153
|
+
logger.debug("ahs_cti: token acquired, expires at %s", self._token_expiry)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# Async strategies
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class _AsyncApiKeyAuth(SyncAuthStrategy):
|
|
162
|
+
"""API-key auth for use with the async client.
|
|
163
|
+
|
|
164
|
+
Satisfies ``SyncAuthStrategy`` — header building is pure in-memory,
|
|
165
|
+
so no coroutine is needed. ``_AsyncHttpClient`` resolves headers via
|
|
166
|
+
``_resolve_headers()`` which handles both sync and async strategies.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(self, api_key: str, tenant_id: Optional[int] = None) -> None:
|
|
170
|
+
self._inner = _ApiKeyAuth(api_key=api_key, tenant_id=tenant_id)
|
|
171
|
+
|
|
172
|
+
def get_headers(self) -> Dict[str, str]:
|
|
173
|
+
return self._inner.get_headers()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class _AsyncCredentialAuth(AsyncAuthStrategy):
|
|
177
|
+
"""Async credential auth — lazy login with asyncio.Lock to prevent races.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
login_url: Full URL of the login endpoint.
|
|
181
|
+
user_id: Agent username.
|
|
182
|
+
password: Agent password.
|
|
183
|
+
http_client: Shared ``httpx.AsyncClient`` instance (borrowed, not owned).
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
def __init__(
|
|
187
|
+
self,
|
|
188
|
+
login_url: str,
|
|
189
|
+
user_id: str,
|
|
190
|
+
password: str,
|
|
191
|
+
http_client: httpx.AsyncClient,
|
|
192
|
+
) -> None:
|
|
193
|
+
self._login_url = login_url
|
|
194
|
+
self._user_id = user_id
|
|
195
|
+
self._password = password
|
|
196
|
+
self._http_client = http_client
|
|
197
|
+
self._access_token: Optional[str] = None
|
|
198
|
+
self._token_expiry: float = 0.0
|
|
199
|
+
self._lock: Optional["asyncio.Lock"] = None # created lazily inside event loop
|
|
200
|
+
|
|
201
|
+
async def get_headers(self) -> Dict[str, str]:
|
|
202
|
+
import asyncio
|
|
203
|
+
if self._lock is None:
|
|
204
|
+
self._lock = asyncio.Lock()
|
|
205
|
+
async with self._lock:
|
|
206
|
+
if not self._is_token_valid():
|
|
207
|
+
await self._login()
|
|
208
|
+
return _build_headers({"Authorization": f"Bearer {self._access_token}"})
|
|
209
|
+
|
|
210
|
+
def _is_token_valid(self) -> bool:
|
|
211
|
+
return (
|
|
212
|
+
self._access_token is not None
|
|
213
|
+
and time.time() < self._token_expiry - _EXPIRY_BUFFER_SECS
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
async def _login(self) -> None:
|
|
217
|
+
logger.debug("ahs_cti: async acquiring token for user_id=%s", self._user_id)
|
|
218
|
+
try:
|
|
219
|
+
resp = await self._http_client.post(
|
|
220
|
+
self._login_url,
|
|
221
|
+
json={"userId": self._user_id, "password": self._password},
|
|
222
|
+
)
|
|
223
|
+
except httpx.RequestError as exc:
|
|
224
|
+
raise CtiAuthError(f"Network error during login: {exc}") from exc
|
|
225
|
+
if resp.status_code != 200:
|
|
226
|
+
raise CtiAuthError(
|
|
227
|
+
f"Login failed (HTTP {resp.status_code}). "
|
|
228
|
+
"Verify user_id, password, and base_url."
|
|
229
|
+
)
|
|
230
|
+
data = resp.json()
|
|
231
|
+
token = data.get("accessToken")
|
|
232
|
+
if not token:
|
|
233
|
+
raise CtiAuthError("Login response did not contain an accessToken.")
|
|
234
|
+
self._access_token = token
|
|
235
|
+
self._token_expiry = _parse_expiry(data.get("expiration"))
|
|
236
|
+
logger.debug("ahs_cti: async token acquired, expires at %s", self._token_expiry)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Central API endpoint registry.
|
|
2
|
+
|
|
3
|
+
All paths are versioned under API_VERSION. To upgrade the entire SDK
|
|
4
|
+
to a new API version, change API_VERSION here — no other file needs editing.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from ._endpoints import Endpoints
|
|
9
|
+
self._http.post(Endpoints.DIAL, body)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
API_VERSION = "v1"
|
|
13
|
+
_BASE = f"/api/{API_VERSION}/cti"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Endpoints:
|
|
17
|
+
"""All CTI platform API endpoint paths."""
|
|
18
|
+
|
|
19
|
+
# ── Calls ─────────────────────────────────────────────────────────────────
|
|
20
|
+
DIAL = f"{_BASE}/calls/dial"
|
|
21
|
+
HANGUP = f"{_BASE}/calls/hangup"
|
|
22
|
+
ANSWER = f"{_BASE}/calls/{{call_id}}/answer"
|
|
23
|
+
FETCH_DETAILS = f"{_BASE}/calls/fetch-call-details"
|
|
24
|
+
ACTIVE_CALLS = f"{_BASE}/calls/active"
|
|
25
|
+
|
|
26
|
+
# ── Hold / Mute / DTMF ───────────────────────────────────────────────────
|
|
27
|
+
HOLD = f"{_BASE}/calls/hold"
|
|
28
|
+
MUTE = f"{_BASE}/calls/mute"
|
|
29
|
+
DTMF = f"{_BASE}/calls/{{call_id}}/dtmf"
|
|
30
|
+
|
|
31
|
+
# ── Transfer ──────────────────────────────────────────────────────────────
|
|
32
|
+
TRANSFER = f"{_BASE}/calls/{{call_id}}/transfer"
|
|
33
|
+
TRANSFER_CALL = f"{_BASE}/calls/transfer-call"
|
|
34
|
+
TRANSFER_CANCEL = f"{_BASE}/calls/transfer/cancel"
|
|
35
|
+
TRANSFER_COMPLETE = f"{_BASE}/calls/transfer/complete"
|
|
36
|
+
|
|
37
|
+
# ── Conference ────────────────────────────────────────────────────────────
|
|
38
|
+
CONFERENCE_ADD = f"{_BASE}/calls/conference/add"
|
|
39
|
+
CONFERENCE_MERGE = f"{_BASE}/calls/conference/merge"
|
|
40
|
+
|
|
41
|
+
# ── Agent ─────────────────────────────────────────────────────────────────
|
|
42
|
+
AGENT_REGISTER = f"{_BASE}/agent/register"
|
|
43
|
+
AGENT_UNREGISTER = f"{_BASE}/agent/unregister"
|
|
44
|
+
AGENT_CONFIG = f"{_BASE}/agent/config"
|
|
45
|
+
|
|
46
|
+
# ── Supervisor ────────────────────────────────────────────────────────────
|
|
47
|
+
SUPERVISOR_LISTEN = f"{_BASE}/supervisor/listen"
|
|
48
|
+
SUPERVISOR_LEAVE = f"{_BASE}/supervisor/leave"
|
|
49
|
+
SUPERVISOR_MODE = f"{_BASE}/supervisor/mode"
|
|
50
|
+
|
|
51
|
+
# ── Data / Lookup ─────────────────────────────────────────────────────────
|
|
52
|
+
DISPOSITIONS = f"{_BASE}/calls/dispositions"
|
|
53
|
+
TRANSFER_TO_DETAILS = f"{_BASE}/transfer-to-details"
|
|
54
|
+
USERS = f"{_BASE}/users"
|
|
55
|
+
HOSPITAL_SERVICES = f"{_BASE}/hospital/services"
|
|
56
|
+
BREAKS = f"{_BASE}/breaks"
|