appsocom 0.5.0__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.
appsocom-0.5.0/LICENSE ADDED
@@ -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,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,43 @@
1
+ # appsocom (Python)
2
+
3
+ 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.
4
+
5
+ ```bash
6
+ pip install appsocom
7
+ ```
8
+
9
+ ```python
10
+ import os
11
+ from appsocom import SfuKit, SfuKitError, verify_webhook
12
+
13
+ kit = SfuKit("https://api.appsocom.dev", os.environ["APPSOCOM_API_KEY"]) # project API key from the console
14
+
15
+ tok = kit.create_token("class-1", "u_7", name="Ada", role="student", metadata={"seat": 3})
16
+ tok["token"], tok["expiresAt"]
17
+
18
+ kit.set_role("class-1", "u_9", "student") # promote an observer
19
+ kit.mute("class-1", "*", ["mic"], lock=True) # mute everyone, keep them muted
20
+ kit.set_room_metadata("class-1", {"topic": "Fractions"})
21
+ kit.set_track_permissions("class-1", "u_7", {"allowAll": False, "allow": [{"userId": "u_1"}]})
22
+ kit.remove_participant("class-1", "u_8", ban=True)
23
+ ```
24
+
25
+ Errors raise `SfuKitError` with `.status` and `.body`.
26
+
27
+ Webhooks (Flask):
28
+
29
+ ```python
30
+ @app.post("/appsocom-webhooks")
31
+ def hook():
32
+ event = verify_webhook(os.environ["APPSOCOM_WEBHOOK_SECRET"], request.get_data(), request.headers.get("X-SfuKit-Signature"))
33
+ if event is None:
34
+ return "", 401
35
+ print(event["event"], event["roomId"])
36
+ return "", 204
37
+ ```
38
+
39
+ 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`.
40
+
41
+ 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.
42
+
43
+ Docs: [docs.appsocom.dev](https://docs.appsocom.dev) · License: MIT
@@ -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,9 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ appsocom/__init__.py
5
+ appsocom.egg-info/PKG-INFO
6
+ appsocom.egg-info/SOURCES.txt
7
+ appsocom.egg-info/dependency_links.txt
8
+ appsocom.egg-info/top_level.txt
9
+ tests/test_sfukit.py
@@ -0,0 +1 @@
1
+ appsocom
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "appsocom"
7
+ version = "0.5.0"
8
+ description = "Python server SDK for Appsocom Live video calls: join tokens, rooms, moderation and webhook verification"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Appsotech Limited" }]
14
+ keywords = ["appsocom", "video", "calls", "webrtc", "sfu", "cloudflare"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Communications :: Conferencing",
21
+ ]
22
+ dependencies = []
23
+
24
+ [project.urls]
25
+ Homepage = "https://live.appsocom.com"
26
+ Documentation = "https://docs.appsocom.dev/server-sdks"
27
+
28
+ [tool.setuptools]
29
+ packages = ["appsocom"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,142 @@
1
+ import base64
2
+ import hashlib
3
+ import hmac
4
+ import json
5
+ import threading
6
+ import time
7
+ import unittest
8
+ from http.server import BaseHTTPRequestHandler, HTTPServer
9
+
10
+ from appsocom import SfuKit, SfuKitError, sign_token, sign_webhook, verify_webhook
11
+
12
+ SECRET = "x" * 40
13
+
14
+
15
+ def decode(part):
16
+ return json.loads(base64.urlsafe_b64decode(part + "=" * (-len(part) % 4)))
17
+
18
+
19
+ class TokenTests(unittest.TestCase):
20
+ def test_signs_hs256_with_role_defaults_and_metadata(self):
21
+ t = sign_token(SECRET, "r1", "u1", role="student", metadata={"seat": 3}, permissions={"screen": True})
22
+ head, body, sig = t["token"].split(".")
23
+ self.assertEqual(decode(head), {"alg": "HS256", "typ": "JWT"})
24
+ claims = decode(body)
25
+ self.assertEqual(claims["sub"], "u1")
26
+ self.assertEqual(claims["room"], "r1")
27
+ self.assertEqual(claims["aud"], "sfukit")
28
+ self.assertEqual(claims["role"], "student")
29
+ self.assertEqual(claims["md"], {"seat": 3})
30
+ self.assertEqual(claims["perm"], {"mic": True, "camera": True, "screen": True, "moderate": False})
31
+ self.assertEqual(claims["exp"] - claims["iat"], 7200)
32
+ want = hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()
33
+ self.assertEqual(base64.urlsafe_b64encode(want).rstrip(b"=").decode(), sig)
34
+
35
+ def test_rejects_bad_input(self):
36
+ with self.assertRaises(ValueError):
37
+ sign_token("short", "r1", "u1")
38
+ with self.assertRaises(ValueError):
39
+ sign_token(SECRET, "bad room", "u1")
40
+ with self.assertRaises(ValueError):
41
+ sign_token(SECRET, "r1", "u1", max_participants=1)
42
+ with self.assertRaises(ValueError):
43
+ sign_token(SECRET, "r1", "u1", metadata={"x": "y" * 5000})
44
+
45
+ def test_client_signs_locally_with_secret(self):
46
+ kit = SfuKit("http://127.0.0.1:1", "k", jwt_secret=SECRET)
47
+ self.assertEqual(decode(kit.create_token("r1", "u1")["token"].split(".")[1])["sub"], "u1")
48
+
49
+
50
+ class WebhookTests(unittest.TestCase):
51
+ def test_round_trip(self):
52
+ body = '{"id":"evt_1","event":"room_started"}'
53
+ sig = sign_webhook("s", body)
54
+ self.assertEqual(verify_webhook("s", body, sig)["id"], "evt_1")
55
+ self.assertEqual(verify_webhook("s", body.encode(), sig)["id"], "evt_1")
56
+ self.assertIsNone(verify_webhook("s", body + " ", sig))
57
+ self.assertIsNone(verify_webhook("other", body, sig))
58
+ self.assertIsNone(verify_webhook("s", body, None))
59
+ old = sign_webhook("s", body, int(time.time()) - 600)
60
+ self.assertIsNone(verify_webhook("s", body, old))
61
+
62
+
63
+ class Handler(BaseHTTPRequestHandler):
64
+ seen = []
65
+
66
+ def log_message(self, *a):
67
+ pass
68
+
69
+ def _go(self):
70
+ n = int(self.headers.get("Content-Length") or 0)
71
+ body = json.loads(self.rfile.read(n)) if n else None
72
+ Handler.seen.append((self.command, self.path, self.headers.get("Authorization"), body))
73
+ if self.path.startswith("/rooms/missing"):
74
+ out, code = {"error": "nope"}, 404
75
+ elif self.command == "PATCH" and "/participants/" in self.path:
76
+ out, code = {"updated": 1}, 200
77
+ elif self.command == "PATCH":
78
+ out, code = {"room": {"locked": True}}, 200
79
+ else:
80
+ out, code = {"disconnected": 1, "muted": 2}, 200
81
+ data = json.dumps(out).encode()
82
+ self.send_response(code)
83
+ self.send_header("Content-Type", "application/json")
84
+ self.send_header("Content-Length", str(len(data)))
85
+ self.end_headers()
86
+ self.wfile.write(data)
87
+
88
+ do_GET = do_POST = do_PATCH = do_DELETE = do_PUT = _go
89
+
90
+
91
+ class AdminTests(unittest.TestCase):
92
+ @classmethod
93
+ def setUpClass(cls):
94
+ cls.server = HTTPServer(("127.0.0.1", 0), Handler)
95
+ threading.Thread(target=cls.server.serve_forever, daemon=True).start()
96
+ cls.kit = SfuKit(f"http://127.0.0.1:{cls.server.server_port}/", "k")
97
+
98
+ @classmethod
99
+ def tearDownClass(cls):
100
+ cls.server.shutdown()
101
+
102
+ def test_calls(self):
103
+ kit = self.kit
104
+ self.assertEqual(kit.set_role("r1", "u1", "student"), 1)
105
+ self.assertEqual(Handler.seen[-1], ("PATCH", "/rooms/r1/participants/u1", "Bearer k", {"role": "student"}))
106
+ kit.set_participant_metadata("r1", "u1", None)
107
+ self.assertEqual(Handler.seen[-1][3], {"metadata": None})
108
+ self.assertEqual(kit.mute("r1", "*", ["mic"], lock=True), 2)
109
+ self.assertEqual(Handler.seen[-1][3], {"userId": "*", "devices": ["mic"], "lock": True})
110
+ self.assertEqual(kit.remove_participant("r1", "u1", ban=True), 1)
111
+ self.assertEqual(Handler.seen[-1][1], "/rooms/r1/participants/u1?ban=true")
112
+ self.assertTrue(kit.lock_room("r1")["locked"])
113
+ kit.update_room("r1", waiting_room=True)
114
+ self.assertEqual(Handler.seen[-1][3], {"waitingRoom": True})
115
+
116
+ def test_errors(self):
117
+ with self.assertRaises(SfuKitError) as e:
118
+ self.kit.get_room("missing")
119
+ self.assertEqual(e.exception.status, 404)
120
+ self.assertIn("nope", str(e.exception))
121
+ with self.assertRaises(SfuKitError) as e:
122
+ self.kit.get_room("bad room!")
123
+ self.assertEqual(e.exception.status, 400)
124
+
125
+
126
+ if __name__ == "__main__":
127
+ unittest.main()
128
+
129
+
130
+ class DataSaverTokenTest(unittest.TestCase):
131
+ def test_data_saver_claim(self):
132
+ import base64
133
+ import json as _j
134
+
135
+ def claims(tok):
136
+ p = tok.split(".")[1]
137
+ return _j.loads(base64.urlsafe_b64decode(p + "=" * (-len(p) % 4)))
138
+
139
+ self.assertEqual(claims(sign_token(SECRET, "r1", "u1", data_saver="audio")["token"])["ds"], "audio")
140
+ self.assertNotIn("ds", claims(sign_token(SECRET, "r1", "u1", data_saver="off")["token"]))
141
+ with self.assertRaises(ValueError):
142
+ sign_token(SECRET, "r1", "u1", data_saver="ultra")