dial-sdk 0.6.2__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.
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ dist/
6
+ *.egg-info/
@@ -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,77 @@
1
+ # dial-sdk
2
+
3
+ Official Python SDK for [Dial](https://getdial.ai) — phone numbers, SMS, WhatsApp, and voice calls for AI agents.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install dial-sdk
9
+ # or
10
+ uv add dial-sdk
11
+ ```
12
+
13
+ Requires Python 3.11+.
14
+
15
+ ## Quickstart
16
+
17
+ The client is async. Construct it with a `DialConfig`, then call its methods inside an `async with` block:
18
+
19
+ ```python
20
+ import asyncio
21
+ from dial_sdk import DialClient, DialConfig, SendMessageParams, MakeCallParams
22
+
23
+ async def main():
24
+ async with DialClient(DialConfig(api_key="sk_live_...")) as dial:
25
+ # List your numbers
26
+ numbers = await dial.list_numbers()
27
+ from_id = numbers[0].id
28
+
29
+ # Send an SMS
30
+ await dial.send_message(SendMessageParams(
31
+ to="+15551234567",
32
+ from_number_id=from_id,
33
+ body="Hello from Dial",
34
+ ))
35
+
36
+ # Place an AI voice call
37
+ call = await dial.make_call(MakeCallParams(
38
+ to="+15551234567",
39
+ from_number_id=from_id,
40
+ outbound_instruction="You are a friendly assistant confirming an appointment.",
41
+ ))
42
+ print(call.id, call.status)
43
+
44
+ asyncio.run(main())
45
+ ```
46
+
47
+ `DialConfig.base_url` defaults to `https://getdial.ai`. Override it for local or self-hosted setups.
48
+
49
+ ## Live events
50
+
51
+ Incoming messages and call transcripts stream via an async generator:
52
+
53
+ ```python
54
+ async with DialClient(DialConfig(api_key="sk_live_...")) as dial:
55
+ async with dial.new_events_connection() as events:
56
+ async for event in events:
57
+ print(event) # MessageEvent / CallEvent / CallTranscribed / ...
58
+ ```
59
+
60
+ ## Client methods
61
+
62
+ | Method | Description |
63
+ | --- | --- |
64
+ | `list_numbers()` | List the phone numbers on your account |
65
+ | `purchase_number(params)` | Buy a new number (`PurchaseNumberParams`) |
66
+ | `set_number_properties(...)` | Update a number's nickname / inbound instruction |
67
+ | `send_message(params)` | Send an SMS or WhatsApp message (`SendMessageParams`) |
68
+ | `list_messages(...)` | List messages, optionally filtered |
69
+ | `make_call(params)` | Place an outbound AI voice call (`MakeCallParams`) |
70
+ | `list_calls(...)` | List calls, optionally filtered |
71
+ | `get_call(call_id)` | Fetch a single call |
72
+ | `new_events_connection()` | Open a live event stream |
73
+
74
+ ## Related
75
+
76
+ - [`dial-langchain`](https://pypi.org/project/dial-langchain/) — these capabilities as LangChain tools.
77
+ - [Documentation](https://docs.getdial.ai)
@@ -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
+ ]
@@ -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()
@@ -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()