callaider 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.
- callaider/__init__.py +27 -0
- callaider/_base_client.py +216 -0
- callaider/_client.py +99 -0
- callaider/_constants.py +4 -0
- callaider/_models.py +140 -0
- callaider/_streaming.py +117 -0
- callaider/exceptions.py +55 -0
- callaider/resources/__init__.py +0 -0
- callaider/resources/assistants.py +96 -0
- callaider/resources/ringing.py +171 -0
- callaider/webhooks.py +40 -0
- callaider-0.1.0.dist-info/METADATA +144 -0
- callaider-0.1.0.dist-info/RECORD +15 -0
- callaider-0.1.0.dist-info/WHEEL +4 -0
- callaider-0.1.0.dist-info/licenses/LICENSE +21 -0
callaider/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from callaider._client import AsyncCallaider, Callaider
|
|
2
|
+
from callaider.exceptions import (
|
|
3
|
+
APIConnectionError,
|
|
4
|
+
APIError,
|
|
5
|
+
APIStatusError,
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
CallaiderError,
|
|
8
|
+
InternalServerError,
|
|
9
|
+
NotFoundError,
|
|
10
|
+
RateLimitError,
|
|
11
|
+
)
|
|
12
|
+
from callaider.webhooks import WebhookParsingError, Webhooks
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Callaider",
|
|
16
|
+
"AsyncCallaider",
|
|
17
|
+
"Webhooks",
|
|
18
|
+
"WebhookParsingError",
|
|
19
|
+
"CallaiderError",
|
|
20
|
+
"APIError",
|
|
21
|
+
"APIStatusError",
|
|
22
|
+
"AuthenticationError",
|
|
23
|
+
"NotFoundError",
|
|
24
|
+
"RateLimitError",
|
|
25
|
+
"InternalServerError",
|
|
26
|
+
"APIConnectionError",
|
|
27
|
+
]
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Mapping
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from callaider._constants import DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, RAW_USER_AGENT
|
|
8
|
+
from callaider.exceptions import (
|
|
9
|
+
APIConnectionError,
|
|
10
|
+
APIStatusError,
|
|
11
|
+
AuthenticationError,
|
|
12
|
+
InternalServerError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
RateLimitError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("callaider")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _make_status_error(
|
|
21
|
+
response: httpx.Response,
|
|
22
|
+
body: Any,
|
|
23
|
+
) -> APIStatusError:
|
|
24
|
+
"""Map HTTP status codes to specific exception types."""
|
|
25
|
+
status_code = response.status_code
|
|
26
|
+
message = f"Error code: {status_code}"
|
|
27
|
+
if isinstance(body, dict):
|
|
28
|
+
message = body.get("message") or body.get("detail") or str(body)
|
|
29
|
+
elif isinstance(body, str) and body:
|
|
30
|
+
message = body
|
|
31
|
+
|
|
32
|
+
if status_code in (401, 403):
|
|
33
|
+
return AuthenticationError(message, response=response, body=body)
|
|
34
|
+
if status_code == 404:
|
|
35
|
+
return NotFoundError(message, response=response, body=body)
|
|
36
|
+
if status_code == 429:
|
|
37
|
+
return RateLimitError(message, response=response, body=body)
|
|
38
|
+
if status_code >= 500:
|
|
39
|
+
return InternalServerError(message, response=response, body=body)
|
|
40
|
+
return APIStatusError(message, response=response, body=body)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SyncHttpxClientWrapper:
|
|
44
|
+
"""Synchronous HTTP transport layer with automatic retry logic."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
49
|
+
api_key: str | None = None,
|
|
50
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
51
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
52
|
+
custom_headers: Mapping[str, str] | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
self.base_url = base_url.rstrip("/")
|
|
55
|
+
self.max_retries = max_retries
|
|
56
|
+
|
|
57
|
+
headers = {
|
|
58
|
+
"Authorization": f"Bearer {api_key}",
|
|
59
|
+
"User-Agent": RAW_USER_AGENT,
|
|
60
|
+
"Accept": "application/json",
|
|
61
|
+
**(custom_headers or {}),
|
|
62
|
+
}
|
|
63
|
+
self._client = httpx.Client(
|
|
64
|
+
base_url=self.base_url,
|
|
65
|
+
headers=headers,
|
|
66
|
+
timeout=timeout,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def request(
|
|
70
|
+
self,
|
|
71
|
+
method: str,
|
|
72
|
+
path: str,
|
|
73
|
+
*,
|
|
74
|
+
params: Mapping[str, Any] | None = None,
|
|
75
|
+
json_data: Any | None = None,
|
|
76
|
+
headers: Mapping[str, str] | None = None,
|
|
77
|
+
) -> Any:
|
|
78
|
+
"""Execute a synchronous HTTP request with exponential backoff retries."""
|
|
79
|
+
retries = self.max_retries
|
|
80
|
+
delay = 0.5
|
|
81
|
+
|
|
82
|
+
for attempt in range(retries + 1):
|
|
83
|
+
try:
|
|
84
|
+
response = self._client.request(
|
|
85
|
+
method=method,
|
|
86
|
+
url=path,
|
|
87
|
+
params=params,
|
|
88
|
+
json=json_data,
|
|
89
|
+
headers=headers,
|
|
90
|
+
)
|
|
91
|
+
if response.is_success:
|
|
92
|
+
return response.json() if response.content else None
|
|
93
|
+
|
|
94
|
+
# Attempt to parse response body for error messaging
|
|
95
|
+
try:
|
|
96
|
+
body = response.json()
|
|
97
|
+
except Exception:
|
|
98
|
+
body = response.text
|
|
99
|
+
|
|
100
|
+
# Retry on rate limits, timeouts, and server errors
|
|
101
|
+
if response.status_code in (408, 429, 500, 502, 503, 504) and attempt < retries:
|
|
102
|
+
logger.warning(
|
|
103
|
+
"Retrying request (%d/%d) due to status %d...",
|
|
104
|
+
attempt + 1,
|
|
105
|
+
retries,
|
|
106
|
+
response.status_code,
|
|
107
|
+
)
|
|
108
|
+
time.sleep(delay)
|
|
109
|
+
delay *= 2
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
raise _make_status_error(response, body)
|
|
113
|
+
|
|
114
|
+
except httpx.RequestError as exc:
|
|
115
|
+
if attempt < retries:
|
|
116
|
+
logger.warning(
|
|
117
|
+
"Retrying request (%d/%d) due to network error: %s",
|
|
118
|
+
attempt + 1,
|
|
119
|
+
retries,
|
|
120
|
+
exc,
|
|
121
|
+
)
|
|
122
|
+
time.sleep(delay)
|
|
123
|
+
delay *= 2
|
|
124
|
+
continue
|
|
125
|
+
raise APIConnectionError(f"Connection error: {exc}") from exc
|
|
126
|
+
|
|
127
|
+
def close(self) -> None:
|
|
128
|
+
"""Close the underlying HTTP client session."""
|
|
129
|
+
self._client.close()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class AsyncHttpxClientWrapper:
|
|
133
|
+
"""Asynchronous HTTP transport layer with automatic retry logic."""
|
|
134
|
+
|
|
135
|
+
def __init__(
|
|
136
|
+
self,
|
|
137
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
138
|
+
api_key: str | None = None,
|
|
139
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
140
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
141
|
+
custom_headers: Mapping[str, str] | None = None,
|
|
142
|
+
) -> None:
|
|
143
|
+
self.base_url = base_url.rstrip("/")
|
|
144
|
+
self.max_retries = max_retries
|
|
145
|
+
|
|
146
|
+
headers = {
|
|
147
|
+
"Authorization": f"Bearer {api_key}",
|
|
148
|
+
"User-Agent": RAW_USER_AGENT,
|
|
149
|
+
"Accept": "application/json",
|
|
150
|
+
**(custom_headers or {}),
|
|
151
|
+
}
|
|
152
|
+
self._client = httpx.AsyncClient(
|
|
153
|
+
base_url=self.base_url,
|
|
154
|
+
headers=headers,
|
|
155
|
+
timeout=timeout,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
async def request(
|
|
159
|
+
self,
|
|
160
|
+
method: str,
|
|
161
|
+
path: str,
|
|
162
|
+
*,
|
|
163
|
+
params: Mapping[str, Any] | None = None,
|
|
164
|
+
json_data: Any | None = None,
|
|
165
|
+
headers: Mapping[str, str] | None = None,
|
|
166
|
+
) -> Any:
|
|
167
|
+
"""Execute an asynchronous HTTP request with exponential backoff retries."""
|
|
168
|
+
retries = self.max_retries
|
|
169
|
+
delay = 0.5
|
|
170
|
+
|
|
171
|
+
for attempt in range(retries + 1):
|
|
172
|
+
try:
|
|
173
|
+
response = await self._client.request(
|
|
174
|
+
method=method,
|
|
175
|
+
url=path,
|
|
176
|
+
params=params,
|
|
177
|
+
json=json_data,
|
|
178
|
+
headers=headers,
|
|
179
|
+
)
|
|
180
|
+
if response.is_success:
|
|
181
|
+
return response.json() if response.content else None
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
body = response.json()
|
|
185
|
+
except Exception:
|
|
186
|
+
body = response.text
|
|
187
|
+
|
|
188
|
+
if response.status_code in (408, 429, 500, 502, 503, 504) and attempt < retries:
|
|
189
|
+
logger.warning(
|
|
190
|
+
"Retrying request (%d/%d) due to status %d...",
|
|
191
|
+
attempt + 1,
|
|
192
|
+
retries,
|
|
193
|
+
response.status_code,
|
|
194
|
+
)
|
|
195
|
+
await asyncio.sleep(delay)
|
|
196
|
+
delay *= 2
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
raise _make_status_error(response, body)
|
|
200
|
+
|
|
201
|
+
except httpx.RequestError as exc:
|
|
202
|
+
if attempt < retries:
|
|
203
|
+
logger.warning(
|
|
204
|
+
"Retrying request (%d/%d) due to network error: %s",
|
|
205
|
+
attempt + 1,
|
|
206
|
+
retries,
|
|
207
|
+
exc,
|
|
208
|
+
)
|
|
209
|
+
await asyncio.sleep(delay)
|
|
210
|
+
delay *= 2
|
|
211
|
+
continue
|
|
212
|
+
raise APIConnectionError(f"Connection error: {exc}") from exc
|
|
213
|
+
|
|
214
|
+
async def close(self) -> None:
|
|
215
|
+
"""Close the underlying async HTTP client session."""
|
|
216
|
+
await self._client.aclose()
|
callaider/_client.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Mapping
|
|
3
|
+
from callaider._base_client import AsyncHttpxClientWrapper, SyncHttpxClientWrapper
|
|
4
|
+
from callaider.resources.assistants import AssistantsResource, AsyncAssistantsResource
|
|
5
|
+
from callaider._constants import DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT
|
|
6
|
+
from callaider._models import HealthResponse
|
|
7
|
+
from callaider.resources.ringing import AsyncRingingResource, RingingResource
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Callaider:
|
|
11
|
+
"""Synchronous client for the Callaider API."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
*,
|
|
16
|
+
api_key: str | None = None,
|
|
17
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
18
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
19
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
20
|
+
default_headers: Mapping[str, str] | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
resolved_key = api_key or os.getenv("CALLAIDER_API_KEY")
|
|
23
|
+
if not resolved_key:
|
|
24
|
+
raise ValueError(
|
|
25
|
+
"Missing API key. Pass `api_key` explicitly or set the `CALLAIDER_API_KEY` environment variable."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
self._transport = SyncHttpxClientWrapper(
|
|
29
|
+
base_url=base_url,
|
|
30
|
+
api_key=resolved_key,
|
|
31
|
+
timeout=timeout,
|
|
32
|
+
max_retries=max_retries,
|
|
33
|
+
custom_headers=default_headers,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Resource namespaces
|
|
37
|
+
self.ringing = RingingResource(self)
|
|
38
|
+
self.assistants = AssistantsResource(self)
|
|
39
|
+
|
|
40
|
+
def health(self) -> HealthResponse:
|
|
41
|
+
"""Check API service health status."""
|
|
42
|
+
data = self._transport.request("GET", "/v1/health")
|
|
43
|
+
return HealthResponse.model_validate(data or {})
|
|
44
|
+
|
|
45
|
+
def close(self) -> None:
|
|
46
|
+
"""Close the active client session."""
|
|
47
|
+
self._transport.close()
|
|
48
|
+
|
|
49
|
+
def __enter__(self) -> "Callaider":
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
def __exit__(self, *args: object) -> None:
|
|
53
|
+
self.close()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AsyncCallaider:
|
|
57
|
+
"""Asynchronous client for the Callaider API."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
api_key: str | None = None,
|
|
63
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
64
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
65
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
66
|
+
default_headers: Mapping[str, str] | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
resolved_key = api_key or os.getenv("CALLAIDER_API_KEY")
|
|
69
|
+
if not resolved_key:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
"Missing API key. Pass `api_key` explicitly or set the `CALLAIDER_API_KEY` environment variable."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
self._transport = AsyncHttpxClientWrapper(
|
|
75
|
+
base_url=base_url,
|
|
76
|
+
api_key=resolved_key,
|
|
77
|
+
timeout=timeout,
|
|
78
|
+
max_retries=max_retries,
|
|
79
|
+
custom_headers=default_headers,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Resource namespaces
|
|
83
|
+
self.ringing = AsyncRingingResource(self)
|
|
84
|
+
self.assistants = AsyncAssistantsResource(self)
|
|
85
|
+
|
|
86
|
+
async def health(self) -> HealthResponse:
|
|
87
|
+
"""Check API service health status asynchronously."""
|
|
88
|
+
data = await self._transport.request("GET", "/v1/health")
|
|
89
|
+
return HealthResponse.model_validate(data or {})
|
|
90
|
+
|
|
91
|
+
async def close(self) -> None:
|
|
92
|
+
"""Close the active client session asynchronously."""
|
|
93
|
+
await self._transport.close()
|
|
94
|
+
|
|
95
|
+
async def __aenter__(self) -> "AsyncCallaider":
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
async def __aexit__(self, *args: object) -> None:
|
|
99
|
+
await self.close()
|
callaider/_constants.py
ADDED
callaider/_models.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Any
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BaseModelStrict(BaseModel):
|
|
7
|
+
"""Base Pydantic model with extra field tolerance and alias support."""
|
|
8
|
+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HealthResponse(BaseModelStrict):
|
|
12
|
+
"""API health status response."""
|
|
13
|
+
status: str = Field(default="ok")
|
|
14
|
+
timestamp: str | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# --- Ringing Enums & Sub-models ---
|
|
18
|
+
|
|
19
|
+
class CampaignStatus(str, Enum):
|
|
20
|
+
DRAFT = "draft"
|
|
21
|
+
CREATED = "created"
|
|
22
|
+
IN_PROGRESS = "in_progress"
|
|
23
|
+
PAUSED = "paused"
|
|
24
|
+
COMPLETED = "completed"
|
|
25
|
+
FAILED = "failed"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CampaignRecipient(BaseModelStrict):
|
|
29
|
+
"""Single recipient details."""
|
|
30
|
+
phone: str
|
|
31
|
+
name: str | None = None
|
|
32
|
+
variables: dict[str, Any] = Field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Campaign(BaseModelStrict):
|
|
36
|
+
"""Full campaign object representation."""
|
|
37
|
+
id: str | int
|
|
38
|
+
name: str | None = None
|
|
39
|
+
status: str | CampaignStatus
|
|
40
|
+
assistant_id: str | None = None
|
|
41
|
+
total_recipients: int | None = 0
|
|
42
|
+
created_at: str | None = None
|
|
43
|
+
updated_at: str | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CampaignStatistics(BaseModelStrict):
|
|
47
|
+
"""Aggregated campaign performance statistics."""
|
|
48
|
+
campaign_id: str | int
|
|
49
|
+
total_calls: int = 0
|
|
50
|
+
completed_calls: int = 0
|
|
51
|
+
successful_calls: int = 0
|
|
52
|
+
failed_calls: int = 0
|
|
53
|
+
average_duration_seconds: float | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CallRecord(BaseModelStrict):
|
|
57
|
+
"""Single call details and outcome."""
|
|
58
|
+
id: str | int
|
|
59
|
+
campaign_id: str | int | None = None
|
|
60
|
+
phone: str
|
|
61
|
+
status: str
|
|
62
|
+
duration_seconds: int | None = 0
|
|
63
|
+
recording_url: str | None = None
|
|
64
|
+
transcript: str | None = None
|
|
65
|
+
post_analysis: dict[str, Any] | None = None
|
|
66
|
+
created_at: str | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class RecordingInfo(BaseModelStrict):
|
|
70
|
+
"""Call audio recording metadata."""
|
|
71
|
+
call_id: str | int
|
|
72
|
+
recording_url: str
|
|
73
|
+
duration_seconds: int | None = None
|
|
74
|
+
format: str = "mp3"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# --- Assistants / External Conversation Models ---
|
|
78
|
+
|
|
79
|
+
class MessageItem(BaseModelStrict):
|
|
80
|
+
"""Individual conversation message representation."""
|
|
81
|
+
role: str = "assistant"
|
|
82
|
+
content: str = ""
|
|
83
|
+
timestamp: str | None = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ExternalConversationMessageRequest(BaseModelStrict):
|
|
87
|
+
"""Request payload for sending messages to an external assistant conversation."""
|
|
88
|
+
message: str
|
|
89
|
+
external_conversation_id: str = Field(alias="externalConversationId")
|
|
90
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ExternalConversationMessageResponse(BaseModelStrict):
|
|
94
|
+
"""Response returned by the AI assistant conversation bridge."""
|
|
95
|
+
ok: bool = True
|
|
96
|
+
status: str | None = None
|
|
97
|
+
external_conversation_id: str | None = Field(default=None, alias="externalConversationId")
|
|
98
|
+
reply: str | None = None
|
|
99
|
+
messages: list[MessageItem] = Field(default_factory=list)
|
|
100
|
+
state: dict[str, Any] = Field(default_factory=dict)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def text(self) -> str:
|
|
104
|
+
"""Convenience helper to extract the main assistant reply text."""
|
|
105
|
+
if self.reply:
|
|
106
|
+
return self.reply
|
|
107
|
+
if self.messages:
|
|
108
|
+
# Return content of the latest assistant message
|
|
109
|
+
for msg in reversed(self.messages):
|
|
110
|
+
if msg.role == "assistant" and msg.content:
|
|
111
|
+
return msg.content
|
|
112
|
+
return self.messages[-1].content
|
|
113
|
+
return ""
|
|
114
|
+
|
|
115
|
+
# --- Webhook Event Models ---
|
|
116
|
+
|
|
117
|
+
class WebhookEventType(str, Enum):
|
|
118
|
+
CALL_STARTED = "call.started"
|
|
119
|
+
CALL_ANSWERED = "call.answered"
|
|
120
|
+
CALL_COMPLETED = "call.completed"
|
|
121
|
+
CALL_FAILED = "call.failed"
|
|
122
|
+
CAMPAIGN_FINISHED = "campaign.finished"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class CallCompletedPayload(BaseModelStrict):
|
|
126
|
+
"""Payload delivered when a call is finished."""
|
|
127
|
+
call_id: str | int
|
|
128
|
+
campaign_id: str | int | None = None
|
|
129
|
+
phone: str
|
|
130
|
+
duration_seconds: int = 0
|
|
131
|
+
recording_url: str | None = None
|
|
132
|
+
transcript: str | None = None
|
|
133
|
+
post_analysis: dict[str, Any] = Field(default_factory=dict)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class WebhookEvent(BaseModelStrict):
|
|
137
|
+
"""Generic incoming Callaider webhook event wrapper."""
|
|
138
|
+
event: str | WebhookEventType
|
|
139
|
+
timestamp: str | None = None
|
|
140
|
+
data: CallCompletedPayload | dict[str, Any]
|
callaider/_streaming.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from typing import Generic, TypeVar, Optional, TYPE_CHECKING
|
|
2
|
+
|
|
3
|
+
import httpx2
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from ._client import OpenAI, AsyncOpenAI
|
|
7
|
+
from ._models import FinalRequestOptions
|
|
8
|
+
|
|
9
|
+
_T = TypeVar("_T")
|
|
10
|
+
|
|
11
|
+
class Stream(Generic[_T]):
|
|
12
|
+
"""Provides the core interface to iterate over a synchronous stream response."""
|
|
13
|
+
|
|
14
|
+
response: httpx2.Response
|
|
15
|
+
_options: Optional[FinalRequestOptions] = None
|
|
16
|
+
_decoder: SSEBytesDecoder
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
*,
|
|
21
|
+
cast_to: type[_T],
|
|
22
|
+
response: httpx2.Response,
|
|
23
|
+
client: OpenAI,
|
|
24
|
+
options: Optional[FinalRequestOptions] = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
self.response = response
|
|
27
|
+
self._cast_to = cast_to
|
|
28
|
+
self._client = client
|
|
29
|
+
self._options = options
|
|
30
|
+
self._decoder = client._make_sse_decoder()
|
|
31
|
+
self._iterator = self.__stream__()
|
|
32
|
+
|
|
33
|
+
def __next__(self) -> _T:
|
|
34
|
+
return self._iterator.__next__()
|
|
35
|
+
|
|
36
|
+
def __iter__(self) -> Iterator[_T]:
|
|
37
|
+
for item in self._iterator:
|
|
38
|
+
yield item
|
|
39
|
+
|
|
40
|
+
def _iter_events(self) -> Iterator[ServerSentEvent]:
|
|
41
|
+
yield from self._decoder.iter_bytes(self.response.iter_bytes())
|
|
42
|
+
|
|
43
|
+
def __stream__(self) -> Iterator[_T]:
|
|
44
|
+
cast_to = cast(Any, self._cast_to)
|
|
45
|
+
response = self.response
|
|
46
|
+
process_data = self._client._process_response_data
|
|
47
|
+
iterator = self._iter_events()
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
for sse in iterator:
|
|
51
|
+
if sse.data.startswith("[DONE]"):
|
|
52
|
+
break
|
|
53
|
+
|
|
54
|
+
# we have to special case the Assistants `thread.` events since we won't have an "event" key in the data
|
|
55
|
+
if sse.event and sse.event.startswith("thread."):
|
|
56
|
+
data = sse.json()
|
|
57
|
+
|
|
58
|
+
if sse.event == "error" and is_mapping(data) and data.get("error"):
|
|
59
|
+
message = None
|
|
60
|
+
error = data.get("error")
|
|
61
|
+
if is_mapping(error):
|
|
62
|
+
message = error.get("message")
|
|
63
|
+
if not message or not isinstance(message, str):
|
|
64
|
+
message = "An error occurred during streaming"
|
|
65
|
+
|
|
66
|
+
raise APIError(
|
|
67
|
+
message=message,
|
|
68
|
+
request=self.response.request,
|
|
69
|
+
body=data["error"],
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
yield process_data(data={"data": data, "event": sse.event}, cast_to=cast_to, response=response)
|
|
73
|
+
else:
|
|
74
|
+
data = sse.json()
|
|
75
|
+
if is_mapping(data) and data.get("error"):
|
|
76
|
+
message = None
|
|
77
|
+
error = data.get("error")
|
|
78
|
+
if is_mapping(error):
|
|
79
|
+
message = error.get("message")
|
|
80
|
+
if not message or not isinstance(message, str):
|
|
81
|
+
message = "An error occurred during streaming"
|
|
82
|
+
|
|
83
|
+
raise APIError(
|
|
84
|
+
message=message,
|
|
85
|
+
request=self.response.request,
|
|
86
|
+
body=data["error"],
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
yield process_data(
|
|
90
|
+
data={"data": data, "event": sse.event}
|
|
91
|
+
if self._options is not None and self._options.synthesize_event_and_data
|
|
92
|
+
else data,
|
|
93
|
+
cast_to=cast_to,
|
|
94
|
+
response=response,
|
|
95
|
+
)
|
|
96
|
+
finally:
|
|
97
|
+
# Ensure the response is closed even if the consumer doesn't read all data
|
|
98
|
+
response.close()
|
|
99
|
+
|
|
100
|
+
def __enter__(self) -> Self:
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def __exit__(
|
|
104
|
+
self,
|
|
105
|
+
exc_type: type[BaseException] | None,
|
|
106
|
+
exc: BaseException | None,
|
|
107
|
+
exc_tb: TracebackType | None,
|
|
108
|
+
) -> None:
|
|
109
|
+
self.close()
|
|
110
|
+
|
|
111
|
+
def close(self) -> None:
|
|
112
|
+
"""
|
|
113
|
+
Close the response and release the connection.
|
|
114
|
+
|
|
115
|
+
Automatically called if the response body is read to completion.
|
|
116
|
+
"""
|
|
117
|
+
self.response.close()
|
callaider/exceptions.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
import httpx
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CallaiderError(Exception):
|
|
6
|
+
"""Base exception for all Callaider SDK errors."""
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class APIError(CallaiderError):
|
|
11
|
+
"""Raised when the API returns an error response."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
message: str,
|
|
16
|
+
*,
|
|
17
|
+
request: httpx.Request | None = None,
|
|
18
|
+
response: httpx.Response | None = None,
|
|
19
|
+
body: Any | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.request = request
|
|
23
|
+
self.response = response
|
|
24
|
+
self.status_code = response.status_code if response else None
|
|
25
|
+
self.body = body
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class APIStatusError(APIError):
|
|
29
|
+
"""Raised for non-2xx HTTP status codes."""
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AuthenticationError(APIStatusError):
|
|
34
|
+
"""Raised for 401 Unauthorized and 403 Forbidden errors."""
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class NotFoundError(APIStatusError):
|
|
39
|
+
"""Raised for 404 Not Found errors."""
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RateLimitError(APIStatusError):
|
|
44
|
+
"""Raised for 429 Too Many Requests errors."""
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class InternalServerError(APIStatusError):
|
|
49
|
+
"""Raised for 5xx Server Errors."""
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class APIConnectionError(APIError):
|
|
54
|
+
"""Raised when a network connection error occurs (DNS, timeout, connection drop)."""
|
|
55
|
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from typing import Any, TYPE_CHECKING
|
|
2
|
+
from callaider._models import (
|
|
3
|
+
ExternalConversationMessageRequest,
|
|
4
|
+
ExternalConversationMessageResponse,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from callaider._client import AsyncCallaider, Callaider
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AssistantsResource:
|
|
12
|
+
"""Synchronous interface for conversational assistants and bridge integrations."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, client: "Callaider") -> None:
|
|
15
|
+
self._client = client
|
|
16
|
+
|
|
17
|
+
def send_message(
|
|
18
|
+
self,
|
|
19
|
+
assistant_id: str | int,
|
|
20
|
+
*,
|
|
21
|
+
message: str,
|
|
22
|
+
external_conversation_id: str = "conv_default_123",
|
|
23
|
+
metadata: dict[str, Any] | None = None,
|
|
24
|
+
) -> ExternalConversationMessageResponse:
|
|
25
|
+
"""Send a message to an AI assistant and receive a contextual response.
|
|
26
|
+
|
|
27
|
+
:param assistant_id: Unique identifier of the assistant.
|
|
28
|
+
:param message: Text prompt/message from the user.
|
|
29
|
+
:param external_conversation_id: External session ID for conversation state.
|
|
30
|
+
:param metadata: Optional metadata dictionary.
|
|
31
|
+
"""
|
|
32
|
+
request_model = ExternalConversationMessageRequest(
|
|
33
|
+
message=message,
|
|
34
|
+
externalConversationId=external_conversation_id,
|
|
35
|
+
metadata=metadata or {},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
data = self._client._transport.request(
|
|
39
|
+
"POST",
|
|
40
|
+
f"/v1/assistants/{assistant_id}/external-conversations/messages",
|
|
41
|
+
json_data=request_model.model_dump(by_alias=True, exclude_none=True),
|
|
42
|
+
)
|
|
43
|
+
return ExternalConversationMessageResponse.model_validate(data)
|
|
44
|
+
|
|
45
|
+
def get_conversation_state(
|
|
46
|
+
self,
|
|
47
|
+
assistant_id: str | int,
|
|
48
|
+
external_conversation_id: str,
|
|
49
|
+
) -> dict[str, Any]:
|
|
50
|
+
"""Retrieve conversation state by external conversation ID."""
|
|
51
|
+
data = self._client._transport.request(
|
|
52
|
+
"GET",
|
|
53
|
+
f"/v1/assistants/{assistant_id}/external-conversations/{external_conversation_id}/state",
|
|
54
|
+
)
|
|
55
|
+
return data or {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AsyncAssistantsResource:
|
|
59
|
+
"""Asynchronous interface for conversational assistants and bridge integrations."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, client: "AsyncCallaider") -> None:
|
|
62
|
+
self._client = client
|
|
63
|
+
|
|
64
|
+
async def send_message(
|
|
65
|
+
self,
|
|
66
|
+
assistant_id: str | int,
|
|
67
|
+
*,
|
|
68
|
+
message: str,
|
|
69
|
+
external_conversation_id: str = "conv_default_123",
|
|
70
|
+
metadata: dict[str, Any] | None = None,
|
|
71
|
+
) -> ExternalConversationMessageResponse:
|
|
72
|
+
"""Send a message to an AI assistant asynchronously."""
|
|
73
|
+
request_model = ExternalConversationMessageRequest(
|
|
74
|
+
message=message,
|
|
75
|
+
externalConversationId=external_conversation_id,
|
|
76
|
+
metadata=metadata or {},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
data = await self._client._transport.request(
|
|
80
|
+
"POST",
|
|
81
|
+
f"/v1/assistants/{assistant_id}/external-conversations/messages",
|
|
82
|
+
json_data=request_model.model_dump(by_alias=True, exclude_none=True),
|
|
83
|
+
)
|
|
84
|
+
return ExternalConversationMessageResponse.model_validate(data)
|
|
85
|
+
|
|
86
|
+
async def get_conversation_state(
|
|
87
|
+
self,
|
|
88
|
+
assistant_id: str | int,
|
|
89
|
+
external_conversation_id: str,
|
|
90
|
+
) -> dict[str, Any]:
|
|
91
|
+
"""Retrieve conversation state asynchronously."""
|
|
92
|
+
data = await self._client._transport.request(
|
|
93
|
+
"GET",
|
|
94
|
+
f"/v1/assistants/{assistant_id}/external-conversations/{external_conversation_id}/state",
|
|
95
|
+
)
|
|
96
|
+
return data or {}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from typing import Any, TYPE_CHECKING
|
|
2
|
+
from callaider._models import (
|
|
3
|
+
Campaign,
|
|
4
|
+
CampaignRecipient,
|
|
5
|
+
CampaignStatistics,
|
|
6
|
+
CallRecord,
|
|
7
|
+
RecordingInfo,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from callaider._client import AsyncCallaider, Callaider
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RingingResource:
|
|
15
|
+
"""Synchronous client interface for the Ringing (outbound calls) module."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, client: "Callaider") -> None:
|
|
18
|
+
self._client = client
|
|
19
|
+
|
|
20
|
+
def list_campaigns(self) -> list[Campaign]:
|
|
21
|
+
"""Fetch all outbound campaigns."""
|
|
22
|
+
data = self._client._transport.request("GET", "/v1/ringing/campaigns")
|
|
23
|
+
items = data if isinstance(data, list) else data.get("data", [])
|
|
24
|
+
return [Campaign.model_validate(c) for c in items]
|
|
25
|
+
|
|
26
|
+
def create_campaign(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
assistant_id: str,
|
|
30
|
+
recipients: list[CampaignRecipient | dict[str, Any]],
|
|
31
|
+
name: str | None = None,
|
|
32
|
+
) -> Campaign:
|
|
33
|
+
"""Create a new outbound call campaign."""
|
|
34
|
+
parsed_recipients = [
|
|
35
|
+
r.model_dump() if isinstance(r, CampaignRecipient) else r
|
|
36
|
+
for r in recipients
|
|
37
|
+
]
|
|
38
|
+
payload: dict[str, Any] = {
|
|
39
|
+
"assistant_id": assistant_id,
|
|
40
|
+
"recipients": parsed_recipients,
|
|
41
|
+
}
|
|
42
|
+
if name:
|
|
43
|
+
payload["name"] = name
|
|
44
|
+
|
|
45
|
+
data = self._client._transport.request("POST", "/v1/ringing/campaigns", json_data=payload)
|
|
46
|
+
return Campaign.model_validate(data)
|
|
47
|
+
|
|
48
|
+
def get_campaign(self, campaign_id: str | int) -> Campaign:
|
|
49
|
+
"""Retrieve details of a specific campaign."""
|
|
50
|
+
data = self._client._transport.request("GET", f"/v1/ringing/campaigns/{campaign_id}")
|
|
51
|
+
return Campaign.model_validate(data)
|
|
52
|
+
|
|
53
|
+
def delete_campaign(self, campaign_id: str | int) -> None:
|
|
54
|
+
"""Delete a campaign by ID."""
|
|
55
|
+
self._client._transport.request("DELETE", f"/v1/ringing/campaigns/{campaign_id}")
|
|
56
|
+
|
|
57
|
+
def launch_campaign(self, campaign_id: str | int) -> Campaign:
|
|
58
|
+
"""Start or schedule execution of a campaign."""
|
|
59
|
+
data = self._client._transport.request("POST", f"/v1/ringing/campaigns/{campaign_id}/launch")
|
|
60
|
+
return Campaign.model_validate(data)
|
|
61
|
+
|
|
62
|
+
def pause_campaign(self, campaign_id: str | int) -> Campaign:
|
|
63
|
+
"""Pause an active campaign."""
|
|
64
|
+
data = self._client._transport.request("POST", f"/v1/ringing/campaigns/{campaign_id}/pause")
|
|
65
|
+
return Campaign.model_validate(data)
|
|
66
|
+
|
|
67
|
+
def resume_campaign(self, campaign_id: str | int) -> Campaign:
|
|
68
|
+
"""Resume a paused campaign."""
|
|
69
|
+
data = self._client._transport.request("POST", f"/v1/ringing/campaigns/{campaign_id}/resume")
|
|
70
|
+
return Campaign.model_validate(data)
|
|
71
|
+
|
|
72
|
+
def get_campaign_statistics(self, campaign_id: str | int) -> CampaignStatistics:
|
|
73
|
+
"""Retrieve aggregated statistics for a specific campaign."""
|
|
74
|
+
data = self._client._transport.request("GET", f"/v1/ringing/campaigns/{campaign_id}/statistics")
|
|
75
|
+
return CampaignStatistics.model_validate(data)
|
|
76
|
+
|
|
77
|
+
def list_campaign_calls(self, campaign_id: str | int) -> list[CallRecord]:
|
|
78
|
+
"""Fetch all call records belonging to a campaign."""
|
|
79
|
+
data = self._client._transport.request("GET", f"/v1/ringing/campaigns/{campaign_id}/calls")
|
|
80
|
+
items = data if isinstance(data, list) else data.get("data", [])
|
|
81
|
+
return [CallRecord.model_validate(item) for item in items]
|
|
82
|
+
|
|
83
|
+
def get_call(self, call_id: str | int) -> CallRecord:
|
|
84
|
+
"""Retrieve details of an individual call record."""
|
|
85
|
+
data = self._client._transport.request("GET", f"/v1/ringing/calls/{call_id}")
|
|
86
|
+
return CallRecord.model_validate(data)
|
|
87
|
+
|
|
88
|
+
def get_call_recording(self, call_id: str | int) -> RecordingInfo:
|
|
89
|
+
"""Retrieve the audio recording link and metadata for a specific call."""
|
|
90
|
+
data = self._client._transport.request("GET", f"/v1/ringing/calls/{call_id}/recording")
|
|
91
|
+
return RecordingInfo.model_validate(data)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class AsyncRingingResource:
|
|
95
|
+
"""Asynchronous client interface for the Ringing (outbound calls) module."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, client: "AsyncCallaider") -> None:
|
|
98
|
+
self._client = client
|
|
99
|
+
|
|
100
|
+
async def list_campaigns(self) -> list[Campaign]:
|
|
101
|
+
"""Fetch all outbound campaigns asynchronously."""
|
|
102
|
+
data = await self._client._transport.request("GET", "/v1/ringing/campaigns")
|
|
103
|
+
items = data if isinstance(data, list) else data.get("data", [])
|
|
104
|
+
return [Campaign.model_validate(c) for c in items]
|
|
105
|
+
|
|
106
|
+
async def create_campaign(
|
|
107
|
+
self,
|
|
108
|
+
*,
|
|
109
|
+
assistant_id: str,
|
|
110
|
+
recipients: list[CampaignRecipient | dict[str, Any]],
|
|
111
|
+
name: str | None = None,
|
|
112
|
+
) -> Campaign:
|
|
113
|
+
"""Create a new outbound call campaign asynchronously."""
|
|
114
|
+
parsed_recipients = [
|
|
115
|
+
r.model_dump() if isinstance(r, CampaignRecipient) else r
|
|
116
|
+
for r in recipients
|
|
117
|
+
]
|
|
118
|
+
payload: dict[str, Any] = {
|
|
119
|
+
"assistant_id": assistant_id,
|
|
120
|
+
"recipients": parsed_recipients,
|
|
121
|
+
}
|
|
122
|
+
if name:
|
|
123
|
+
payload["name"] = name
|
|
124
|
+
|
|
125
|
+
data = await self._client._transport.request("POST", "/v1/ringing/campaigns", json_data=payload)
|
|
126
|
+
return Campaign.model_validate(data)
|
|
127
|
+
|
|
128
|
+
async def get_campaign(self, campaign_id: str | int) -> Campaign:
|
|
129
|
+
"""Retrieve details of a specific campaign asynchronously."""
|
|
130
|
+
data = await self._client._transport.request("GET", f"/v1/ringing/campaigns/{campaign_id}")
|
|
131
|
+
return Campaign.model_validate(data)
|
|
132
|
+
|
|
133
|
+
async def delete_campaign(self, campaign_id: str | int) -> None:
|
|
134
|
+
"""Delete a campaign by ID asynchronously."""
|
|
135
|
+
await self._client._transport.request("DELETE", f"/v1/ringing/campaigns/{campaign_id}")
|
|
136
|
+
|
|
137
|
+
async def launch_campaign(self, campaign_id: str | int) -> Campaign:
|
|
138
|
+
"""Start or schedule execution of a campaign asynchronously."""
|
|
139
|
+
data = await self._client._transport.request("POST", f"/v1/ringing/campaigns/{campaign_id}/launch")
|
|
140
|
+
return Campaign.model_validate(data)
|
|
141
|
+
|
|
142
|
+
async def pause_campaign(self, campaign_id: str | int) -> Campaign:
|
|
143
|
+
"""Pause an active campaign asynchronously."""
|
|
144
|
+
data = await self._client._transport.request("POST", f"/v1/ringing/campaigns/{campaign_id}/pause")
|
|
145
|
+
return Campaign.model_validate(data)
|
|
146
|
+
|
|
147
|
+
async def resume_campaign(self, campaign_id: str | int) -> Campaign:
|
|
148
|
+
"""Resume a paused campaign asynchronously."""
|
|
149
|
+
data = await self._client._transport.request("POST", f"/v1/ringing/campaigns/{campaign_id}/resume")
|
|
150
|
+
return Campaign.model_validate(data)
|
|
151
|
+
|
|
152
|
+
async def get_campaign_statistics(self, campaign_id: str | int) -> CampaignStatistics:
|
|
153
|
+
"""Retrieve aggregated statistics for a specific campaign asynchronously."""
|
|
154
|
+
data = await self._client._transport.request("GET", f"/v1/ringing/campaigns/{campaign_id}/statistics")
|
|
155
|
+
return CampaignStatistics.model_validate(data)
|
|
156
|
+
|
|
157
|
+
async def list_campaign_calls(self, campaign_id: str | int) -> list[CallRecord]:
|
|
158
|
+
"""Fetch all call records belonging to a campaign asynchronously."""
|
|
159
|
+
data = await self._client._transport.request("GET", f"/v1/ringing/campaigns/{campaign_id}/calls")
|
|
160
|
+
items = data if isinstance(data, list) else data.get("data", [])
|
|
161
|
+
return [CallRecord.model_validate(item) for item in items]
|
|
162
|
+
|
|
163
|
+
async def get_call(self, call_id: str | int) -> CallRecord:
|
|
164
|
+
"""Retrieve details of an individual call record asynchronously."""
|
|
165
|
+
data = await self._client._transport.request("GET", f"/v1/ringing/calls/{call_id}")
|
|
166
|
+
return CallRecord.model_validate(data)
|
|
167
|
+
|
|
168
|
+
async def get_call_recording(self, call_id: str | int) -> RecordingInfo:
|
|
169
|
+
"""Retrieve the audio recording link and metadata for a specific call asynchronously."""
|
|
170
|
+
data = await self._client._transport.request("GET", f"/v1/ringing/calls/{call_id}/recording")
|
|
171
|
+
return RecordingInfo.model_validate(data)
|
callaider/webhooks.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, Union
|
|
3
|
+
from callaider._models import WebhookEvent
|
|
4
|
+
from callaider.exceptions import CallaiderError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class WebhookParsingError(CallaiderError):
|
|
8
|
+
"""Raised when an incoming webhook payload cannot be parsed."""
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Webhooks:
|
|
13
|
+
"""Helper utilities for parsing and verifying Callaider webhooks."""
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def construct_event(
|
|
17
|
+
payload: Union[str, bytes, dict[str, Any]],
|
|
18
|
+
*,
|
|
19
|
+
secret: str | None = None, # Reserved for signature verification if HMAC is used
|
|
20
|
+
) -> WebhookEvent:
|
|
21
|
+
"""Parse raw incoming HTTP payload into a strongly-typed WebhookEvent.
|
|
22
|
+
|
|
23
|
+
:param payload: Raw request body (bytes, str or parsed dict).
|
|
24
|
+
:param secret: Optional webhook signing secret.
|
|
25
|
+
:raises WebhookParsingError: If payload is invalid JSON or does not match schema.
|
|
26
|
+
:return: Validated WebhookEvent object.
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
if isinstance(payload, (bytes, bytearray)):
|
|
30
|
+
data = json.loads(payload.decode("utf-8"))
|
|
31
|
+
elif isinstance(payload, str):
|
|
32
|
+
data = json.loads(payload)
|
|
33
|
+
elif isinstance(payload, dict):
|
|
34
|
+
data = payload
|
|
35
|
+
else:
|
|
36
|
+
raise WebhookParsingError(f"Unsupported payload type: {type(payload)}")
|
|
37
|
+
|
|
38
|
+
return WebhookEvent.model_validate(data)
|
|
39
|
+
except Exception as exc:
|
|
40
|
+
raise WebhookParsingError(f"Failed to parse webhook event: {exc}") from exc
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: callaider
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Callaider AI Voice Platform
|
|
5
|
+
Project-URL: Homepage, https://github.com/astatdeglebantiy/callaider-sdk-python
|
|
6
|
+
Author-email: AstatdeGlebantiy <glebsh2chko@gmail.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: httpx2>=2.12.0
|
|
11
|
+
Requires-Dist: pydantic>=2.0.0
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.23.0
|
|
13
|
+
Requires-Dist: pytest>=8.0.0
|
|
14
|
+
Requires-Dist: respx>=0.21.0
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Callaider Python SDK
|
|
18
|
+
|
|
19
|
+
[](https://pypi.org/project/callaider/)
|
|
20
|
+
[](https://pypi.org/project/callaider/)
|
|
21
|
+
[](https://opensource.org/licenses/MIT)
|
|
22
|
+
|
|
23
|
+
The official Python client library for the [Callaider](https://callaider.ai) AI voice calling and conversational platform.
|
|
24
|
+
|
|
25
|
+
Built on top of the [Callaider OpenAPI 3.0.3 specification](https://github.com/NBM-Labs/callaider_openapi).
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- ⚡ **Sync & Async clients** built on modern `httpx`.
|
|
32
|
+
- 🛡️ **Fully typed** request/response models with Pydantic v2.
|
|
33
|
+
- 🔄 **Automatic retries** with exponential backoff on 429/5xx errors.
|
|
34
|
+
- 📞 **Complete Ringing API support**: Campaigns, batch calls, recordings, and statistics.
|
|
35
|
+
- 🤖 **External Conversation Bridge**: Direct AI assistant messaging integration (ideal for Telegram bots).
|
|
36
|
+
- 🪝 **Built-in Webhooks parser**: Typed events parsing for callback handling.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install callaider
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Or using `uv`:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
uv add callaider
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Quickstart
|
|
55
|
+
|
|
56
|
+
### Synchronous Usage
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from callaider import Callaider
|
|
60
|
+
|
|
61
|
+
client = Callaider(api_key="your_api_key_here")
|
|
62
|
+
|
|
63
|
+
# 1. Check API status
|
|
64
|
+
health = client.health()
|
|
65
|
+
print(f"API status: {health.status}")
|
|
66
|
+
|
|
67
|
+
# 2. Create an automated call campaign
|
|
68
|
+
campaign = client.ringing.create_campaign(
|
|
69
|
+
assistant_id="asst_sales_01",
|
|
70
|
+
recipients=[
|
|
71
|
+
{"phone": "+380501234567", "name": "Alex"}
|
|
72
|
+
],
|
|
73
|
+
name="VIP Outreach Campaign"
|
|
74
|
+
)
|
|
75
|
+
print(f"Created campaign #{campaign.id} with status: {campaign.status}")
|
|
76
|
+
|
|
77
|
+
# 3. Launch the campaign
|
|
78
|
+
client.ringing.launch_campaign(campaign.id)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Asynchronous Usage (FastAPI, Asyncio, Telegram Bots)
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
import asyncio
|
|
85
|
+
from callaider import AsyncCallaider
|
|
86
|
+
|
|
87
|
+
async def main():
|
|
88
|
+
async with AsyncCallaider(api_key="your_api_key_here") as client:
|
|
89
|
+
# Send a message to AI assistant bridge
|
|
90
|
+
response = await client.assistants.send_message(
|
|
91
|
+
assistant_id="asst_support_01",
|
|
92
|
+
external_conversation_id="telegram_user_12345",
|
|
93
|
+
message="Hello! I need help with my order."
|
|
94
|
+
)
|
|
95
|
+
print("AI Response:", response.text)
|
|
96
|
+
|
|
97
|
+
asyncio.run(main())
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Webhooks Handling
|
|
103
|
+
|
|
104
|
+
Easily parse incoming Callaider webhook payloads in FastAPI or Flask:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from fastapi import FastAPI, Request
|
|
108
|
+
from callaider import Webhooks
|
|
109
|
+
|
|
110
|
+
app = FastAPI()
|
|
111
|
+
|
|
112
|
+
@app.post("/webhooks/callaider")
|
|
113
|
+
async def handle_webhook(request: Request):
|
|
114
|
+
payload = await request.body()
|
|
115
|
+
event = Webhooks.construct_event(payload)
|
|
116
|
+
|
|
117
|
+
if event.event == "call.completed":
|
|
118
|
+
print(f"Call {event.data.call_id} finished. Duration: {event.data.duration_seconds}s")
|
|
119
|
+
print(f"Audio recording: {event.data.recording_url}")
|
|
120
|
+
|
|
121
|
+
return {"status": "ok"}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Development & Testing
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
# Clone the repository
|
|
130
|
+
git clone https://github.com/astatdeglebantiy/callaider-python.git
|
|
131
|
+
cd callaider-python
|
|
132
|
+
|
|
133
|
+
# Install dependencies with uv
|
|
134
|
+
uv sync --all-extras
|
|
135
|
+
|
|
136
|
+
# Run unit tests
|
|
137
|
+
uv run pytest
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
callaider/__init__.py,sha256=4CUuEgT4EC6tfI8WAmZPZj7MwvU3r2VPEplv_lPebZ0,600
|
|
2
|
+
callaider/_base_client.py,sha256=HN6ZHkWIJKi4ynnzKGh_G8PkvmCBVAjfdoKCHJnFBM8,7147
|
|
3
|
+
callaider/_client.py,sha256=3nhI8-VLNcnuojZ2pG4bDKWTmtIzcODp7bVM6WPpvYU,3344
|
|
4
|
+
callaider/_constants.py,sha256=ThJNLsxDcoCsqRuS_PclGE0w6euRtqrKMY5u5LA7SOw,134
|
|
5
|
+
callaider/_models.py,sha256=5OeBqB0UukCkJicVh601W00m8-Z2MVoDKurln96wGkQ,4198
|
|
6
|
+
callaider/_streaming.py,sha256=Fg5urQlLBlqPQCmcR1OEMPGE_uaCLToQW5qEqso6Gi4,4099
|
|
7
|
+
callaider/exceptions.py,sha256=4eKAHoo5OuIJd1ybIsss7irxpCs6wSidWaz83dnnOXw,1256
|
|
8
|
+
callaider/webhooks.py,sha256=nzHJNYcS7JOFUgmyrbG-tVG7Is8wH4-LYUHJsYtREuU,1492
|
|
9
|
+
callaider/resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
callaider/resources/assistants.py,sha256=SHGZXWhntMbMT6VEJSkSTU84_ay4wFSVl-Fpjx6GlB4,3443
|
|
11
|
+
callaider/resources/ringing.py,sha256=nOvRiw0UYlDkCVNOyYj3lnj3B7GV0vpG5_zGomi97yY,7937
|
|
12
|
+
callaider-0.1.0.dist-info/METADATA,sha256=ejzmdIoa-y12s2eab2qpwDP-I6tYq5JXX6_QvmSoLsQ,3761
|
|
13
|
+
callaider-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
14
|
+
callaider-0.1.0.dist-info/licenses/LICENSE,sha256=KDKJENJgzihiZEqAmD8Bp59OY9Cyn0fA6toKkninS7g,1061
|
|
15
|
+
callaider-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gleb
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|