deewan 0.2.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.
- deewan/__init__.py +16 -0
- deewan/_client.py +402 -0
- deewan/_webhooks.py +42 -0
- deewan-0.2.0.dist-info/METADATA +49 -0
- deewan-0.2.0.dist-info/RECORD +7 -0
- deewan-0.2.0.dist-info/WHEEL +4 -0
- deewan-0.2.0.dist-info/licenses/LICENSE +21 -0
deewan/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Official server SDK for the Deewan API.
|
|
2
|
+
|
|
3
|
+
from deewan import Deewan
|
|
4
|
+
|
|
5
|
+
deewan = Deewan(api_key=os.environ["DEEWAN_API_KEY"])
|
|
6
|
+
room = deewan.rooms.create(name="Onboarding call")
|
|
7
|
+
link = deewan.rooms.create_join_link(room["id"], name="Sara", role="host")
|
|
8
|
+
|
|
9
|
+
Use it on your server only: an API key must never reach a browser or an app.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from ._client import Deewan, DeewanError
|
|
13
|
+
from ._webhooks import verify_webhook
|
|
14
|
+
|
|
15
|
+
__all__ = ["Deewan", "DeewanError", "verify_webhook"]
|
|
16
|
+
__version__ = "0.2.0"
|
deewan/_client.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import random
|
|
6
|
+
import socket
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from typing import Any, Iterator, Optional
|
|
12
|
+
|
|
13
|
+
VERSION = "0.2.0"
|
|
14
|
+
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DeewanError(Exception):
|
|
18
|
+
"""An API error. ``status`` is 0 when the request never got an answer."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, status: int, code: str, message: str, request_id: Optional[str] = None, details: Any = None):
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.status = status
|
|
23
|
+
self.code = code
|
|
24
|
+
self.message = message
|
|
25
|
+
self.request_id = request_id
|
|
26
|
+
self.details = details
|
|
27
|
+
|
|
28
|
+
def __repr__(self) -> str:
|
|
29
|
+
return f"DeewanError(status={self.status}, code={self.code!r}, message={self.message!r})"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
33
|
+
# urllib would follow a redirect with the Authorization header attached,
|
|
34
|
+
# even to another host. The API never redirects, so refuse all of them.
|
|
35
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D401
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _never_sent(error: BaseException) -> bool:
|
|
40
|
+
"""Failures before the request left: DNS, refused connection."""
|
|
41
|
+
reason = getattr(error, "reason", error)
|
|
42
|
+
return isinstance(reason, (ConnectionRefusedError, socket.gaierror))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _backoff(attempt: int) -> float:
|
|
46
|
+
return min(8.0, 0.5 * 2**attempt) + random.random() * 0.25
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _q(value: str) -> str:
|
|
50
|
+
return urllib.parse.quote(str(value), safe="")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _drop_none(values: dict[str, Any]) -> dict[str, Any]:
|
|
54
|
+
return {k: v for k, v in values.items() if v is not None}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Deewan:
|
|
58
|
+
"""The Deewan API client.
|
|
59
|
+
|
|
60
|
+
``timeout`` is per request, in seconds. Reads (GET, DELETE) are retried on
|
|
61
|
+
429 and 5xx up to ``max_retries`` times; writes are retried only on 429 or
|
|
62
|
+
when the connection never opened, so nothing is applied twice.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
api_key: str,
|
|
68
|
+
*,
|
|
69
|
+
base_url: str = "https://deewan.io/api/v1",
|
|
70
|
+
timeout: float = 15.0,
|
|
71
|
+
max_retries: int = 2,
|
|
72
|
+
):
|
|
73
|
+
if not isinstance(api_key, str) or not api_key.startswith("dwn_"):
|
|
74
|
+
raise ValueError("Deewan: pass a valid api_key (it starts with dwn_).")
|
|
75
|
+
parsed = urllib.parse.urlparse(base_url)
|
|
76
|
+
if parsed.scheme != "https" and parsed.hostname not in _LOCAL_HOSTS:
|
|
77
|
+
raise ValueError("Deewan: base_url must use https.")
|
|
78
|
+
self._key = api_key
|
|
79
|
+
self._base = base_url.rstrip("/")
|
|
80
|
+
self._timeout = timeout
|
|
81
|
+
self._retries = max_retries
|
|
82
|
+
self._opener = urllib.request.build_opener(_NoRedirect())
|
|
83
|
+
self.rooms = Rooms(self)
|
|
84
|
+
self.meetings = Meetings(self)
|
|
85
|
+
self.polls = Polls(self)
|
|
86
|
+
self.breakouts = Breakouts(self)
|
|
87
|
+
self.recordings = Recordings(self)
|
|
88
|
+
self.webhooks = Webhooks(self)
|
|
89
|
+
|
|
90
|
+
def me(self) -> dict[str, Any]:
|
|
91
|
+
"""The key's workspace, scopes and environment."""
|
|
92
|
+
return self._request("GET", "/me")
|
|
93
|
+
|
|
94
|
+
# -------------------------------------------------------------- transport
|
|
95
|
+
|
|
96
|
+
def _request(self, method: str, path: str, body: Any = None, query: Optional[dict[str, Any]] = None) -> Any:
|
|
97
|
+
return json.loads(self._send(method, path, body, query) or b"null")
|
|
98
|
+
|
|
99
|
+
def _send(self, method: str, path: str, body: Any = None, query: Optional[dict[str, Any]] = None) -> bytes:
|
|
100
|
+
url = self._base + path
|
|
101
|
+
if query:
|
|
102
|
+
clean = _drop_none(query)
|
|
103
|
+
if clean:
|
|
104
|
+
url += "?" + urllib.parse.urlencode(clean)
|
|
105
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
106
|
+
headers = {
|
|
107
|
+
"Authorization": f"Bearer {self._key}",
|
|
108
|
+
"Accept": "application/json",
|
|
109
|
+
"User-Agent": f"deewan-sdk-python/{VERSION}",
|
|
110
|
+
}
|
|
111
|
+
if data is not None:
|
|
112
|
+
headers["Content-Type"] = "application/json"
|
|
113
|
+
safe = method in ("GET", "DELETE")
|
|
114
|
+
|
|
115
|
+
attempt = 0
|
|
116
|
+
while True:
|
|
117
|
+
request = urllib.request.Request(url, data=data, method=method, headers=headers)
|
|
118
|
+
try:
|
|
119
|
+
with self._opener.open(request, timeout=self._timeout) as response:
|
|
120
|
+
return response.read()
|
|
121
|
+
except urllib.error.HTTPError as error:
|
|
122
|
+
retryable = error.code == 429 or error.code >= 500
|
|
123
|
+
if retryable and (safe or error.code == 429) and attempt < self._retries:
|
|
124
|
+
after = error.headers.get("Retry-After") if error.headers else None
|
|
125
|
+
try:
|
|
126
|
+
wait = float(after) if after else _backoff(attempt)
|
|
127
|
+
except ValueError:
|
|
128
|
+
wait = _backoff(attempt)
|
|
129
|
+
time.sleep(wait if wait > 0 else _backoff(attempt))
|
|
130
|
+
attempt += 1
|
|
131
|
+
continue
|
|
132
|
+
raise self._to_error(error) from None
|
|
133
|
+
except (urllib.error.URLError, socket.timeout, ConnectionError) as error:
|
|
134
|
+
if (safe or _never_sent(error)) and attempt < self._retries:
|
|
135
|
+
time.sleep(_backoff(attempt))
|
|
136
|
+
attempt += 1
|
|
137
|
+
continue
|
|
138
|
+
raise DeewanError(0, "network_error", str(getattr(error, "reason", error))) from None
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _to_error(error: urllib.error.HTTPError) -> DeewanError:
|
|
142
|
+
request_id = error.headers.get("x-request-id") if error.headers else None
|
|
143
|
+
try:
|
|
144
|
+
payload = json.loads(error.read() or b"null") or {}
|
|
145
|
+
except ValueError:
|
|
146
|
+
payload = {}
|
|
147
|
+
detail = payload.get("error") if isinstance(payload, dict) else None
|
|
148
|
+
detail = detail if isinstance(detail, dict) else {}
|
|
149
|
+
return DeewanError(
|
|
150
|
+
error.code,
|
|
151
|
+
detail.get("code") or "http_error",
|
|
152
|
+
detail.get("message") or f"HTTP {error.code}",
|
|
153
|
+
request_id,
|
|
154
|
+
detail.get("details"),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class Rooms:
|
|
159
|
+
def __init__(self, client: Deewan):
|
|
160
|
+
self._c = client
|
|
161
|
+
|
|
162
|
+
def list(self, *, limit: Optional[int] = None, starting_after: Optional[str] = None) -> dict[str, Any]:
|
|
163
|
+
return self._c._request("GET", "/rooms", query={"limit": limit, "starting_after": starting_after})
|
|
164
|
+
|
|
165
|
+
def all(self, page_size: int = 100) -> Iterator[dict[str, Any]]:
|
|
166
|
+
"""Every room, fetching pages as needed."""
|
|
167
|
+
after: Optional[str] = None
|
|
168
|
+
while True:
|
|
169
|
+
page = self.list(limit=page_size, starting_after=after)
|
|
170
|
+
yield from page["data"]
|
|
171
|
+
if not page.get("has_more") or not page["data"]:
|
|
172
|
+
return
|
|
173
|
+
after = page["data"][-1]["id"]
|
|
174
|
+
|
|
175
|
+
def create(self, *, name: str, description: Optional[str] = None, settings: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
|
176
|
+
return self._c._request("POST", "/rooms", _drop_none({"name": name, "description": description, "settings": settings}))
|
|
177
|
+
|
|
178
|
+
def retrieve(self, room_id: str) -> dict[str, Any]:
|
|
179
|
+
return self._c._request("GET", f"/rooms/{_q(room_id)}")
|
|
180
|
+
|
|
181
|
+
def update(self, room_id: str, **changes: Any) -> dict[str, Any]:
|
|
182
|
+
"""``name``, ``description``, ``locked`` or ``settings`` (a partial dict)."""
|
|
183
|
+
return self._c._request("PATCH", f"/rooms/{_q(room_id)}", changes)
|
|
184
|
+
|
|
185
|
+
def delete(self, room_id: str) -> dict[str, Any]:
|
|
186
|
+
return self._c._request("DELETE", f"/rooms/{_q(room_id)}")
|
|
187
|
+
|
|
188
|
+
def create_join_link(
|
|
189
|
+
self,
|
|
190
|
+
room_id: str,
|
|
191
|
+
*,
|
|
192
|
+
name: str,
|
|
193
|
+
role: Optional[str] = None,
|
|
194
|
+
user_id: Optional[str] = None,
|
|
195
|
+
expires_in: Optional[int] = None,
|
|
196
|
+
locale: Optional[str] = None,
|
|
197
|
+
redirect_url: Optional[str] = None,
|
|
198
|
+
branding: Optional[str] = None,
|
|
199
|
+
) -> dict[str, Any]:
|
|
200
|
+
"""A personal, expiring link into Deewan's hosted meeting page."""
|
|
201
|
+
body = _drop_none(
|
|
202
|
+
{"name": name, "role": role, "user_id": user_id, "expires_in": expires_in, "locale": locale, "redirect_url": redirect_url, "branding": branding}
|
|
203
|
+
)
|
|
204
|
+
return self._c._request("POST", f"/rooms/{_q(room_id)}/join-links", body)
|
|
205
|
+
|
|
206
|
+
def create_token(
|
|
207
|
+
self,
|
|
208
|
+
room_id: str,
|
|
209
|
+
*,
|
|
210
|
+
name: str,
|
|
211
|
+
role: Optional[str] = None,
|
|
212
|
+
user_id: Optional[str] = None,
|
|
213
|
+
avatar: Optional[str] = None,
|
|
214
|
+
exchange_key: Optional[str] = None,
|
|
215
|
+
) -> dict[str, Any]:
|
|
216
|
+
"""Credentials for an interface you build yourself (web, React Native or Flutter SDK).
|
|
217
|
+
|
|
218
|
+
For a room with strict encryption, pass the participant's ``exchange_key``
|
|
219
|
+
(the client SDK makes it on their device); the response then has
|
|
220
|
+
``e2ee == "strict"`` and no passphrase.
|
|
221
|
+
"""
|
|
222
|
+
body = _drop_none({"name": name, "role": role, "user_id": user_id, "avatar": avatar, "exchange_key": exchange_key})
|
|
223
|
+
return self._c._request("POST", f"/rooms/{_q(room_id)}/tokens", body)
|
|
224
|
+
|
|
225
|
+
def list_participants(self, room_id: str) -> dict[str, Any]:
|
|
226
|
+
return self._c._request("GET", f"/rooms/{_q(room_id)}/participants")
|
|
227
|
+
|
|
228
|
+
def remove_participant(self, room_id: str, identity: str) -> dict[str, Any]:
|
|
229
|
+
return self._c._request("DELETE", f"/rooms/{_q(room_id)}/participants/{_q(identity)}")
|
|
230
|
+
|
|
231
|
+
def mute_participant(self, room_id: str, identity: str) -> dict[str, Any]:
|
|
232
|
+
return self._c._request("POST", f"/rooms/{_q(room_id)}/participants/{_q(identity)}/mute")
|
|
233
|
+
|
|
234
|
+
def make_cohost(self, room_id: str, identity: str) -> dict[str, Any]:
|
|
235
|
+
return self._c._request("POST", f"/rooms/{_q(room_id)}/participants/{_q(identity)}/cohost")
|
|
236
|
+
|
|
237
|
+
def remove_cohost(self, room_id: str, identity: str) -> dict[str, Any]:
|
|
238
|
+
return self._c._request("DELETE", f"/rooms/{_q(room_id)}/participants/{_q(identity)}/cohost")
|
|
239
|
+
|
|
240
|
+
def invite_to_speak(self, room_id: str, identity: str) -> dict[str, Any]:
|
|
241
|
+
"""Webinars: lets someone in the audience turn on their microphone and camera."""
|
|
242
|
+
return self._c._request("POST", f"/rooms/{_q(room_id)}/participants/{_q(identity)}/stage")
|
|
243
|
+
|
|
244
|
+
def move_to_audience(self, room_id: str, identity: str) -> dict[str, Any]:
|
|
245
|
+
"""Webinars: sends someone back to the audience."""
|
|
246
|
+
return self._c._request("DELETE", f"/rooms/{_q(room_id)}/participants/{_q(identity)}/stage")
|
|
247
|
+
|
|
248
|
+
def list_waiting(self, room_id: str) -> dict[str, Any]:
|
|
249
|
+
return self._c._request("GET", f"/rooms/{_q(room_id)}/waiting")
|
|
250
|
+
|
|
251
|
+
def admit(self, room_id: str, identity: str) -> dict[str, Any]:
|
|
252
|
+
return self._c._request("POST", f"/rooms/{_q(room_id)}/waiting/{_q(identity)}", {"decision": "admit"})
|
|
253
|
+
|
|
254
|
+
def deny(self, room_id: str, identity: str) -> dict[str, Any]:
|
|
255
|
+
return self._c._request("POST", f"/rooms/{_q(room_id)}/waiting/{_q(identity)}", {"decision": "deny"})
|
|
256
|
+
|
|
257
|
+
def end(self, room_id: str) -> dict[str, Any]:
|
|
258
|
+
"""Ends the meeting for everyone; the room remains."""
|
|
259
|
+
return self._c._request("POST", f"/rooms/{_q(room_id)}/end")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class Meetings:
|
|
263
|
+
def __init__(self, client: Deewan):
|
|
264
|
+
self._c = client
|
|
265
|
+
|
|
266
|
+
def list(self, *, from_: Optional[str] = None, to: Optional[str] = None, limit: Optional[int] = None) -> dict[str, Any]:
|
|
267
|
+
return self._c._request("GET", "/meetings", query={"from": from_, "to": to, "limit": limit})
|
|
268
|
+
|
|
269
|
+
def create(self, *, title: str, starts_at: str, ends_at: str, **extra: Any) -> dict[str, Any]:
|
|
270
|
+
"""Schedules a meeting. ``extra``: description, room_id, recurrence, recurrence_until, timezone."""
|
|
271
|
+
return self._c._request("POST", "/meetings", {"title": title, "starts_at": starts_at, "ends_at": ends_at, **extra})
|
|
272
|
+
|
|
273
|
+
def retrieve(self, meeting_id: str) -> dict[str, Any]:
|
|
274
|
+
return self._c._request("GET", f"/meetings/{_q(meeting_id)}")
|
|
275
|
+
|
|
276
|
+
def update(self, meeting_id: str, **changes: Any) -> dict[str, Any]:
|
|
277
|
+
return self._c._request("PATCH", f"/meetings/{_q(meeting_id)}", changes)
|
|
278
|
+
|
|
279
|
+
def cancel(self, meeting_id: str) -> dict[str, Any]:
|
|
280
|
+
return self._c._request("DELETE", f"/meetings/{_q(meeting_id)}")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class Polls:
|
|
284
|
+
def __init__(self, client: Deewan):
|
|
285
|
+
self._c = client
|
|
286
|
+
|
|
287
|
+
def list(self, room_id: str) -> dict[str, Any]:
|
|
288
|
+
return self._c._request("GET", f"/rooms/{_q(room_id)}/polls")
|
|
289
|
+
|
|
290
|
+
def create(self, room_id: str, *, question: str, options: list[str], **extra: Any) -> dict[str, Any]:
|
|
291
|
+
"""``extra``: kind ("single"|"multiple"), anonymous, results ("live"|"after_vote"|"after_close")."""
|
|
292
|
+
return self._c._request("POST", f"/rooms/{_q(room_id)}/polls", {"question": question, "options": options, **extra})
|
|
293
|
+
|
|
294
|
+
def close(self, poll_id: str) -> dict[str, Any]:
|
|
295
|
+
return self._c._request("PATCH", f"/polls/{_q(poll_id)}", {"status": "closed"})
|
|
296
|
+
|
|
297
|
+
def reopen(self, poll_id: str) -> dict[str, Any]:
|
|
298
|
+
return self._c._request("PATCH", f"/polls/{_q(poll_id)}", {"status": "open"})
|
|
299
|
+
|
|
300
|
+
def delete(self, poll_id: str) -> dict[str, Any]:
|
|
301
|
+
return self._c._request("DELETE", f"/polls/{_q(poll_id)}")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class Breakouts:
|
|
305
|
+
def __init__(self, client: Deewan):
|
|
306
|
+
self._c = client
|
|
307
|
+
|
|
308
|
+
def get(self, room_id: str) -> dict[str, Any]:
|
|
309
|
+
return self._c._request("GET", f"/rooms/{_q(room_id)}/breakouts")
|
|
310
|
+
|
|
311
|
+
def start(self, room_id: str, *, rooms: Any, **extra: Any) -> dict[str, Any]:
|
|
312
|
+
"""``rooms``: a count (1–50) or a list of names. ``extra`` as in the API reference."""
|
|
313
|
+
return self._c._request("POST", f"/rooms/{_q(room_id)}/breakouts", {"rooms": rooms, **extra})
|
|
314
|
+
|
|
315
|
+
def move(self, room_id: str, participant: str, breakout_id: Optional[str], *, can_return: Optional[bool] = None) -> dict[str, Any]:
|
|
316
|
+
body = _drop_none({"participant": participant, "can_return": can_return})
|
|
317
|
+
body["breakout_id"] = breakout_id
|
|
318
|
+
return self._c._request("POST", f"/rooms/{_q(room_id)}/breakouts/assignments", body)
|
|
319
|
+
|
|
320
|
+
def add_room(self, room_id: str, name: Optional[str] = None) -> dict[str, Any]:
|
|
321
|
+
return self._c._request("POST", f"/rooms/{_q(room_id)}/breakouts/rooms", _drop_none({"name": name}))
|
|
322
|
+
|
|
323
|
+
def rename_room(self, room_id: str, breakout_id: str, name: str) -> dict[str, Any]:
|
|
324
|
+
return self._c._request("PATCH", f"/rooms/{_q(room_id)}/breakouts/rooms/{_q(breakout_id)}", {"name": name})
|
|
325
|
+
|
|
326
|
+
def remove_room(self, room_id: str, breakout_id: str) -> dict[str, Any]:
|
|
327
|
+
return self._c._request("DELETE", f"/rooms/{_q(room_id)}/breakouts/rooms/{_q(breakout_id)}")
|
|
328
|
+
|
|
329
|
+
def announce(self, room_id: str, message: str) -> dict[str, Any]:
|
|
330
|
+
"""Shows a short message in every room. Not end-to-end encrypted."""
|
|
331
|
+
return self._c._request("POST", f"/rooms/{_q(room_id)}/breakouts/announce", {"message": message})
|
|
332
|
+
|
|
333
|
+
def end(self, room_id: str, *, warn: Optional[bool] = None) -> dict[str, Any]:
|
|
334
|
+
return self._c._request("POST", f"/rooms/{_q(room_id)}/breakouts/end", _drop_none({"warn": warn}))
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class Recordings:
|
|
338
|
+
def __init__(self, client: Deewan):
|
|
339
|
+
self._c = client
|
|
340
|
+
|
|
341
|
+
def list(self, *, room_id: Optional[str] = None, limit: Optional[int] = None) -> dict[str, Any]:
|
|
342
|
+
return self._c._request("GET", "/recordings", query={"room_id": room_id, "limit": limit})
|
|
343
|
+
|
|
344
|
+
def retrieve(self, recording_id: str) -> dict[str, Any]:
|
|
345
|
+
return self._c._request("GET", f"/recordings/{_q(recording_id)}")
|
|
346
|
+
|
|
347
|
+
def delete(self, recording_id: str) -> dict[str, Any]:
|
|
348
|
+
"""Deletes the recording and its stored parts. Cannot be undone."""
|
|
349
|
+
return self._c._request("DELETE", f"/recordings/{_q(recording_id)}")
|
|
350
|
+
|
|
351
|
+
def download_info(self, recording_id: str) -> dict[str, Any]:
|
|
352
|
+
"""The encrypted parts and the key for this one recording."""
|
|
353
|
+
return self._c._request("POST", f"/recordings/{_q(recording_id)}/download")
|
|
354
|
+
|
|
355
|
+
def download(self, recording_id: str) -> tuple[bytes, str]:
|
|
356
|
+
"""Fetches a recording and decrypts it here. Returns ``(bytes, mime_type)``.
|
|
357
|
+
|
|
358
|
+
Needs ``pip install "deewan[recordings]"``. Recordings are encrypted
|
|
359
|
+
where they were made; this is the only way to get a playable file.
|
|
360
|
+
"""
|
|
361
|
+
try:
|
|
362
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
363
|
+
except ImportError as error: # pragma: no cover
|
|
364
|
+
raise ImportError('Recordings.download needs: pip install "deewan[recordings]"') from error
|
|
365
|
+
info = self.download_info(recording_id)
|
|
366
|
+
enc = info["encryption"]
|
|
367
|
+
key = base64.urlsafe_b64decode(enc["key"] + "=" * (-len(enc["key"]) % 4))
|
|
368
|
+
iv_bytes = int(enc["iv_bytes"])
|
|
369
|
+
cipher = AESGCM(key)
|
|
370
|
+
chunks = []
|
|
371
|
+
for part in info["parts"]:
|
|
372
|
+
index = part["index"]
|
|
373
|
+
body = self._c._send("GET", f"/recordings/{_q(recording_id)}/parts/{int(index)}")
|
|
374
|
+
aad = f"deewan-recording:{recording_id}:{index}".encode("utf-8")
|
|
375
|
+
chunks.append(cipher.decrypt(body[:iv_bytes], body[iv_bytes:], aad))
|
|
376
|
+
return b"".join(chunks), info["mime_type"]
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class Webhooks:
|
|
380
|
+
def __init__(self, client: Deewan):
|
|
381
|
+
self._c = client
|
|
382
|
+
|
|
383
|
+
def list(self) -> dict[str, Any]:
|
|
384
|
+
return self._c._request("GET", "/webhooks")
|
|
385
|
+
|
|
386
|
+
def create(self, *, url: str, events: list[str], description: Optional[str] = None, enabled: Optional[bool] = None) -> dict[str, Any]:
|
|
387
|
+
"""The response's ``secret`` is shown only once — store it."""
|
|
388
|
+
body = _drop_none({"url": url, "events": events, "description": description, "enabled": enabled})
|
|
389
|
+
return self._c._request("POST", "/webhooks", body)
|
|
390
|
+
|
|
391
|
+
def update(self, webhook_id: str, **changes: Any) -> dict[str, Any]:
|
|
392
|
+
return self._c._request("PATCH", f"/webhooks/{_q(webhook_id)}", changes)
|
|
393
|
+
|
|
394
|
+
def delete(self, webhook_id: str) -> dict[str, Any]:
|
|
395
|
+
return self._c._request("DELETE", f"/webhooks/{_q(webhook_id)}")
|
|
396
|
+
|
|
397
|
+
def test(self, webhook_id: str) -> dict[str, Any]:
|
|
398
|
+
return self._c._request("POST", f"/webhooks/{_q(webhook_id)}/test")
|
|
399
|
+
|
|
400
|
+
def deliveries(self, webhook_id: str) -> dict[str, Any]:
|
|
401
|
+
"""The 50 most recent delivery attempts to this endpoint, newest first."""
|
|
402
|
+
return self._c._request("GET", f"/webhooks/{_q(webhook_id)}/deliveries")
|
deewan/_webhooks.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Optional, Union
|
|
8
|
+
|
|
9
|
+
from ._client import DeewanError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def verify_webhook(
|
|
13
|
+
raw_body: Union[str, bytes],
|
|
14
|
+
signature_header: Optional[str],
|
|
15
|
+
secret: str,
|
|
16
|
+
tolerance_seconds: int = 300,
|
|
17
|
+
) -> dict[str, Any]:
|
|
18
|
+
"""Verifies a webhook request and returns the parsed event.
|
|
19
|
+
|
|
20
|
+
Pass the raw body exactly as received (not re-serialized JSON) and the
|
|
21
|
+
``Deewan-Signature`` header. Signatures older than ``tolerance_seconds``
|
|
22
|
+
are rejected, to stop replays.
|
|
23
|
+
"""
|
|
24
|
+
if not signature_header:
|
|
25
|
+
raise DeewanError(400, "missing_signature", "Missing Deewan-Signature header.")
|
|
26
|
+
parts: dict[str, str] = {}
|
|
27
|
+
for item in signature_header.split(","):
|
|
28
|
+
key, _, value = item.strip().partition("=")
|
|
29
|
+
parts[key] = value
|
|
30
|
+
try:
|
|
31
|
+
timestamp = int(parts.get("t", ""))
|
|
32
|
+
except ValueError:
|
|
33
|
+
raise DeewanError(400, "bad_signature", "Malformed Deewan-Signature header.") from None
|
|
34
|
+
if not parts.get("v1"):
|
|
35
|
+
raise DeewanError(400, "bad_signature", "Malformed Deewan-Signature header.")
|
|
36
|
+
if abs(time.time() - timestamp) > tolerance_seconds:
|
|
37
|
+
raise DeewanError(400, "stale_signature", "Webhook timestamp is outside the allowed tolerance.")
|
|
38
|
+
body = raw_body.decode("utf-8") if isinstance(raw_body, bytes) else raw_body
|
|
39
|
+
expected = hmac.new(secret.encode("utf-8"), f"{timestamp}.{body}".encode("utf-8"), hashlib.sha256).hexdigest()
|
|
40
|
+
if not hmac.compare_digest(expected, parts["v1"]):
|
|
41
|
+
raise DeewanError(400, "bad_signature", "Webhook signature does not match.")
|
|
42
|
+
return json.loads(body)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: deewan
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Official server SDK for the Deewan video meetings API: rooms, join links, meetings, polls, breakouts, recordings and webhooks.
|
|
5
|
+
Project-URL: Documentation, https://deewan.io/en/docs/sdk
|
|
6
|
+
Project-URL: Repository, https://github.com/deewanio/deewan-platform/tree/main/packages/python
|
|
7
|
+
Project-URL: Issues, https://github.com/deewanio/deewan-platform/issues
|
|
8
|
+
Author-email: Deewan <support@deewan.io>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: deewan,e2ee,meetings,video,webrtc
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Provides-Extra: recordings
|
|
18
|
+
Requires-Dist: cryptography>=42; extra == 'recordings'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# deewan
|
|
22
|
+
|
|
23
|
+
The official Python server SDK for the [Deewan](https://deewan.io) video meetings API.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install deewan
|
|
27
|
+
pip install "deewan[recordings]" # only to download (decrypt) recordings
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import os
|
|
32
|
+
from deewan import Deewan, DeewanError, verify_webhook
|
|
33
|
+
|
|
34
|
+
deewan = Deewan(os.environ["DEEWAN_API_KEY"])
|
|
35
|
+
|
|
36
|
+
room = deewan.rooms.create(name="Onboarding call")
|
|
37
|
+
link = deewan.rooms.create_join_link(room["id"], name="Sara", role="host")
|
|
38
|
+
token = deewan.rooms.create_token(room["id"], name="Omar") # for a custom interface
|
|
39
|
+
|
|
40
|
+
event = verify_webhook(raw_body, headers["Deewan-Signature"], os.environ["DEEWAN_WEBHOOK_SECRET"])
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Python 3.9+, no dependencies. Requests time out after 15 s; reads are retried
|
|
44
|
+
on 429 and 5xx, writes only when they can't have been applied. Redirects and
|
|
45
|
+
plain http are refused, so the key is only ever sent to Deewan. Errors are
|
|
46
|
+
`DeewanError` with `status`, `code`, `message` and `request_id`.
|
|
47
|
+
|
|
48
|
+
Server-side only — the API key must never reach a browser or app.
|
|
49
|
+
Full reference: https://deewan.io/en/docs/sdk
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
deewan/__init__.py,sha256=adJVPLKmXy_F5Oynm8B11ifDVL6ZfZukBR-gcLnlejw,506
|
|
2
|
+
deewan/_client.py,sha256=ySXYiwk_hrTzUcWTvECUzb-xB9LrC7FRJ5OLKzsrOlg,18074
|
|
3
|
+
deewan/_webhooks.py,sha256=Hp1FPLmy5EBWqP36HQHfRh0Wb8kF2NY6Z0gBqAKdXU4,1669
|
|
4
|
+
deewan-0.2.0.dist-info/METADATA,sha256=eVBVzzsRZ2yI-BSJ2IQlK4mzzJj1CueNjGS23CIDr-s,1916
|
|
5
|
+
deewan-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
+
deewan-0.2.0.dist-info/licenses/LICENSE,sha256=md0vUKCEZqoQHGXUNV8uYbE2IgDNvRH5fpTMJ5zGc2w,1063
|
|
7
|
+
deewan-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Deewan
|
|
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.
|