appsocom 0.5.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.
appsocom/__init__.py
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
"""sfukit server SDK for Python (standard library only).
|
|
2
|
+
|
|
3
|
+
from appsocom import SfuKit, verify_webhook
|
|
4
|
+
kit = SfuKit(url=os.environ["SFUKIT_URL"], admin_key=os.environ["SFUKIT_ADMIN_API_KEY"])
|
|
5
|
+
tok = kit.create_token(room_id="class-1", user_id="u_7", name="Ada", role="student")
|
|
6
|
+
tok["token"]
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.parse
|
|
18
|
+
import urllib.request
|
|
19
|
+
from typing import Any, Dict, List, Optional, Sequence, Union
|
|
20
|
+
|
|
21
|
+
__all__ = ["SfuKit", "SfuKitError", "sign_token", "verify_webhook", "sign_webhook", "SIGNATURE_HEADER"]
|
|
22
|
+
__version__ = "0.5.0"
|
|
23
|
+
|
|
24
|
+
SIGNATURE_HEADER = "X-SfuKit-Signature"
|
|
25
|
+
_ID = re.compile(r"^[A-Za-z0-9._:@-]{1,128}$")
|
|
26
|
+
_ROLE = re.compile(r"^[A-Za-z0-9._-]{1,32}$")
|
|
27
|
+
_ROLE_DEFAULTS = {
|
|
28
|
+
"teacher": {"mic": True, "camera": True, "screen": True, "moderate": True},
|
|
29
|
+
"student": {"mic": True, "camera": True, "screen": False, "moderate": False},
|
|
30
|
+
"observer": {"mic": False, "camera": False, "screen": False, "moderate": False},
|
|
31
|
+
}
|
|
32
|
+
_UNSET: Any = object()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SfuKitError(Exception):
|
|
36
|
+
"""A non-2xx answer from the kit (or a bad argument caught before calling it, status 400)."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, status: int, message: str, body: Any = None):
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
self.status = status
|
|
41
|
+
self.body = body
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _id(value: str) -> str:
|
|
45
|
+
if not isinstance(value, str) or not _ID.match(value):
|
|
46
|
+
raise SfuKitError(400, f'invalid id "{value}" (letters, numbers, . _ : @ -, max 128)')
|
|
47
|
+
return urllib.parse.quote(value, safe="")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _json(value: Any) -> str:
|
|
51
|
+
# Same bytes as JavaScript's JSON.stringify (no spaces, UTF-8 kept).
|
|
52
|
+
return json.dumps(value, separators=(",", ":"), ensure_ascii=False)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SfuKit:
|
|
56
|
+
"""Admin client for one sfukit deployment.
|
|
57
|
+
|
|
58
|
+
url: where the kit's Worker routes live, e.g. "https://video.client.com"
|
|
59
|
+
admin_key: the deployment's ADMIN_API_KEY (keep it on the server)
|
|
60
|
+
jwt_secret: the deployment's JWT_SECRET; when set, create_token() signs locally
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, url: str, admin_key: str, jwt_secret: Optional[str] = None, timeout: float = 10.0):
|
|
64
|
+
if not url:
|
|
65
|
+
raise ValueError("url is required")
|
|
66
|
+
if not admin_key:
|
|
67
|
+
raise ValueError("admin_key is required")
|
|
68
|
+
self.url = url.rstrip("/")
|
|
69
|
+
self._admin_key = admin_key
|
|
70
|
+
self._jwt_secret = jwt_secret
|
|
71
|
+
self.timeout = timeout
|
|
72
|
+
|
|
73
|
+
# ---------- tokens ----------
|
|
74
|
+
|
|
75
|
+
def create_token(self, room_id: str, user_id: str, **options: Any) -> Dict[str, Any]:
|
|
76
|
+
"""Mint a join token: {"token", "expiresAt"}.
|
|
77
|
+
|
|
78
|
+
options: name, role, permissions (dict), can_publish, metadata (dict, 4 KB),
|
|
79
|
+
max_participants (2-10000), ttl_seconds (60-86400, default 7200),
|
|
80
|
+
data_saver ("off", "low", "minimal", "audio": the preset the client starts with).
|
|
81
|
+
"""
|
|
82
|
+
if self._jwt_secret:
|
|
83
|
+
return sign_token(self._jwt_secret, room_id, user_id, **options)
|
|
84
|
+
body: Dict[str, Any] = {"roomId": room_id, "userId": user_id}
|
|
85
|
+
names = {"can_publish": "canPublish", "max_participants": "maxParticipants", "ttl_seconds": "ttlSeconds", "data_saver": "dataSaver"}
|
|
86
|
+
for k, v in options.items():
|
|
87
|
+
if v is not None:
|
|
88
|
+
body[names.get(k, k)] = v
|
|
89
|
+
return self.request("POST", "/tokens", body)
|
|
90
|
+
|
|
91
|
+
# ---------- rooms ----------
|
|
92
|
+
|
|
93
|
+
def get_room(self, room_id: str) -> Dict[str, Any]:
|
|
94
|
+
"""{"roomId", "room": settings, "participants": [...]}."""
|
|
95
|
+
return self.request("GET", f"/rooms/{_id(room_id)}")
|
|
96
|
+
|
|
97
|
+
def list_participants(self, room_id: str) -> List[Dict[str, Any]]:
|
|
98
|
+
return self.get_room(room_id)["participants"]
|
|
99
|
+
|
|
100
|
+
def update_room(self, room_id: str, **update: Any) -> Dict[str, Any]:
|
|
101
|
+
"""locked, chat, waiting_room, spotlight (list of userIds), metadata (dict or None to clear)."""
|
|
102
|
+
body = {("waitingRoom" if k == "waiting_room" else k): v for k, v in update.items()}
|
|
103
|
+
return self.request("PATCH", f"/rooms/{_id(room_id)}", body)["room"]
|
|
104
|
+
|
|
105
|
+
def set_room_metadata(self, room_id: str, metadata: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
106
|
+
return self.update_room(room_id, metadata=metadata)
|
|
107
|
+
|
|
108
|
+
def lock_room(self, room_id: str, locked: bool = True) -> Dict[str, Any]:
|
|
109
|
+
return self.update_room(room_id, locked=locked)
|
|
110
|
+
|
|
111
|
+
def close_room(self, room_id: str) -> int:
|
|
112
|
+
"""Disconnect everyone; returns how many connections were closed."""
|
|
113
|
+
return self.request("DELETE", f"/rooms/{_id(room_id)}")["disconnected"]
|
|
114
|
+
|
|
115
|
+
# ---------- participants ----------
|
|
116
|
+
|
|
117
|
+
def update_participant(
|
|
118
|
+
self,
|
|
119
|
+
room_id: str,
|
|
120
|
+
user_id: str,
|
|
121
|
+
*,
|
|
122
|
+
role: Optional[str] = None,
|
|
123
|
+
permissions: Any = _UNSET,
|
|
124
|
+
name: Optional[str] = None,
|
|
125
|
+
metadata: Any = _UNSET,
|
|
126
|
+
track_permissions: Any = _UNSET,
|
|
127
|
+
) -> int:
|
|
128
|
+
"""Change role/permissions/name/metadata/track permissions; pass None to clear the last three."""
|
|
129
|
+
body: Dict[str, Any] = {}
|
|
130
|
+
if role is not None:
|
|
131
|
+
body["role"] = role
|
|
132
|
+
if permissions is not _UNSET:
|
|
133
|
+
body["permissions"] = permissions
|
|
134
|
+
if name is not None:
|
|
135
|
+
body["name"] = name
|
|
136
|
+
if metadata is not _UNSET:
|
|
137
|
+
body["metadata"] = metadata
|
|
138
|
+
if track_permissions is not _UNSET:
|
|
139
|
+
body["trackPermissions"] = track_permissions
|
|
140
|
+
return self.request("PATCH", f"/rooms/{_id(room_id)}/participants/{_id(user_id)}", body)["updated"]
|
|
141
|
+
|
|
142
|
+
def set_permissions(self, room_id: str, user_id: str, permissions: Optional[Dict[str, bool]]) -> int:
|
|
143
|
+
return self.update_participant(room_id, user_id, permissions=permissions)
|
|
144
|
+
|
|
145
|
+
def set_role(self, room_id: str, user_id: str, role: str, permissions: Optional[Dict[str, bool]] = None) -> int:
|
|
146
|
+
"""e.g. promote an observer: set_role(room, user, "student")."""
|
|
147
|
+
if permissions is None:
|
|
148
|
+
return self.update_participant(room_id, user_id, role=role)
|
|
149
|
+
return self.update_participant(room_id, user_id, role=role, permissions=permissions)
|
|
150
|
+
|
|
151
|
+
def set_participant_metadata(self, room_id: str, user_id: str, metadata: Optional[Dict[str, Any]]) -> int:
|
|
152
|
+
return self.update_participant(room_id, user_id, metadata=metadata)
|
|
153
|
+
|
|
154
|
+
def set_track_permissions(self, room_id: str, user_id: str, track_permissions: Optional[Dict[str, Any]]) -> int:
|
|
155
|
+
"""{"allowAll": False, "allow": [{"userId": "u_1", "kinds": ["video"]}]}; None = everyone may receive."""
|
|
156
|
+
return self.update_participant(room_id, user_id, track_permissions=track_permissions)
|
|
157
|
+
|
|
158
|
+
def remove_participant(self, room_id: str, user_id: str, ban: bool = False) -> int:
|
|
159
|
+
q = "?ban=true" if ban else ""
|
|
160
|
+
return self.request("DELETE", f"/rooms/{_id(room_id)}/participants/{_id(user_id)}{q}")["disconnected"]
|
|
161
|
+
|
|
162
|
+
def unban(self, room_id: str, user_id: str) -> bool:
|
|
163
|
+
return self.request("DELETE", f"/rooms/{_id(room_id)}/bans/{_id(user_id)}")["removed"]
|
|
164
|
+
|
|
165
|
+
def mute(self, room_id: str, user_id: str, devices: Sequence[str] = ("mic",), lock: bool = False) -> int:
|
|
166
|
+
"""Turn off devices of one user, or of everyone without `moderate` ("*")."""
|
|
167
|
+
body = {"userId": user_id, "devices": list(devices), "lock": lock}
|
|
168
|
+
return self.request("POST", f"/rooms/{_id(room_id)}/mute", body)["muted"]
|
|
169
|
+
|
|
170
|
+
# ---------- classroom features ----------
|
|
171
|
+
|
|
172
|
+
def admit(self, room_id: str, user_id: str) -> int:
|
|
173
|
+
return self.request("POST", f"/rooms/{_id(room_id)}/admit", {"userId": user_id})["admitted"]
|
|
174
|
+
|
|
175
|
+
def post_chat(self, room_id: str, text: str, to: Optional[str] = None) -> Any:
|
|
176
|
+
body: Dict[str, Any] = {"text": text}
|
|
177
|
+
if to:
|
|
178
|
+
body["to"] = to
|
|
179
|
+
return self.request("POST", f"/rooms/{_id(room_id)}/chat", body)["message"]
|
|
180
|
+
|
|
181
|
+
def lower_hands(self, room_id: str, user_id: Optional[str] = None) -> int:
|
|
182
|
+
if user_id:
|
|
183
|
+
return self.request("DELETE", f"/rooms/{_id(room_id)}/hands/{_id(user_id)}")["updated"]
|
|
184
|
+
return self.request("DELETE", f"/rooms/{_id(room_id)}/hands")["lowered"]
|
|
185
|
+
|
|
186
|
+
def get_attendance(self, room_id: str) -> Any:
|
|
187
|
+
return self.request("GET", f"/rooms/{_id(room_id)}/attendance")
|
|
188
|
+
|
|
189
|
+
def get_attendance_csv(self, room_id: str) -> str:
|
|
190
|
+
return self.request("GET", f"/rooms/{_id(room_id)}/attendance?format=csv", raw=True)
|
|
191
|
+
|
|
192
|
+
def clear_attendance(self, room_id: str) -> None:
|
|
193
|
+
self.request("DELETE", f"/rooms/{_id(room_id)}/attendance")
|
|
194
|
+
|
|
195
|
+
def start_breakouts(self, room_id: str, groups: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
196
|
+
"""groups: [{"name": "Group 1", "userIds": [...]}, ...]."""
|
|
197
|
+
return self.request("PUT", f"/rooms/{_id(room_id)}/breakouts", {"groups": groups})["groups"]
|
|
198
|
+
|
|
199
|
+
def end_breakouts(self, room_id: str) -> None:
|
|
200
|
+
self.request("DELETE", f"/rooms/{_id(room_id)}/breakouts")
|
|
201
|
+
|
|
202
|
+
def move_to_group(self, room_id: str, user_id: str, group: Optional[str]) -> int:
|
|
203
|
+
return self.request("POST", f"/rooms/{_id(room_id)}/move", {"userId": user_id, "group": group})["moved"]
|
|
204
|
+
|
|
205
|
+
# ---------- recording (needs the kit's STORAGE bucket; video needs the recorder bot) ----------
|
|
206
|
+
|
|
207
|
+
def start_recording(
|
|
208
|
+
self, room_id: str, video: bool = False, composite: Optional[bool] = None, max_minutes: Optional[int] = None
|
|
209
|
+
) -> Dict[str, Any]:
|
|
210
|
+
"""Start recording to R2; video=True records audio + video with the recorder bot."""
|
|
211
|
+
body: Dict[str, Any] = {"video": video}
|
|
212
|
+
if composite is not None:
|
|
213
|
+
body["composite"] = composite
|
|
214
|
+
if max_minutes is not None:
|
|
215
|
+
body["maxMinutes"] = max_minutes
|
|
216
|
+
return self.request("POST", f"/rooms/{_id(room_id)}/recording", body)["recording"]
|
|
217
|
+
|
|
218
|
+
def recording_status(self, room_id: str) -> Optional[Dict[str, Any]]:
|
|
219
|
+
"""The recording in progress, or None."""
|
|
220
|
+
return self.request("GET", f"/rooms/{_id(room_id)}/recording")["recording"]
|
|
221
|
+
|
|
222
|
+
def stop_recording(self, room_id: str) -> Dict[str, Any]:
|
|
223
|
+
"""Stop recording; returns the manifest. SfuKitError 404 if nothing is recording."""
|
|
224
|
+
return self.request("DELETE", f"/rooms/{_id(room_id)}/recording")
|
|
225
|
+
|
|
226
|
+
def list_recordings(self, room_id: str) -> List[Dict[str, Any]]:
|
|
227
|
+
return self.request("GET", f"/rooms/{_id(room_id)}/recordings")["recordings"]
|
|
228
|
+
|
|
229
|
+
def download_recording(self, room_id: str, recording_id: str, file: str) -> bytes:
|
|
230
|
+
q = urllib.parse.quote
|
|
231
|
+
return self.request("GET", f"/rooms/{_id(room_id)}/recordings/{q(recording_id, safe='')}/{q(file, safe='')}", raw=bytes)
|
|
232
|
+
|
|
233
|
+
# ---------- AI lessons (an AI tutor, or an AI-taught class) ----------
|
|
234
|
+
|
|
235
|
+
def start_lesson(self, room_id: str, lesson: Optional[Dict[str, Any]] = None, mode: str = "class", **options: Any) -> Dict[str, Any]:
|
|
236
|
+
"""Start an AI lesson. lesson: {"title", "subject"?, "level"?, "curriculum"?, "objectives"?, "sections" or "text"},
|
|
237
|
+
or lesson=None with planId="..." (an approved plan from the lesson library; needed when review is required).
|
|
238
|
+
options: learnerId, floor ("hands"|"teacher"|"off"), scope ("strict"|"guided"|"open"), maxMinutes, llmModel, voice, avatar…
|
|
239
|
+
Returns {"lessonId", "agentId", "participant"}."""
|
|
240
|
+
return self.request("POST", f"/rooms/{_id(room_id)}/lessons", {"mode": mode, **({"lesson": lesson} if lesson is not None else {}), **options})
|
|
241
|
+
|
|
242
|
+
def get_lesson(self, room_id: str, lesson_id: str) -> Dict[str, Any]:
|
|
243
|
+
return self.request("GET", f"/rooms/{_id(room_id)}/lessons/{_id(lesson_id)}")
|
|
244
|
+
|
|
245
|
+
def control_lesson(self, room_id: str, lesson_id: str, action: str, **args: Any) -> None:
|
|
246
|
+
"""action: pause, resume, end, goto (section=), call-on (userId=), floor-policy (policy=), scope (scope=), note (text=)."""
|
|
247
|
+
self.request("POST", f"/rooms/{_id(room_id)}/lessons/{_id(lesson_id)}/control", {"action": action, **args})
|
|
248
|
+
|
|
249
|
+
def stop_lesson(self, room_id: str, lesson_id: str) -> None:
|
|
250
|
+
self.request("DELETE", f"/rooms/{_id(room_id)}/lessons/{_id(lesson_id)}")
|
|
251
|
+
|
|
252
|
+
def lesson_report(self, lesson_id: str) -> Dict[str, Any]:
|
|
253
|
+
"""A finished lesson's report: summary, per-learner results and feedback, alerts, transcript."""
|
|
254
|
+
return self.request("GET", f"/lessons/{_id(lesson_id)}/report")
|
|
255
|
+
|
|
256
|
+
def learner_progress(self, learner_id: str) -> Dict[str, Any]:
|
|
257
|
+
return self.request("GET", f"/learners/{urllib.parse.quote(learner_id, safe='')}/progress")
|
|
258
|
+
|
|
259
|
+
def delete_learner_progress(self, learner_id: str) -> None:
|
|
260
|
+
self.request("DELETE", f"/learners/{urllib.parse.quote(learner_id, safe='')}/progress")
|
|
261
|
+
|
|
262
|
+
# ---------- lesson library: drafts, review, courses, timetables ----------
|
|
263
|
+
|
|
264
|
+
def lesson_settings(self) -> Dict[str, Any]:
|
|
265
|
+
"""{"requireReview": bool, "defaults": {...}}. With requireReview on (the default) only approved plans are taught."""
|
|
266
|
+
return self.request("GET", "/lesson-settings")
|
|
267
|
+
|
|
268
|
+
def set_lesson_settings(self, require_review: Optional[bool] = None, defaults: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
269
|
+
body: Dict[str, Any] = {}
|
|
270
|
+
if require_review is not None:
|
|
271
|
+
body["requireReview"] = require_review
|
|
272
|
+
if defaults is not None:
|
|
273
|
+
body["defaults"] = defaults
|
|
274
|
+
return self.request("PUT", "/lesson-settings", body)
|
|
275
|
+
|
|
276
|
+
def list_lesson_plans(self, status: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
277
|
+
"""Plans in the library; status: "draft", "approved" or "archived"."""
|
|
278
|
+
return self.request("GET", "/lesson-plans" + (f"?status={urllib.parse.quote(status)}" if status else ""))["plans"]
|
|
279
|
+
|
|
280
|
+
def create_lesson_plan(self, **fields: Any) -> Dict[str, Any]:
|
|
281
|
+
"""A written plan=... (saved as a draft), or material for the AI to draft from: text=, link= (Google
|
|
282
|
+
Docs/Slides), topic=, curriculum=, exam={"board", "subject", "year"}. AI drafts come back with
|
|
283
|
+
draftState "processing"; the lesson_plan_ready webhook says when they're ready to review."""
|
|
284
|
+
return self.request("POST", "/lesson-plans", fields)
|
|
285
|
+
|
|
286
|
+
def upload_lesson_material(self, files: Sequence[tuple], **fields: Any) -> Dict[str, Any]:
|
|
287
|
+
"""files: [(filename, bytes), ...] (PDF, .docx, .pptx, text, Markdown, HTML, RTF, photos).
|
|
288
|
+
fields: the same options as create_lesson_plan (topic, level, language...)."""
|
|
289
|
+
boundary = "sfukit" + hashlib.sha256(str(time.time()).encode()).hexdigest()[:24]
|
|
290
|
+
parts: List[bytes] = []
|
|
291
|
+
for k, v in fields.items():
|
|
292
|
+
parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode("utf-8"))
|
|
293
|
+
for name, data in files:
|
|
294
|
+
safe = str(name).replace('"', "").replace("\r", "").replace("\n", "")
|
|
295
|
+
parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{safe}"\r\nContent-Type: application/octet-stream\r\n\r\n'.encode("utf-8") + bytes(data) + b"\r\n")
|
|
296
|
+
parts.append(f"--{boundary}--\r\n".encode("utf-8"))
|
|
297
|
+
return self.request("POST", "/lesson-plans/upload", _encoded=(b"".join(parts), f"multipart/form-data; boundary={boundary}"))
|
|
298
|
+
|
|
299
|
+
def get_lesson_plan(self, plan_id: str) -> Dict[str, Any]:
|
|
300
|
+
return self.request("GET", f"/lesson-plans/{_id(plan_id)}")
|
|
301
|
+
|
|
302
|
+
def update_lesson_plan(self, plan_id: str, **changes: Any) -> Dict[str, Any]:
|
|
303
|
+
"""Edit a plan. An approved plan keeps teaching its approved version until you approve again."""
|
|
304
|
+
return self.request("PUT", f"/lesson-plans/{_id(plan_id)}", changes)
|
|
305
|
+
|
|
306
|
+
def revise_lesson_plan(self, plan_id: str, instruction: str) -> Dict[str, Any]:
|
|
307
|
+
"""Ask the AI to change a plan, e.g. "make section 2 simpler"."""
|
|
308
|
+
return self.request("POST", f"/lesson-plans/{_id(plan_id)}/revise", {"instruction": instruction})
|
|
309
|
+
|
|
310
|
+
def approve_lesson_plan(self, plan_id: str) -> Dict[str, Any]:
|
|
311
|
+
"""Approve the plan as it is now (a new version). Start lessons with planId=... to teach it."""
|
|
312
|
+
return self.request("POST", f"/lesson-plans/{_id(plan_id)}/approve")
|
|
313
|
+
|
|
314
|
+
def lesson_plan_versions(self, plan_id: str) -> List[Dict[str, Any]]:
|
|
315
|
+
return self.request("GET", f"/lesson-plans/{_id(plan_id)}/versions")["versions"]
|
|
316
|
+
|
|
317
|
+
def trial_lesson_plan(self, plan_id: str, room_id: Optional[str] = None) -> Dict[str, Any]:
|
|
318
|
+
"""A 5-minute tutor run of the plan as it is now (even unapproved), with no reports or emails."""
|
|
319
|
+
return self.request("POST", f"/lesson-plans/{_id(plan_id)}/trial", {"roomId": room_id} if room_id else {})
|
|
320
|
+
|
|
321
|
+
def archive_lesson_plan(self, plan_id: str) -> None:
|
|
322
|
+
self.request("DELETE", f"/lesson-plans/{_id(plan_id)}")
|
|
323
|
+
|
|
324
|
+
def list_courses(self) -> List[Dict[str, Any]]:
|
|
325
|
+
return self.request("GET", "/courses")["courses"]
|
|
326
|
+
|
|
327
|
+
def save_course(self, course: Dict[str, Any]) -> Dict[str, Any]:
|
|
328
|
+
"""Create a course {"title", "subject"?, "level"?, "plans": [planIds in order]}, or update one that has an "id"."""
|
|
329
|
+
body = {k: v for k, v in course.items() if k != "id"}
|
|
330
|
+
if course.get("id"):
|
|
331
|
+
return self.request("PUT", f"/courses/{_id(course['id'])}", body)
|
|
332
|
+
return self.request("POST", "/courses", body)
|
|
333
|
+
|
|
334
|
+
def delete_course(self, course_id: str) -> None:
|
|
335
|
+
self.request("DELETE", f"/courses/{_id(course_id)}")
|
|
336
|
+
|
|
337
|
+
def list_timetables(self) -> List[Dict[str, Any]]:
|
|
338
|
+
return self.request("GET", "/timetables")["timetables"]
|
|
339
|
+
|
|
340
|
+
def save_timetable(self, timetable: Dict[str, Any], allow_clashes: bool = False) -> Dict[str, Any]:
|
|
341
|
+
"""Create a timetable, or update one that has an "id". Two lessons in one room at once → SfuKitError 409
|
|
342
|
+
with the clashes in .body, unless allow_clashes=True."""
|
|
343
|
+
body = {k: v for k, v in timetable.items() if k != "id"}
|
|
344
|
+
q = "?allowClashes=true" if allow_clashes else ""
|
|
345
|
+
if timetable.get("id"):
|
|
346
|
+
return self.request("PUT", f"/timetables/{_id(timetable['id'])}{q}", body)
|
|
347
|
+
return self.request("POST", f"/timetables{q}", body)
|
|
348
|
+
|
|
349
|
+
def delete_timetable(self, timetable_id: str) -> None:
|
|
350
|
+
self.request("DELETE", f"/timetables/{_id(timetable_id)}")
|
|
351
|
+
|
|
352
|
+
def timetable_runs(self, days: int = 7, timetable_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
353
|
+
"""Scheduled lessons in the next `days` days, with their status."""
|
|
354
|
+
q = {"days": str(days)}
|
|
355
|
+
if timetable_id:
|
|
356
|
+
q["timetableId"] = timetable_id
|
|
357
|
+
return self.request("GET", "/timetable-runs?" + urllib.parse.urlencode(q))["runs"]
|
|
358
|
+
|
|
359
|
+
def start_timetable_run(self, key: str) -> Dict[str, Any]:
|
|
360
|
+
return self.request("POST", f"/timetable-runs/{urllib.parse.quote(key, safe='')}/start")
|
|
361
|
+
|
|
362
|
+
def skip_timetable_run(self, key: str) -> Dict[str, Any]:
|
|
363
|
+
return self.request("POST", f"/timetable-runs/{urllib.parse.quote(key, safe='')}/skip")
|
|
364
|
+
|
|
365
|
+
def health(self) -> Dict[str, Any]:
|
|
366
|
+
return self.request("GET", "/health")
|
|
367
|
+
|
|
368
|
+
# ---------- plumbing ----------
|
|
369
|
+
|
|
370
|
+
def request(self, method: str, path: str, body: Any = None, raw: Any = False, _encoded: Optional[tuple] = None) -> Any:
|
|
371
|
+
"""Call any admin endpoint (for routes this SDK doesn't wrap yet). raw=True: text, raw=bytes: bytes."""
|
|
372
|
+
headers = {"Authorization": f"Bearer {self._admin_key}", "User-Agent": f"sfukit-python/{__version__}"}
|
|
373
|
+
data = None
|
|
374
|
+
if _encoded is not None:
|
|
375
|
+
data, headers["Content-Type"] = _encoded
|
|
376
|
+
elif body is not None:
|
|
377
|
+
data = _json(body).encode("utf-8")
|
|
378
|
+
headers["Content-Type"] = "application/json"
|
|
379
|
+
req = urllib.request.Request(self.url + path, data=data, headers=headers, method=method)
|
|
380
|
+
try:
|
|
381
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as res:
|
|
382
|
+
data_out = res.read()
|
|
383
|
+
if raw is bytes:
|
|
384
|
+
return data_out
|
|
385
|
+
text = data_out.decode("utf-8")
|
|
386
|
+
except urllib.error.HTTPError as e:
|
|
387
|
+
text = e.read().decode("utf-8", "replace")
|
|
388
|
+
try:
|
|
389
|
+
parsed: Any = json.loads(text)
|
|
390
|
+
except ValueError:
|
|
391
|
+
parsed = text
|
|
392
|
+
msg = parsed.get("error", text) if isinstance(parsed, dict) else text
|
|
393
|
+
raise SfuKitError(e.code, f"sfukit {method} {path}: {e.code} {msg}", parsed) from None
|
|
394
|
+
if raw:
|
|
395
|
+
return text
|
|
396
|
+
return json.loads(text) if text else None
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _b64url(data: bytes) -> str:
|
|
400
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
DATA_SAVER_MODES = ("off", "low", "minimal", "audio")
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def sign_token(
|
|
407
|
+
jwt_secret: str,
|
|
408
|
+
room_id: str,
|
|
409
|
+
user_id: str,
|
|
410
|
+
*,
|
|
411
|
+
name: Optional[str] = None,
|
|
412
|
+
role: Optional[str] = None,
|
|
413
|
+
permissions: Optional[Dict[str, bool]] = None,
|
|
414
|
+
can_publish: Optional[bool] = None,
|
|
415
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
416
|
+
max_participants: Optional[int] = None,
|
|
417
|
+
ttl_seconds: Optional[int] = None,
|
|
418
|
+
data_saver: Optional[str] = None,
|
|
419
|
+
) -> Dict[str, Any]:
|
|
420
|
+
"""Sign a join token with JWT_SECRET, without calling the kit (same as POST /tokens)."""
|
|
421
|
+
if not jwt_secret or len(jwt_secret) < 32:
|
|
422
|
+
raise ValueError("jwt_secret must be at least 32 characters")
|
|
423
|
+
if not _ID.match(room_id or "") or not _ID.match(user_id or ""):
|
|
424
|
+
raise ValueError("room_id and user_id must be 1-128 chars of letters, numbers, . _ : @ -")
|
|
425
|
+
if role is not None and not _ROLE.match(role):
|
|
426
|
+
raise ValueError("role must be 1-32 chars of letters, numbers, . _ -")
|
|
427
|
+
pub = True if can_publish is None else bool(can_publish)
|
|
428
|
+
perm = dict(_ROLE_DEFAULTS[role]) if role in _ROLE_DEFAULTS else {"mic": pub, "camera": pub, "screen": pub, "moderate": False}
|
|
429
|
+
for k in ("mic", "camera", "screen", "moderate"):
|
|
430
|
+
v = (permissions or {}).get(k)
|
|
431
|
+
if isinstance(v, bool):
|
|
432
|
+
perm[k] = v
|
|
433
|
+
if metadata is not None and (not isinstance(metadata, dict) or len(_json(metadata)) > 4096):
|
|
434
|
+
raise ValueError("metadata must be a dict of at most 4 KB as JSON")
|
|
435
|
+
if max_participants is not None and (
|
|
436
|
+
not isinstance(max_participants, int) or isinstance(max_participants, bool) or not 2 <= max_participants <= 10000
|
|
437
|
+
):
|
|
438
|
+
raise ValueError("max_participants must be an integer 2-10000")
|
|
439
|
+
if data_saver is not None and data_saver not in DATA_SAVER_MODES:
|
|
440
|
+
raise ValueError('data_saver must be "off", "low", "minimal" or "audio"')
|
|
441
|
+
ttl = min(max(7200 if ttl_seconds is None else ttl_seconds, 60), 86400)
|
|
442
|
+
now = int(time.time())
|
|
443
|
+
exp = now + ttl
|
|
444
|
+
payload: Dict[str, Any] = {
|
|
445
|
+
"room": room_id,
|
|
446
|
+
"name": (name if name is not None else user_id)[:80],
|
|
447
|
+
"pub": perm["mic"] or perm["camera"] or perm["screen"],
|
|
448
|
+
"perm": perm,
|
|
449
|
+
}
|
|
450
|
+
if role:
|
|
451
|
+
payload["role"] = role
|
|
452
|
+
if max_participants is not None:
|
|
453
|
+
payload["max"] = max_participants
|
|
454
|
+
if data_saver and data_saver != "off":
|
|
455
|
+
payload["ds"] = data_saver
|
|
456
|
+
if metadata is not None:
|
|
457
|
+
payload["md"] = metadata
|
|
458
|
+
payload.update({"sub": user_id, "aud": "sfukit", "iat": now, "exp": exp})
|
|
459
|
+
signing_input = _b64url(_json({"alg": "HS256", "typ": "JWT"}).encode()) + "." + _b64url(_json(payload).encode())
|
|
460
|
+
sig = hmac.new(jwt_secret.encode(), signing_input.encode(), hashlib.sha256).digest()
|
|
461
|
+
return {"token": f"{signing_input}.{_b64url(sig)}", "expiresAt": exp}
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def verify_webhook(
|
|
465
|
+
secret: str, raw_body: Union[str, bytes], signature_header: Optional[str], tolerance_seconds: int = 300
|
|
466
|
+
) -> Optional[Dict[str, Any]]:
|
|
467
|
+
"""Check a webhook delivery; returns the event dict, or None if the signature is wrong or too old.
|
|
468
|
+
|
|
469
|
+
Pass the RAW body (bytes or str, not re-serialized JSON) and the X-SfuKit-Signature header.
|
|
470
|
+
`secret` is WEBHOOK_SECRET (or ADMIN_API_KEY if none was set).
|
|
471
|
+
"""
|
|
472
|
+
if not secret or not signature_header:
|
|
473
|
+
return None
|
|
474
|
+
parts = {}
|
|
475
|
+
for p in signature_header.split(","):
|
|
476
|
+
k, _, v = p.partition("=")
|
|
477
|
+
parts[k.strip()] = v.strip()
|
|
478
|
+
try:
|
|
479
|
+
t = int(parts.get("t", ""))
|
|
480
|
+
except ValueError:
|
|
481
|
+
return None
|
|
482
|
+
v1 = parts.get("v1", "")
|
|
483
|
+
if not re.fullmatch(r"[0-9a-f]{64}", v1) or abs(time.time() - t) > tolerance_seconds:
|
|
484
|
+
return None
|
|
485
|
+
body = raw_body if isinstance(raw_body, bytes) else raw_body.encode("utf-8")
|
|
486
|
+
expected = hmac.new(secret.encode(), str(t).encode() + b"." + body, hashlib.sha256).hexdigest()
|
|
487
|
+
if not hmac.compare_digest(expected, v1):
|
|
488
|
+
return None
|
|
489
|
+
try:
|
|
490
|
+
event = json.loads(body)
|
|
491
|
+
except ValueError:
|
|
492
|
+
return None
|
|
493
|
+
return event if isinstance(event, dict) else None
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def sign_webhook(secret: str, raw_body: Union[str, bytes], t: Optional[int] = None) -> str:
|
|
497
|
+
"""Build a signature header (for tests of your webhook endpoint)."""
|
|
498
|
+
t = int(time.time()) if t is None else t
|
|
499
|
+
body = raw_body if isinstance(raw_body, bytes) else raw_body.encode("utf-8")
|
|
500
|
+
return f"t={t},v1=" + hmac.new(secret.encode(), str(t).encode() + b"." + body, hashlib.sha256).hexdigest()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: appsocom
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Python server SDK for Appsocom Live video calls: join tokens, rooms, moderation and webhook verification
|
|
5
|
+
Author: Appsotech Limited
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://live.appsocom.com
|
|
8
|
+
Project-URL: Documentation, https://docs.appsocom.dev/server-sdks
|
|
9
|
+
Keywords: appsocom,video,calls,webrtc,sfu,cloudflare
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Communications :: Conferencing
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# appsocom (Python)
|
|
21
|
+
|
|
22
|
+
Python server SDK for [Appsocom Live](https://live.appsocom.com): mint join tokens, manage rooms and participants, moderate and verify webhooks. Python 3.9+, standard library only.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install appsocom
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import os
|
|
30
|
+
from appsocom import SfuKit, SfuKitError, verify_webhook
|
|
31
|
+
|
|
32
|
+
kit = SfuKit("https://api.appsocom.dev", os.environ["APPSOCOM_API_KEY"]) # project API key from the console
|
|
33
|
+
|
|
34
|
+
tok = kit.create_token("class-1", "u_7", name="Ada", role="student", metadata={"seat": 3})
|
|
35
|
+
tok["token"], tok["expiresAt"]
|
|
36
|
+
|
|
37
|
+
kit.set_role("class-1", "u_9", "student") # promote an observer
|
|
38
|
+
kit.mute("class-1", "*", ["mic"], lock=True) # mute everyone, keep them muted
|
|
39
|
+
kit.set_room_metadata("class-1", {"topic": "Fractions"})
|
|
40
|
+
kit.set_track_permissions("class-1", "u_7", {"allowAll": False, "allow": [{"userId": "u_1"}]})
|
|
41
|
+
kit.remove_participant("class-1", "u_8", ban=True)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Errors raise `SfuKitError` with `.status` and `.body`.
|
|
45
|
+
|
|
46
|
+
Webhooks (Flask):
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
@app.post("/appsocom-webhooks")
|
|
50
|
+
def hook():
|
|
51
|
+
event = verify_webhook(os.environ["APPSOCOM_WEBHOOK_SECRET"], request.get_data(), request.headers.get("X-SfuKit-Signature"))
|
|
52
|
+
if event is None:
|
|
53
|
+
return "", 401
|
|
54
|
+
print(event["event"], event["roomId"])
|
|
55
|
+
return "", 204
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
All methods: `create_token`, `get_room`, `list_participants`, `update_room`, `set_room_metadata`, `lock_room`, `close_room`, `update_participant`, `set_permissions`, `set_role`, `set_participant_metadata`, `set_track_permissions`, `remove_participant`, `unban`, `mute`, `admit`, `post_chat`, `lower_hands`, `get_attendance`, `get_attendance_csv`, `clear_attendance`, `start_breakouts`, `end_breakouts`, `move_to_group`, `health`, `request()`. Standalone: `sign_token`, `verify_webhook`, `sign_webhook`.
|
|
59
|
+
|
|
60
|
+
Self-hosting your own deployment instead? Point `url` at it and pass its admin key; the optional `jwt_secret` signs tokens locally without an HTTP call.
|
|
61
|
+
|
|
62
|
+
Docs: [docs.appsocom.dev](https://docs.appsocom.dev) · License: MIT
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
appsocom/__init__.py,sha256=iI62W2CHQUU1hLAqatZvgKXO2xiZtPfEVCK4HWpMN2s,25014
|
|
2
|
+
appsocom-0.5.0.dist-info/licenses/LICENSE,sha256=ZOybpglT4A_kVgE00vBRt4BEzRzbS2zw5t6iawvtDig,1074
|
|
3
|
+
appsocom-0.5.0.dist-info/METADATA,sha256=u9p9hMDkDUN3rLyclsct71zFdoA94VFrpYmet_FKtcA,2742
|
|
4
|
+
appsocom-0.5.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
5
|
+
appsocom-0.5.0.dist-info/top_level.txt,sha256=Tf5FNlUTYK4WSm07tqAp6Rw2IGh1OmAdNzpZEb8iSzo,9
|
|
6
|
+
appsocom-0.5.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Appsotech Limited
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
appsocom
|