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.
- dial_sdk-0.6.2/.gitignore +6 -0
- dial_sdk-0.6.2/PKG-INFO +87 -0
- dial_sdk-0.6.2/README.md +77 -0
- dial_sdk-0.6.2/dial_sdk/__init__.py +58 -0
- dial_sdk-0.6.2/dial_sdk/client.py +203 -0
- dial_sdk-0.6.2/dial_sdk/events.py +155 -0
- dial_sdk-0.6.2/dial_sdk/self_hosted.py +151 -0
- dial_sdk-0.6.2/dial_sdk/types.py +224 -0
- dial_sdk-0.6.2/pyproject.toml +18 -0
- dial_sdk-0.6.2/test.py +52 -0
- dial_sdk-0.6.2/tests/test_self_hosted.py +112 -0
- dial_sdk-0.6.2/uv.lock +1036 -0
dial_sdk-0.6.2/PKG-INFO
ADDED
|
@@ -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)
|
dial_sdk-0.6.2/README.md
ADDED
|
@@ -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()
|