dial-sdk 0.6.2__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.
- dial_sdk/__init__.py +58 -0
- dial_sdk/client.py +203 -0
- dial_sdk/events.py +155 -0
- dial_sdk/self_hosted.py +151 -0
- dial_sdk/types.py +224 -0
- dial_sdk-0.6.2.dist-info/METADATA +87 -0
- dial_sdk-0.6.2.dist-info/RECORD +8 -0
- dial_sdk-0.6.2.dist-info/WHEEL +4 -0
dial_sdk/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from .client import DialClient
|
|
2
|
+
from .events import EventsConnection
|
|
3
|
+
from .self_hosted import (
|
|
4
|
+
CallConnected,
|
|
5
|
+
Interrupt,
|
|
6
|
+
PingPong,
|
|
7
|
+
ReminderRequired,
|
|
8
|
+
Response,
|
|
9
|
+
ResponseRequired,
|
|
10
|
+
TranscriptItem,
|
|
11
|
+
TranscriptUpdate,
|
|
12
|
+
parse_dial_message,
|
|
13
|
+
serialize_server_message,
|
|
14
|
+
verify_dial_signature,
|
|
15
|
+
)
|
|
16
|
+
from .types import (
|
|
17
|
+
Call,
|
|
18
|
+
CallEvent,
|
|
19
|
+
CallStatus,
|
|
20
|
+
CallTranscribed,
|
|
21
|
+
DialConfig,
|
|
22
|
+
DialEvent,
|
|
23
|
+
MakeCallParams,
|
|
24
|
+
Message,
|
|
25
|
+
MessageEvent,
|
|
26
|
+
PhoneNumber,
|
|
27
|
+
PurchaseNumberParams,
|
|
28
|
+
SendMessageParams,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"DialClient",
|
|
33
|
+
"EventsConnection",
|
|
34
|
+
"DialConfig",
|
|
35
|
+
"PurchaseNumberParams",
|
|
36
|
+
"SendMessageParams",
|
|
37
|
+
"MakeCallParams",
|
|
38
|
+
"PhoneNumber",
|
|
39
|
+
"Message",
|
|
40
|
+
"Call",
|
|
41
|
+
"CallStatus",
|
|
42
|
+
"CallEvent",
|
|
43
|
+
"MessageEvent",
|
|
44
|
+
"CallTranscribed",
|
|
45
|
+
"DialEvent",
|
|
46
|
+
# Self-Hosted protocol
|
|
47
|
+
"TranscriptItem",
|
|
48
|
+
"CallConnected",
|
|
49
|
+
"TranscriptUpdate",
|
|
50
|
+
"ResponseRequired",
|
|
51
|
+
"ReminderRequired",
|
|
52
|
+
"Response",
|
|
53
|
+
"Interrupt",
|
|
54
|
+
"PingPong",
|
|
55
|
+
"parse_dial_message",
|
|
56
|
+
"serialize_server_message",
|
|
57
|
+
"verify_dial_signature",
|
|
58
|
+
]
|
dial_sdk/client.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
from typing import Any, Optional
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from .events import EventsConnection
|
|
6
|
+
from .types import (
|
|
7
|
+
Call,
|
|
8
|
+
DialConfig,
|
|
9
|
+
MakeCallParams,
|
|
10
|
+
Message,
|
|
11
|
+
PhoneNumber,
|
|
12
|
+
PurchaseNumberParams,
|
|
13
|
+
SendMessageParams,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = "https://getdial.ai"
|
|
17
|
+
|
|
18
|
+
# Sentinel distinguishing "argument not provided" from None (which clears the field).
|
|
19
|
+
_UNSET: Any = object()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DialClient:
|
|
23
|
+
def __init__(self, config: DialConfig):
|
|
24
|
+
self._base_url = config.base_url or DEFAULT_BASE_URL
|
|
25
|
+
self._api_key = config.api_key
|
|
26
|
+
self._client = httpx.AsyncClient(
|
|
27
|
+
base_url=self._base_url,
|
|
28
|
+
headers={
|
|
29
|
+
"Authorization": f"Bearer {config.api_key}",
|
|
30
|
+
"Content-Type": "application/json",
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
async def _request(
|
|
35
|
+
self,
|
|
36
|
+
method: str,
|
|
37
|
+
path: str,
|
|
38
|
+
body: Optional[dict] = None,
|
|
39
|
+
params: Optional[dict] = None,
|
|
40
|
+
headers: Optional[dict] = None,
|
|
41
|
+
):
|
|
42
|
+
response = await self._client.request(method, path, json=body, params=params, headers=headers)
|
|
43
|
+
if response.is_error:
|
|
44
|
+
raise Exception(f"Dial API error {response.status_code}: {response.text}")
|
|
45
|
+
return response.json()
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def _filters(number_id, direction, since) -> dict:
|
|
49
|
+
return {
|
|
50
|
+
k: v
|
|
51
|
+
for k, v in {"numberId": number_id, "direction": direction, "since": since}.items()
|
|
52
|
+
if v is not None
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# ── Numbers ──────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
async def list_numbers(self) -> list[PhoneNumber]:
|
|
58
|
+
"""`dial number list` — list the account's phone numbers."""
|
|
59
|
+
data = await self._request("GET", "/api/v1/numbers")
|
|
60
|
+
return [PhoneNumber.from_api(n) for n in data["numbers"]]
|
|
61
|
+
|
|
62
|
+
async def purchase_number(self, params: PurchaseNumberParams) -> PhoneNumber:
|
|
63
|
+
"""`dial number purchase` — provision a new phone number."""
|
|
64
|
+
body: dict = {
|
|
65
|
+
"inboundInstruction": params.inbound_instruction,
|
|
66
|
+
"country": params.country,
|
|
67
|
+
}
|
|
68
|
+
if params.area_code:
|
|
69
|
+
body["areaCode"] = params.area_code
|
|
70
|
+
data = await self._request("POST", "/api/v1/numbers", body)
|
|
71
|
+
return PhoneNumber.from_api(data["number"])
|
|
72
|
+
|
|
73
|
+
async def set_number_properties(
|
|
74
|
+
self,
|
|
75
|
+
number_id: str,
|
|
76
|
+
*,
|
|
77
|
+
inbound_instruction: Optional[str] = None,
|
|
78
|
+
nickname: Optional[str] = _UNSET,
|
|
79
|
+
) -> PhoneNumber:
|
|
80
|
+
"""`dial number set` — update a number's properties (any subset; at least one).
|
|
81
|
+
|
|
82
|
+
``nickname=None`` or ``nickname=""`` clears the nickname; omit the
|
|
83
|
+
argument to leave it unchanged.
|
|
84
|
+
"""
|
|
85
|
+
body: dict = {}
|
|
86
|
+
if inbound_instruction is not None:
|
|
87
|
+
body["inboundInstruction"] = inbound_instruction
|
|
88
|
+
if nickname is not _UNSET:
|
|
89
|
+
body["nickname"] = nickname
|
|
90
|
+
if not body:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
"Provide at least one property to update (inbound_instruction or nickname)."
|
|
93
|
+
)
|
|
94
|
+
data = await self._request("PATCH", f"/api/v1/numbers/{number_id}", body)
|
|
95
|
+
return PhoneNumber.from_api(data["number"])
|
|
96
|
+
|
|
97
|
+
async def set_inbound_instruction(
|
|
98
|
+
self, number_id: str, inbound_instruction: str
|
|
99
|
+
) -> PhoneNumber:
|
|
100
|
+
"""Deprecated: use :meth:`set_number_properties` instead."""
|
|
101
|
+
return await self.set_number_properties(
|
|
102
|
+
number_id, inbound_instruction=inbound_instruction
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# ── Messages ─────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
async def send_message(self, params: SendMessageParams) -> Message:
|
|
108
|
+
"""`dial message` — send an SMS."""
|
|
109
|
+
data = await self._request(
|
|
110
|
+
"POST",
|
|
111
|
+
"/api/v1/messages",
|
|
112
|
+
{
|
|
113
|
+
"to": params.to,
|
|
114
|
+
"fromNumberId": params.from_number_id,
|
|
115
|
+
"body": params.body,
|
|
116
|
+
"channel": params.channel,
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
return Message.from_api(data["message"])
|
|
120
|
+
|
|
121
|
+
async def list_messages(
|
|
122
|
+
self,
|
|
123
|
+
*,
|
|
124
|
+
number_id: Optional[str] = None,
|
|
125
|
+
direction: Optional[str] = None,
|
|
126
|
+
since: Optional[str] = None,
|
|
127
|
+
) -> list[Message]:
|
|
128
|
+
"""`dial message list` — list messages, optionally filtered."""
|
|
129
|
+
data = await self._request(
|
|
130
|
+
"GET", "/api/v1/messages", params=self._filters(number_id, direction, since)
|
|
131
|
+
)
|
|
132
|
+
return [Message.from_api(m) for m in data["messages"]]
|
|
133
|
+
|
|
134
|
+
# ── Calls ────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
async def make_call(self, params: MakeCallParams) -> Call:
|
|
137
|
+
"""`dial call` — place an outbound AI voice call."""
|
|
138
|
+
body = {
|
|
139
|
+
"to": params.to,
|
|
140
|
+
"fromNumberId": params.from_number_id,
|
|
141
|
+
"outboundInstruction": params.outbound_instruction,
|
|
142
|
+
}
|
|
143
|
+
# Omitted → the server auto-detects from the destination number's country.
|
|
144
|
+
if params.language is not None:
|
|
145
|
+
body["language"] = params.language
|
|
146
|
+
# Same key across retries → the server returns the already-placed call.
|
|
147
|
+
headers = (
|
|
148
|
+
{"Idempotency-Key": params.idempotency_key}
|
|
149
|
+
if params.idempotency_key is not None
|
|
150
|
+
else None
|
|
151
|
+
)
|
|
152
|
+
data = await self._request("POST", "/api/v1/calls", body, headers=headers)
|
|
153
|
+
return Call.from_api(data["call"])
|
|
154
|
+
|
|
155
|
+
async def list_calls(
|
|
156
|
+
self,
|
|
157
|
+
*,
|
|
158
|
+
number_id: Optional[str] = None,
|
|
159
|
+
direction: Optional[str] = None,
|
|
160
|
+
since: Optional[str] = None,
|
|
161
|
+
) -> list[Call]:
|
|
162
|
+
"""`dial call list` — list calls, optionally filtered."""
|
|
163
|
+
data = await self._request(
|
|
164
|
+
"GET", "/api/v1/calls", params=self._filters(number_id, direction, since)
|
|
165
|
+
)
|
|
166
|
+
return [Call.from_api(c) for c in data["calls"]]
|
|
167
|
+
|
|
168
|
+
async def get_call(self, call_id: str) -> Call:
|
|
169
|
+
"""`dial call get <id>` — fetch a single call by id."""
|
|
170
|
+
data = await self._request("GET", f"/api/v1/calls/{call_id}")
|
|
171
|
+
return Call.from_api(data["call"])
|
|
172
|
+
|
|
173
|
+
# ── Events (wait-for) ────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
def new_events_connection(self) -> EventsConnection:
|
|
176
|
+
"""Open a long-lived connection to the account's event stream.
|
|
177
|
+
|
|
178
|
+
SDK equivalent of ``dial wait-for``: rather than returning a single
|
|
179
|
+
event, it yields every ``message.received`` / ``call.ended`` /
|
|
180
|
+
``call.transcribed`` event on the account channel. The PubNub token is
|
|
181
|
+
re-minted automatically before it expires. Every event shares one
|
|
182
|
+
envelope; read values from ``event["data"]``. No I/O happens until the
|
|
183
|
+
connection is entered::
|
|
184
|
+
|
|
185
|
+
async with client.new_events_connection() as conn:
|
|
186
|
+
async for event in conn:
|
|
187
|
+
if event["type"] == "message.received":
|
|
188
|
+
print(event["data"]["from"], event["data"]["body"])
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
async def _mint() -> dict:
|
|
192
|
+
return await self._request("POST", "/api/v1/listen/subscribe")
|
|
193
|
+
|
|
194
|
+
return EventsConnection(_mint)
|
|
195
|
+
|
|
196
|
+
async def close(self):
|
|
197
|
+
await self._client.aclose()
|
|
198
|
+
|
|
199
|
+
async def __aenter__(self) -> "DialClient":
|
|
200
|
+
return self
|
|
201
|
+
|
|
202
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
203
|
+
await self.close()
|
dial_sdk/events.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import secrets
|
|
3
|
+
from typing import AsyncIterator, Awaitable, Callable, Optional
|
|
4
|
+
|
|
5
|
+
from pubnub.callbacks import SubscribeCallback
|
|
6
|
+
from pubnub.pnconfiguration import PNConfiguration
|
|
7
|
+
from pubnub.pubnub_asyncio import PubNubAsyncio
|
|
8
|
+
|
|
9
|
+
from .types import DialEvent
|
|
10
|
+
|
|
11
|
+
# A coroutine that calls POST /api/v1/listen/subscribe and returns the grant dict
|
|
12
|
+
# ({ "subscribeKey", "channel", "token", "ttlSeconds" }). Called on open and again
|
|
13
|
+
# before each TTL expiry.
|
|
14
|
+
MintGrant = Callable[[], Awaitable[dict]]
|
|
15
|
+
|
|
16
|
+
# Renew the token at this fraction of its TTL, leaving headroom before expiry.
|
|
17
|
+
_RENEW_FRACTION = 0.8
|
|
18
|
+
# On a failed renewal, retry this soon (seconds) rather than waiting a full TTL.
|
|
19
|
+
_RENEW_RETRY_SECONDS = 5.0
|
|
20
|
+
|
|
21
|
+
# Sentinel pushed onto the queue by close() to wake a parked consumer.
|
|
22
|
+
_CLOSED = object()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _QueueListener(SubscribeCallback):
|
|
26
|
+
"""Funnels PubNub messages on our channel into an asyncio.Queue."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, queue: "asyncio.Queue", channel: str):
|
|
29
|
+
self._queue = queue
|
|
30
|
+
self._channel = channel
|
|
31
|
+
|
|
32
|
+
def message(self, pubnub, message):
|
|
33
|
+
if message.channel == self._channel:
|
|
34
|
+
self._queue.put_nowait(message.message)
|
|
35
|
+
|
|
36
|
+
def presence(self, pubnub, presence):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
def status(self, pubnub, status):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class EventsConnection:
|
|
44
|
+
"""A long-lived subscription to the account's event stream, consumed as an
|
|
45
|
+
async iterator of event dicts.
|
|
46
|
+
|
|
47
|
+
This is the SDK's idiom for what the CLI does one-shot with ``dial wait-for``:
|
|
48
|
+
instead of returning a single event, it yields every ``message.received`` /
|
|
49
|
+
``call.ended`` event on the account channel until the consumer stops iterating
|
|
50
|
+
or the connection is closed. The PubNub token is silently re-minted before it
|
|
51
|
+
expires, so a connection can stay open indefinitely. PubNub specifics
|
|
52
|
+
(subscribe key, channel, token, TTL) are never surfaced to the caller.
|
|
53
|
+
|
|
54
|
+
Usage::
|
|
55
|
+
|
|
56
|
+
async with client.new_events_connection() as conn:
|
|
57
|
+
async for event in conn:
|
|
58
|
+
handle(event)
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, mint: MintGrant):
|
|
62
|
+
self._mint = mint
|
|
63
|
+
self._pubnub: Optional[PubNubAsyncio] = None
|
|
64
|
+
self._listener: Optional[_QueueListener] = None
|
|
65
|
+
self._channel = ""
|
|
66
|
+
self._queue: "asyncio.Queue" = asyncio.Queue()
|
|
67
|
+
self._renew_task: Optional["asyncio.Task"] = None
|
|
68
|
+
self._opened = False
|
|
69
|
+
self._closed = False
|
|
70
|
+
|
|
71
|
+
async def open(self) -> "EventsConnection":
|
|
72
|
+
"""Mint the first token and establish the subscription. Idempotent."""
|
|
73
|
+
if self._opened:
|
|
74
|
+
return self
|
|
75
|
+
self._opened = True
|
|
76
|
+
|
|
77
|
+
grant = await self._mint()
|
|
78
|
+
self._channel = grant["channel"]
|
|
79
|
+
|
|
80
|
+
config = PNConfiguration()
|
|
81
|
+
config.subscribe_key = grant["subscribeKey"]
|
|
82
|
+
config.user_id = f"dial-sdk-{secrets.token_hex(8)}"
|
|
83
|
+
self._pubnub = PubNubAsyncio(config)
|
|
84
|
+
self._pubnub.set_token(grant["token"])
|
|
85
|
+
|
|
86
|
+
self._listener = _QueueListener(self._queue, self._channel)
|
|
87
|
+
self._pubnub.add_listener(self._listener)
|
|
88
|
+
self._pubnub.subscribe().channels([self._channel]).execute()
|
|
89
|
+
|
|
90
|
+
self._renew_task = asyncio.create_task(self._renew_loop(grant["ttlSeconds"]))
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
async def _renew_loop(self, ttl_seconds: float) -> None:
|
|
94
|
+
try:
|
|
95
|
+
while not self._closed:
|
|
96
|
+
await asyncio.sleep(max(1.0, ttl_seconds * _RENEW_FRACTION))
|
|
97
|
+
if self._closed:
|
|
98
|
+
return
|
|
99
|
+
try:
|
|
100
|
+
grant = await self._mint()
|
|
101
|
+
if self._pubnub:
|
|
102
|
+
self._pubnub.set_token(grant["token"])
|
|
103
|
+
ttl_seconds = grant["ttlSeconds"]
|
|
104
|
+
except Exception:
|
|
105
|
+
# Transient mint failure — keep the (still-valid) token, retry soon.
|
|
106
|
+
await asyncio.sleep(_RENEW_RETRY_SECONDS)
|
|
107
|
+
except asyncio.CancelledError:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
def __aiter__(self) -> AsyncIterator[DialEvent]:
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
async def __anext__(self) -> DialEvent:
|
|
114
|
+
if self._closed and self._queue.empty():
|
|
115
|
+
raise StopAsyncIteration
|
|
116
|
+
item = await self._queue.get()
|
|
117
|
+
if item is _CLOSED:
|
|
118
|
+
raise StopAsyncIteration
|
|
119
|
+
return item
|
|
120
|
+
|
|
121
|
+
async def close(self) -> None:
|
|
122
|
+
"""Tear down the subscription and end iteration. Idempotent."""
|
|
123
|
+
if self._closed:
|
|
124
|
+
return
|
|
125
|
+
self._closed = True
|
|
126
|
+
|
|
127
|
+
if self._renew_task:
|
|
128
|
+
self._renew_task.cancel()
|
|
129
|
+
try:
|
|
130
|
+
await self._renew_task
|
|
131
|
+
except (asyncio.CancelledError, Exception):
|
|
132
|
+
pass
|
|
133
|
+
self._renew_task = None
|
|
134
|
+
|
|
135
|
+
if self._pubnub:
|
|
136
|
+
if self._listener:
|
|
137
|
+
self._pubnub.remove_listener(self._listener)
|
|
138
|
+
try:
|
|
139
|
+
self._pubnub.unsubscribe_all()
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
try:
|
|
143
|
+
await self._pubnub.stop()
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
self._pubnub = None
|
|
147
|
+
|
|
148
|
+
# Wake any consumer parked in __anext__.
|
|
149
|
+
self._queue.put_nowait(_CLOSED)
|
|
150
|
+
|
|
151
|
+
async def __aenter__(self) -> "EventsConnection":
|
|
152
|
+
return await self.open()
|
|
153
|
+
|
|
154
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
155
|
+
await self.close()
|
dial_sdk/self_hosted.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Self-Hosted protocol — the WebSocket contract your server speaks when Dial
|
|
2
|
+
drives a call through your own LLM.
|
|
3
|
+
|
|
4
|
+
These pydantic models are the executable form of
|
|
5
|
+
https://docs.getdial.ai/api-reference/self-hosted-protocol, plus
|
|
6
|
+
``verify_dial_signature`` for the ``X-Dial-Signature`` header. Configuration of
|
|
7
|
+
Self-Hosted mode happens in the dashboard / REST API — this module is purely the
|
|
8
|
+
protocol types + signature verification for building a conformant server.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import hmac
|
|
15
|
+
import time
|
|
16
|
+
from typing import Annotated, Literal, Optional, Union
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
|
|
19
|
+
|
|
20
|
+
Role = Literal["agent", "user"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TranscriptItem(BaseModel):
|
|
24
|
+
role: Role
|
|
25
|
+
content: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# --- Dial -> your server ----------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CallConnected(BaseModel):
|
|
32
|
+
# Sent when Dial connects — once per connection, so you also get it on a
|
|
33
|
+
# reconnect (Retell auto_reconnect). `from` is a keyword → `from_` + alias.
|
|
34
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
35
|
+
type: Literal["call_connected"]
|
|
36
|
+
call_id: str
|
|
37
|
+
direction: Literal["inbound", "outbound"]
|
|
38
|
+
from_: str = Field(alias="from")
|
|
39
|
+
to: str
|
|
40
|
+
# The instruction Dial ran the call with (system_prompt + general context).
|
|
41
|
+
instruction: Optional[str] = None
|
|
42
|
+
# The call's primary BCP-47 language (e.g. en-US, he-IL).
|
|
43
|
+
language: Optional[str] = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TranscriptUpdate(BaseModel):
|
|
47
|
+
type: Literal["transcript_update"]
|
|
48
|
+
transcript: list[TranscriptItem]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ResponseRequired(BaseModel):
|
|
52
|
+
type: Literal["response_required"]
|
|
53
|
+
response_id: int
|
|
54
|
+
transcript: list[TranscriptItem]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ReminderRequired(BaseModel):
|
|
58
|
+
type: Literal["reminder_required"]
|
|
59
|
+
response_id: int
|
|
60
|
+
transcript: list[TranscriptItem]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class PingPong(BaseModel):
|
|
64
|
+
# Keepalive, sent both directions. Dial pings ~every 2s; echo it back.
|
|
65
|
+
type: Literal["ping_pong"]
|
|
66
|
+
timestamp: int
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
DialServerMessage = Annotated[
|
|
70
|
+
Union[CallConnected, TranscriptUpdate, ResponseRequired, ReminderRequired, PingPong],
|
|
71
|
+
Field(discriminator="type"),
|
|
72
|
+
]
|
|
73
|
+
_dial_server_adapter: TypeAdapter[DialServerMessage] = TypeAdapter(DialServerMessage)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_dial_message(raw: Union[str, bytes, dict]) -> DialServerMessage:
|
|
77
|
+
"""Parse + validate a frame received from Dial. Raises ``ValidationError``."""
|
|
78
|
+
if isinstance(raw, (str, bytes, bytearray)):
|
|
79
|
+
return _dial_server_adapter.validate_json(raw)
|
|
80
|
+
return _dial_server_adapter.validate_python(raw)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# --- your server -> Dial ----------------------------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Response(BaseModel):
|
|
87
|
+
type: Literal["response"]
|
|
88
|
+
response_id: int
|
|
89
|
+
content: str
|
|
90
|
+
content_complete: bool
|
|
91
|
+
end_call: Optional[bool] = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Interrupt(BaseModel):
|
|
95
|
+
type: Literal["interrupt"]
|
|
96
|
+
content: str
|
|
97
|
+
content_complete: bool
|
|
98
|
+
end_call: Optional[bool] = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
ServerDialMessage = Union[Response, Interrupt, PingPong]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def serialize_server_message(message: ServerDialMessage) -> str:
|
|
105
|
+
"""Validate + serialize an outbound frame to a JSON string (omitting unset
|
|
106
|
+
``end_call``)."""
|
|
107
|
+
return message.model_dump_json(exclude_none=True)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# --- signature verification -------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def verify_dial_signature(
|
|
114
|
+
secret: str,
|
|
115
|
+
header: str,
|
|
116
|
+
data: str,
|
|
117
|
+
*,
|
|
118
|
+
now: Optional[int] = None,
|
|
119
|
+
tolerance_seconds: int = 300,
|
|
120
|
+
) -> bool:
|
|
121
|
+
"""Verify a Dial ``X-Dial-Signature: t=<unix_seconds>,v1=<hex>`` header.
|
|
122
|
+
|
|
123
|
+
The signature is ``HMAC-SHA256(secret, "<t>.<data>")``, where ``data`` is
|
|
124
|
+
the value the signature covers — the ``call_id`` for the self-hosted
|
|
125
|
+
WebSocket protocol, or the raw request body for a webhook delivery. Returns
|
|
126
|
+
``False`` (never raises) on a malformed header, a stale/future timestamp, or
|
|
127
|
+
a mismatch. Matches Dial's server-side signer byte-for-byte.
|
|
128
|
+
"""
|
|
129
|
+
if not isinstance(header, str):
|
|
130
|
+
return False
|
|
131
|
+
t: Optional[int] = None
|
|
132
|
+
v1: Optional[str] = None
|
|
133
|
+
for part in header.split(","):
|
|
134
|
+
key, _, value = part.partition("=")
|
|
135
|
+
if key == "t":
|
|
136
|
+
try:
|
|
137
|
+
t = int(value)
|
|
138
|
+
except ValueError:
|
|
139
|
+
return False
|
|
140
|
+
elif key == "v1":
|
|
141
|
+
v1 = value
|
|
142
|
+
if t is None or not v1:
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
if now is None:
|
|
146
|
+
now = int(time.time())
|
|
147
|
+
if abs(now - t) > tolerance_seconds:
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
expected = hmac.new(secret.encode(), f"{t}.{data}".encode(), hashlib.sha256).hexdigest()
|
|
151
|
+
return hmac.compare_digest(expected, v1)
|
dial_sdk/types.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Literal, Optional, TypedDict, Union
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class DialConfig:
|
|
7
|
+
api_key: str
|
|
8
|
+
base_url: str = "https://getdial.ai"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ── Request params ──────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class PurchaseNumberParams:
|
|
16
|
+
inbound_instruction: str # system prompt for inbound calls to this number
|
|
17
|
+
country: str = "US" # ISO-3166 alpha-2
|
|
18
|
+
area_code: Optional[str] = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class SendMessageParams:
|
|
23
|
+
to: str
|
|
24
|
+
from_number_id: str
|
|
25
|
+
body: str
|
|
26
|
+
channel: str = "sms"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class MakeCallParams:
|
|
31
|
+
to: str
|
|
32
|
+
from_number_id: str
|
|
33
|
+
outbound_instruction: str # system prompt for the AI voice agent during this call
|
|
34
|
+
# BCP-47, e.g. "en-US". None → the server auto-detects from the destination
|
|
35
|
+
# number's country prefix and the agent handles both it and en-US.
|
|
36
|
+
language: Optional[str] = None
|
|
37
|
+
# Unique key (e.g. a UUID) making the placement idempotent. Pass the same
|
|
38
|
+
# key when retrying a failed or ambiguous make_call and the server returns
|
|
39
|
+
# the already-placed call instead of dialing again.
|
|
40
|
+
idempotency_key: Optional[str] = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── Resources ───────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class PhoneNumber:
|
|
48
|
+
id: str
|
|
49
|
+
number: str
|
|
50
|
+
sid: str
|
|
51
|
+
nickname: Optional[str] # human-readable label; None when unset
|
|
52
|
+
country: str
|
|
53
|
+
capabilities: str
|
|
54
|
+
inbound_instruction: Optional[str] # system prompt for inbound calls; None for legacy numbers
|
|
55
|
+
account_id: str
|
|
56
|
+
created_at: str
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_api(cls, d: dict) -> "PhoneNumber":
|
|
60
|
+
return cls(
|
|
61
|
+
id=d["id"],
|
|
62
|
+
number=d["number"],
|
|
63
|
+
sid=d["sid"],
|
|
64
|
+
nickname=d.get("nickname"),
|
|
65
|
+
country=d["country"],
|
|
66
|
+
capabilities=d["capabilities"],
|
|
67
|
+
inbound_instruction=d.get("inboundInstruction"),
|
|
68
|
+
account_id=d["accountId"],
|
|
69
|
+
created_at=d["createdAt"],
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class Message:
|
|
75
|
+
id: str
|
|
76
|
+
sid: str
|
|
77
|
+
from_: str
|
|
78
|
+
to: str
|
|
79
|
+
body: str
|
|
80
|
+
direction: str
|
|
81
|
+
channel: str
|
|
82
|
+
status: str
|
|
83
|
+
phone_number_id: str
|
|
84
|
+
created_at: str
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_api(cls, d: dict) -> "Message":
|
|
88
|
+
return cls(
|
|
89
|
+
id=d["id"],
|
|
90
|
+
sid=d["sid"],
|
|
91
|
+
from_=d["from"],
|
|
92
|
+
to=d["to"],
|
|
93
|
+
body=d["body"],
|
|
94
|
+
direction=d["direction"],
|
|
95
|
+
channel=d["channel"],
|
|
96
|
+
status=d["status"],
|
|
97
|
+
phone_number_id=d["phoneNumberId"],
|
|
98
|
+
created_at=d["createdAt"],
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class CallStatus(TypedDict):
|
|
103
|
+
"""Server-derived, client-facing call status (camelCase, as returned)."""
|
|
104
|
+
|
|
105
|
+
state: str # "Queued" | "Ringing" | "In-Progress" | "Terminated" | "Unknown"
|
|
106
|
+
terminationType: Optional[str]
|
|
107
|
+
cancelRequested: bool
|
|
108
|
+
cancelPending: bool
|
|
109
|
+
label: str
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class Call:
|
|
114
|
+
id: str
|
|
115
|
+
phone_number_id: str
|
|
116
|
+
from_: str
|
|
117
|
+
to: str
|
|
118
|
+
direction: str
|
|
119
|
+
duration: int
|
|
120
|
+
transcript: Optional[str]
|
|
121
|
+
instruction: Optional[str] # the system prompt the agent ran with (outbound or inbound snapshot)
|
|
122
|
+
queued_at: Optional[str]
|
|
123
|
+
started_ringing_at: Optional[str]
|
|
124
|
+
call_started_at: Optional[str]
|
|
125
|
+
cancel_requested_at: Optional[str]
|
|
126
|
+
terminated_at: Optional[str]
|
|
127
|
+
termination_type: Optional[str]
|
|
128
|
+
status: CallStatus
|
|
129
|
+
created_at: str
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def from_api(cls, d: dict) -> "Call":
|
|
133
|
+
return cls(
|
|
134
|
+
id=d["id"],
|
|
135
|
+
phone_number_id=d["phoneNumberId"],
|
|
136
|
+
from_=d["from"],
|
|
137
|
+
to=d["to"],
|
|
138
|
+
direction=d["direction"],
|
|
139
|
+
duration=d["duration"],
|
|
140
|
+
transcript=d.get("transcript"),
|
|
141
|
+
instruction=d.get("instruction"),
|
|
142
|
+
queued_at=d.get("queuedAt"),
|
|
143
|
+
started_ringing_at=d.get("startedRingingAt"),
|
|
144
|
+
call_started_at=d.get("callStartedAt"),
|
|
145
|
+
cancel_requested_at=d.get("cancelRequestedAt"),
|
|
146
|
+
terminated_at=d.get("terminatedAt"),
|
|
147
|
+
termination_type=d.get("terminationType"),
|
|
148
|
+
status=d["status"],
|
|
149
|
+
created_at=d["createdAt"],
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ── Events (account stream payloads, raw camelCase JSON) ────────
|
|
154
|
+
# Every event shares one envelope; `type` selects the shape of `data`. These
|
|
155
|
+
# TypedDicts are advisory — events are yielded as the raw dicts the backend
|
|
156
|
+
# publishes (not transformed), so read values from event["data"][...].
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class RelatedObject(TypedDict):
|
|
160
|
+
id: str
|
|
161
|
+
type: Literal["call", "message"]
|
|
162
|
+
url: Optional[str]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class CallEndedData(TypedDict):
|
|
166
|
+
callId: str
|
|
167
|
+
from_: str # NOTE: actual key on the wire is "from"
|
|
168
|
+
to: str
|
|
169
|
+
direction: str
|
|
170
|
+
durationSeconds: Optional[int]
|
|
171
|
+
status: str
|
|
172
|
+
canceled: bool
|
|
173
|
+
transcriptAvailable: bool
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class CallEnded(TypedDict):
|
|
177
|
+
id: str
|
|
178
|
+
object: Literal["event"]
|
|
179
|
+
type: Literal["call.ended"]
|
|
180
|
+
version: int
|
|
181
|
+
createdAt: str
|
|
182
|
+
relatedObject: Optional[RelatedObject]
|
|
183
|
+
data: CallEndedData
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class MessageReceivedData(TypedDict):
|
|
187
|
+
messageId: str
|
|
188
|
+
from_: str # NOTE: actual key on the wire is "from"
|
|
189
|
+
to: str
|
|
190
|
+
channel: str
|
|
191
|
+
body: str
|
|
192
|
+
source: str # "external" | "internal"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class MessageReceived(TypedDict):
|
|
196
|
+
id: str
|
|
197
|
+
object: Literal["event"]
|
|
198
|
+
type: Literal["message.received"]
|
|
199
|
+
version: int
|
|
200
|
+
createdAt: str
|
|
201
|
+
relatedObject: Optional[RelatedObject]
|
|
202
|
+
data: MessageReceivedData
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class CallTranscribedData(TypedDict):
|
|
206
|
+
callId: str
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class CallTranscribed(TypedDict):
|
|
210
|
+
id: str
|
|
211
|
+
object: Literal["event"]
|
|
212
|
+
type: Literal["call.transcribed"]
|
|
213
|
+
version: int
|
|
214
|
+
createdAt: str
|
|
215
|
+
relatedObject: Optional[RelatedObject]
|
|
216
|
+
data: CallTranscribedData
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# Keep the public names CallEvent / MessageEvent for back-compat with imports.
|
|
220
|
+
CallEvent = CallEnded
|
|
221
|
+
MessageEvent = MessageReceived
|
|
222
|
+
|
|
223
|
+
# Events are yielded as the raw dicts published by the backend (not transformed).
|
|
224
|
+
DialEvent = Union[CallEnded, MessageReceived, CallTranscribed, dict]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dial-sdk
|
|
3
|
+
Version: 0.6.2
|
|
4
|
+
Summary: Official Dial SDK — phone numbers, SMS, WhatsApp, and voice calls for AI agents
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: httpx>=0.27
|
|
7
|
+
Requires-Dist: pubnub>=7
|
|
8
|
+
Requires-Dist: pydantic>=2
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# dial-sdk
|
|
12
|
+
|
|
13
|
+
Official Python SDK for [Dial](https://getdial.ai) — phone numbers, SMS, WhatsApp, and voice calls for AI agents.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install dial-sdk
|
|
19
|
+
# or
|
|
20
|
+
uv add dial-sdk
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Requires Python 3.11+.
|
|
24
|
+
|
|
25
|
+
## Quickstart
|
|
26
|
+
|
|
27
|
+
The client is async. Construct it with a `DialConfig`, then call its methods inside an `async with` block:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import asyncio
|
|
31
|
+
from dial_sdk import DialClient, DialConfig, SendMessageParams, MakeCallParams
|
|
32
|
+
|
|
33
|
+
async def main():
|
|
34
|
+
async with DialClient(DialConfig(api_key="sk_live_...")) as dial:
|
|
35
|
+
# List your numbers
|
|
36
|
+
numbers = await dial.list_numbers()
|
|
37
|
+
from_id = numbers[0].id
|
|
38
|
+
|
|
39
|
+
# Send an SMS
|
|
40
|
+
await dial.send_message(SendMessageParams(
|
|
41
|
+
to="+15551234567",
|
|
42
|
+
from_number_id=from_id,
|
|
43
|
+
body="Hello from Dial",
|
|
44
|
+
))
|
|
45
|
+
|
|
46
|
+
# Place an AI voice call
|
|
47
|
+
call = await dial.make_call(MakeCallParams(
|
|
48
|
+
to="+15551234567",
|
|
49
|
+
from_number_id=from_id,
|
|
50
|
+
outbound_instruction="You are a friendly assistant confirming an appointment.",
|
|
51
|
+
))
|
|
52
|
+
print(call.id, call.status)
|
|
53
|
+
|
|
54
|
+
asyncio.run(main())
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`DialConfig.base_url` defaults to `https://getdial.ai`. Override it for local or self-hosted setups.
|
|
58
|
+
|
|
59
|
+
## Live events
|
|
60
|
+
|
|
61
|
+
Incoming messages and call transcripts stream via an async generator:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
async with DialClient(DialConfig(api_key="sk_live_...")) as dial:
|
|
65
|
+
async with dial.new_events_connection() as events:
|
|
66
|
+
async for event in events:
|
|
67
|
+
print(event) # MessageEvent / CallEvent / CallTranscribed / ...
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Client methods
|
|
71
|
+
|
|
72
|
+
| Method | Description |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| `list_numbers()` | List the phone numbers on your account |
|
|
75
|
+
| `purchase_number(params)` | Buy a new number (`PurchaseNumberParams`) |
|
|
76
|
+
| `set_number_properties(...)` | Update a number's nickname / inbound instruction |
|
|
77
|
+
| `send_message(params)` | Send an SMS or WhatsApp message (`SendMessageParams`) |
|
|
78
|
+
| `list_messages(...)` | List messages, optionally filtered |
|
|
79
|
+
| `make_call(params)` | Place an outbound AI voice call (`MakeCallParams`) |
|
|
80
|
+
| `list_calls(...)` | List calls, optionally filtered |
|
|
81
|
+
| `get_call(call_id)` | Fetch a single call |
|
|
82
|
+
| `new_events_connection()` | Open a live event stream |
|
|
83
|
+
|
|
84
|
+
## Related
|
|
85
|
+
|
|
86
|
+
- [`dial-langchain`](https://pypi.org/project/dial-langchain/) — these capabilities as LangChain tools.
|
|
87
|
+
- [Documentation](https://docs.getdial.ai)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
dial_sdk/__init__.py,sha256=i6dPfAqI-gu5bz6v7fgz9cD6QSDG7VMjGIsCg8YdFQ4,1128
|
|
2
|
+
dial_sdk/client.py,sha256=lJKVRs6MivTY4yojR5c2G6_XJ9ziVoBjUcsicaKjyYI,7734
|
|
3
|
+
dial_sdk/events.py,sha256=uZTjo99n_gmKGfaDe3x_sBeVIyK8LaHog9UXFgl5SRc,5347
|
|
4
|
+
dial_sdk/self_hosted.py,sha256=xFBlDJSsWSEAp2Ql6cfMuzIDBYug-QSh8HTLu6mb7fQ,4600
|
|
5
|
+
dial_sdk/types.py,sha256=VYK_GAHy9PUNH9YFqPcvq5NJScWAgk3GJt1OklGokok,6219
|
|
6
|
+
dial_sdk-0.6.2.dist-info/METADATA,sha256=SMcRT71lnUB4hSmgv2MDz-4DkAiIvlGWm4TTL1bdFeE,2691
|
|
7
|
+
dial_sdk-0.6.2.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
8
|
+
dial_sdk-0.6.2.dist-info/RECORD,,
|