voxa-code 0.1.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.
server/hooks.py ADDED
@@ -0,0 +1,202 @@
1
+ """Claude Code hook integration.
2
+
3
+ The reliable, terminal-agnostic way to know a Claude session finished or needs
4
+ input is Claude Code's own lifecycle hooks (Stop / Notification), not screen
5
+ scraping. We install a global hook in ~/.claude/settings.json that POSTs the
6
+ hook's stdin JSON to the local Voxa server's /hook endpoint; the server then
7
+ rings the phone (or speaks, if a line is attached) exactly like the watcher did.
8
+
9
+ This module is pure/host-agnostic so it is fully unit-testable:
10
+ - last_assistant_text: pull a spoken summary from a transcript JSONL
11
+ - route_hook: decide what (if anything) to announce for a hook event
12
+ - merge_hook / install_claude_hook / uninstall_claude_hook: edit settings.json
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import shlex
20
+
21
+ # Identifies OUR hook entries inside settings.json so install is idempotent and
22
+ # uninstall is precise (it is a shell comment on the command, ignored at runtime).
23
+ MARKER = "voxa-hook"
24
+
25
+ # Events we register globally. Stop = finished a turn; Notification = needs
26
+ # permission / input; UserPromptSubmit = turn start (lets us time the turn so a
27
+ # quick interactive exchange doesn't ring the phone).
28
+ HOOK_EVENTS = ("Stop", "Notification", "UserPromptSubmit")
29
+
30
+
31
+ def last_assistant_text(transcript_path: str, max_len: int = 240) -> str:
32
+ """Return the last assistant message's text from a Claude transcript JSONL, as a
33
+ short spoken summary. Empty string if unreadable or none found."""
34
+ if not transcript_path or not os.path.exists(transcript_path):
35
+ return ""
36
+ last = ""
37
+ try:
38
+ with open(transcript_path, "r", encoding="utf-8", errors="ignore") as f:
39
+ for line in f:
40
+ line = line.strip()
41
+ if not line:
42
+ continue
43
+ try:
44
+ obj = json.loads(line)
45
+ except ValueError:
46
+ continue
47
+ if obj.get("type") != "assistant":
48
+ continue
49
+ msg = obj.get("message") or {}
50
+ content = msg.get("content")
51
+ if isinstance(content, str):
52
+ text = content
53
+ elif isinstance(content, list):
54
+ text = " ".join(
55
+ b.get("text", "") for b in content
56
+ if isinstance(b, dict) and b.get("type") == "text"
57
+ )
58
+ else:
59
+ text = ""
60
+ text = " ".join(text.split())
61
+ if text:
62
+ last = text
63
+ except OSError:
64
+ return ""
65
+ return last[:max_len]
66
+
67
+
68
+ def _label_for(cwd: str) -> str:
69
+ return os.path.basename((cwd or "").rstrip("/"))
70
+
71
+
72
+ def route_hook(body: dict, *, turn_start: dict, hook_last: dict, now: float,
73
+ min_seconds: float = 30.0, cooldown: float = 8.0,
74
+ read_transcript=last_assistant_text):
75
+ """Decide the announcement for a Claude Code hook event, or None to stay silent.
76
+
77
+ Mutates ``turn_start`` (per-session turn start time) and ``hook_last`` (per-session
78
+ last-announced time, for debounce). Pure otherwise, so it is easy to unit test.
79
+
80
+ - UserPromptSubmit -> record turn start, announce nothing.
81
+ - Stop -> announce "finished" UNLESS the turn was shorter than ``min_seconds`` (a
82
+ quick interactive exchange) or within the debounce ``cooldown``.
83
+ - Notification -> announce "needs input" (debounced).
84
+ """
85
+ event = body.get("hook_event_name") or body.get("hook_event") or ""
86
+ session = body.get("session_id") or ""
87
+ label = _label_for(body.get("cwd") or "")
88
+
89
+ if event == "UserPromptSubmit":
90
+ turn_start[session] = now
91
+ return None
92
+
93
+ if event == "Stop":
94
+ start = turn_start.pop(session, None)
95
+ if start is not None and (now - start) < min_seconds:
96
+ return None # quick interactive turn: don't call
97
+ if now - hook_last.get(session, float("-inf")) < cooldown:
98
+ return None # debounce repeated stops
99
+ hook_last[session] = now
100
+ summary = read_transcript(body.get("transcript_path", ""))
101
+ return f"{label or 'a session'} finished" + (f": {summary}" if summary else "")
102
+
103
+ if event == "Notification":
104
+ if now - hook_last.get(session, float("-inf")) < cooldown:
105
+ return None
106
+ hook_last[session] = now
107
+ summary = (body.get("message") or "").strip()
108
+ return f"{label or 'Claude'} needs input" + (f": {summary}" if summary else "")
109
+
110
+ return None
111
+
112
+
113
+ # --------------------------------------------------------------------------
114
+ # settings.json install / uninstall
115
+ # --------------------------------------------------------------------------
116
+
117
+
118
+ def hook_command(url: str) -> str:
119
+ """The shell command Claude Code runs for the hook: POST the event's stdin JSON to
120
+ the Voxa server. `; true` keeps the hook exit code 0 (a down server never blocks
121
+ Claude); the trailing comment is our MARKER for idempotent install/uninstall."""
122
+ return (f"curl -s -m 5 -X POST {shlex.quote(url)} "
123
+ f"-H 'Content-Type: application/json' --data-binary @- >/dev/null 2>&1 "
124
+ f"; true # {MARKER}")
125
+
126
+
127
+ def _is_ours(entry: dict) -> bool:
128
+ try:
129
+ return any(MARKER in (h.get("command") or "") for h in entry.get("hooks", []))
130
+ except (AttributeError, TypeError):
131
+ return False
132
+
133
+
134
+ def merge_hook(settings: dict, url: str) -> dict:
135
+ """Return settings with our Stop/Notification/UserPromptSubmit hooks merged in,
136
+ replacing any prior Voxa entries and preserving every other hook. Pure."""
137
+ out = dict(settings or {})
138
+ hooks = {k: list(v) for k, v in (out.get("hooks") or {}).items()}
139
+ entry = {"hooks": [{"type": "command", "command": hook_command(url)}]}
140
+ for event in HOOK_EVENTS:
141
+ kept = [e for e in hooks.get(event, []) if not _is_ours(e)]
142
+ kept.append(entry)
143
+ hooks[event] = kept
144
+ out["hooks"] = hooks
145
+ return out
146
+
147
+
148
+ def remove_hook(settings: dict) -> dict:
149
+ """Return settings with all Voxa hook entries removed; other hooks untouched."""
150
+ out = dict(settings or {})
151
+ hooks = {k: [e for e in v if not _is_ours(e)] for k, v in (out.get("hooks") or {}).items()}
152
+ hooks = {k: v for k, v in hooks.items() if v} # drop now-empty event lists
153
+ if hooks:
154
+ out["hooks"] = hooks
155
+ else:
156
+ out.pop("hooks", None)
157
+ return out
158
+
159
+
160
+ def _load(settings_path: str) -> dict:
161
+ """Load settings.json. Missing/empty -> {}. A malformed EXISTING file RAISES rather
162
+ than returning {}, so callers never overwrite (and destroy) a real config they
163
+ couldn't parse."""
164
+ if not os.path.exists(settings_path):
165
+ return {}
166
+ with open(settings_path, "r", encoding="utf-8") as f:
167
+ raw = f.read()
168
+ if not raw.strip():
169
+ return {}
170
+ data = json.loads(raw) # raises ValueError on malformed -> do NOT clobber
171
+ if not isinstance(data, dict):
172
+ raise ValueError("settings.json is not a JSON object")
173
+ return data
174
+
175
+
176
+ def _save(settings_path: str, data: dict) -> None:
177
+ os.makedirs(os.path.dirname(settings_path) or ".", exist_ok=True)
178
+ tmp = settings_path + ".tmp"
179
+ with open(tmp, "w", encoding="utf-8") as f:
180
+ json.dump(data, f, indent=2)
181
+ os.replace(tmp, settings_path)
182
+
183
+
184
+ def install_claude_hook(settings_path: str, url: str) -> None:
185
+ """Idempotently install the Voxa hooks into a Claude Code settings.json."""
186
+ _save(settings_path, merge_hook(_load(settings_path), url))
187
+
188
+
189
+ def uninstall_claude_hook(settings_path: str) -> None:
190
+ """Remove the Voxa hooks from a Claude Code settings.json (leaves others)."""
191
+ if os.path.exists(settings_path):
192
+ _save(settings_path, remove_hook(_load(settings_path)))
193
+
194
+
195
+ def default_settings_path() -> str:
196
+ return os.path.expanduser("~/.claude/settings.json")
197
+
198
+
199
+ def hook_url(host: str, port: int, token: str) -> str:
200
+ """The local /hook URL the installed hook POSTs to (always loopback)."""
201
+ from urllib.parse import quote
202
+ return f"http://127.0.0.1:{port}/hook?token={quote(token, safe='')}"
server/orchestrator.py ADDED
@@ -0,0 +1,315 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ from typing import Awaitable, Callable
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ Speak = Callable[[str], Awaitable[None]]
11
+ NotifyUI = Callable[[dict], Awaitable[None]]
12
+
13
+
14
+ def suggest_dirs(path: str, limit: int = 12) -> tuple[str, list[str]]:
15
+ """Find the deepest existing ancestor of ``path`` and list its subdirectories.
16
+
17
+ Used to help the user when a spoken folder path is wrong: the operator can
18
+ read these back as suggestions.
19
+ """
20
+ p = os.path.abspath(os.path.expanduser(path or "~"))
21
+ while p and not os.path.isdir(p):
22
+ parent = os.path.dirname(p)
23
+ if parent == p:
24
+ break
25
+ p = parent
26
+ try:
27
+ names = sorted(
28
+ n for n in os.listdir(p)
29
+ if os.path.isdir(os.path.join(p, n)) and not n.startswith(".")
30
+ )
31
+ except OSError:
32
+ names = []
33
+ return p, names[:limit]
34
+
35
+
36
+ class Orchestrator:
37
+ def __init__(self, controller, speak: Speak, notify_ui: NotifyUI):
38
+ self._c = controller
39
+ self._speak = speak
40
+ self._notify = notify_ui
41
+ self._bg: set[asyncio.Task] = set()
42
+ self._last_terminals: list[dict] = []
43
+ self._final_cb = self._on_final
44
+ controller.on_final(self._final_cb)
45
+ self._wire_output(controller)
46
+
47
+ async def _on_final(self, text: str) -> None:
48
+ await self._speak(text)
49
+ await self._notify({"type": "status", "status": "finished"})
50
+
51
+ def _wire_output(self, controller) -> None:
52
+ """Stream Claude's live screen to the phone UI (text), if the controller
53
+ supports it. Not spoken — purely a visual 'what Claude is doing' feed. The
54
+ colour variant is optional (tmux only); the getattr guard no-ops elsewhere."""
55
+ register = getattr(controller, "on_output", None)
56
+ if register:
57
+ register(self._on_output)
58
+ register_color = getattr(controller, "on_output_color", None)
59
+ if register_color:
60
+ register_color(self._on_output_color)
61
+
62
+ async def _on_output(self, text: str) -> None:
63
+ await self._notify({"type": "claude_output", "text": text})
64
+
65
+ async def _on_output_color(self, text: str) -> None:
66
+ await self._notify({"type": "claude_output_color", "text": text})
67
+
68
+ async def send_scrollback(self) -> None:
69
+ """Push Claude's full scrollback (coloured) to the phone's full-screen view.
70
+ On-demand (the view requests it); tmux-only — a no-op for other controllers."""
71
+ cap = getattr(self._c, "capture_scrollback", None)
72
+ if not cap:
73
+ return
74
+ text = await asyncio.to_thread(cap)
75
+ if text:
76
+ await self._notify({"type": "claude_scrollback", "text": text})
77
+
78
+ @property
79
+ def controller(self):
80
+ """The controller currently being driven (swaps when attaching to a terminal)."""
81
+ return self._c
82
+
83
+ def set_final(self, cb) -> None:
84
+ """Set the final-output callback (e.g. the session hub's handler) so it is
85
+ preserved across controller swaps when attaching to a different terminal."""
86
+ self._final_cb = cb
87
+ self._c.on_final(cb)
88
+
89
+ # --- attaching to an already-open terminal -----------------------------
90
+
91
+ def _build_controller(self, sess: dict):
92
+ if sess.get("backend") == "iterm":
93
+ from server.terminals import ItermController
94
+ return ItermController(sess["raw_id"])
95
+ if sess.get("backend") == "terminal_app":
96
+ from server.terminals import TerminalAppController
97
+ return TerminalAppController(sess["raw_id"])
98
+ if sess.get("backend") == "tmux":
99
+ from server.tmux_controller import TmuxController
100
+ return TmuxController(
101
+ session_name=sess["raw_id"], socket=sess.get("socket"),
102
+ launch_terminal=False,
103
+ )
104
+ if sess.get("backend") == "ax" and sess.get("app_pid"):
105
+ from server.ax_controller import AXController
106
+ return AXController(sess["app_pid"], sess.get("cwd", ""))
107
+ return None
108
+
109
+ async def _swap_controller(self, new) -> None:
110
+ try:
111
+ # detach_only: stop the old monitor but do NOT send Escape, otherwise
112
+ # attaching to terminal B would cancel the in-flight generation in the
113
+ # session we're leaving. (Escape is reserved for the explicit stop_claude.)
114
+ await self._c.stop(detach_only=True)
115
+ except Exception:
116
+ pass
117
+ self._c = new
118
+ new.on_final(self._final_cb)
119
+ self._wire_output(new)
120
+
121
+ def _resolve_terminal(self, args: dict):
122
+ items = self._last_terminals
123
+ if not items:
124
+ return None
125
+ tid = args.get("id")
126
+ if tid:
127
+ for s in items:
128
+ if s["id"] == tid:
129
+ return s
130
+ idx = args.get("index")
131
+ if isinstance(idx, int) and 1 <= idx <= len(items):
132
+ return items[idx - 1]
133
+ match = (args.get("match") or args.get("target") or "").lower().strip()
134
+ if match:
135
+ for s in items:
136
+ if match in s["label"].lower() or match in s.get("cwd", "").lower():
137
+ return s
138
+ return None
139
+
140
+ async def _attach(self, sess: dict) -> dict:
141
+ new = self._build_controller(sess)
142
+ if new is None:
143
+ return {"error": f"cannot control a {sess.get('app','')} terminal directly"}
144
+ await self._swap_controller(new)
145
+ try:
146
+ await new.start(sess.get("cwd") or None)
147
+ except PermissionError as e:
148
+ return {"error": str(e)}
149
+ await self._notify({"type": "status", "working_dir": new.working_dir or sess.get("cwd", "")})
150
+ if getattr(new, "mirrors_screen", True) is False:
151
+ await self._notify({
152
+ "type": "claude_output",
153
+ "text": "Live view isn't available for this terminal.",
154
+ })
155
+ # Recap what this terminal was working on, read from Claude's own transcript.
156
+ recap = ""
157
+ try:
158
+ from server.transcripts import recap as build_recap
159
+ recap = await asyncio.to_thread(build_recap, sess.get("cwd", ""))
160
+ except Exception:
161
+ pass
162
+ result = {"attached": sess["label"], "working_dir": sess.get("cwd", "")}
163
+ if recap:
164
+ result["recap"] = recap
165
+ return result
166
+
167
+ async def attach_source(self, cwd: str) -> dict:
168
+ """Attach to the open Claude terminal running in ``cwd`` (the session that
169
+ triggered a call), so answering continues THAT session with its context.
170
+ Returns the attach result (with recap) or an error dict."""
171
+ cwd = (cwd or "").rstrip("/")
172
+ if not cwd:
173
+ return {"error": "no cwd"}
174
+ from server.terminals import discover_claude_sessions
175
+ sessions = await asyncio.to_thread(discover_claude_sessions)
176
+ self._last_terminals = list(sessions)
177
+ match = next((s for s in sessions if (s.get("cwd") or "").rstrip("/") == cwd), None)
178
+ if match is None:
179
+ return {"error": "source terminal not open or not discoverable"}
180
+ if not match.get("controllable"):
181
+ return {"error": f"{match.get('app', '')} terminal can't be driven (use tmux/iTerm2)"}
182
+ return await self._attach(match)
183
+
184
+ async def cancel_all(self) -> None:
185
+ """Cancel any in-flight Claude send task and stop the controller."""
186
+ pending = list(self._bg)
187
+ for task in pending:
188
+ task.cancel()
189
+ await asyncio.gather(*pending, return_exceptions=True)
190
+ await self._c.stop()
191
+
192
+ def set_terminal_app(self, app: str) -> None:
193
+ fn = getattr(self._c, "set_terminal_app", None)
194
+ if fn:
195
+ fn(app)
196
+
197
+ def remember_terminals(self, sessions: list[dict]) -> None:
198
+ """Cache the terminals the phone is shown (its tappable list) so a later
199
+ attach_terminal by id resolves against the same set the user sees."""
200
+ self._last_terminals = list(sessions or [])
201
+
202
+ async def send_direct(self, text: str) -> None:
203
+ """Type text straight into the live Claude session (raw terminal chat from the
204
+ phone's full-screen view), bypassing the voice operator. Non-blocking like
205
+ send_to_claude; the result streams back through the live-output feed."""
206
+ text = (text or "").strip()
207
+ if not text:
208
+ return
209
+ if not getattr(self._c, "_started", True):
210
+ await self._notify({"type": "status",
211
+ "status": "Open a folder first to chat with Claude."})
212
+ return
213
+ task = asyncio.create_task(self._c.send(text))
214
+ self._bg.add(task)
215
+
216
+ def _done(t: asyncio.Task) -> None:
217
+ self._bg.discard(t)
218
+ if t.exception():
219
+ logger.warning("claude_input send failed: %s", t.exception())
220
+ task.add_done_callback(_done)
221
+ await self._notify({"type": "status", "status": "Claude working (mic paused)"})
222
+
223
+ async def _start(self, working_dir: str) -> dict:
224
+ try:
225
+ await self._c.start(working_dir)
226
+ except Exception as e:
227
+ # Help the user recover from a wrong spoken path with suggestions.
228
+ base, options = suggest_dirs(working_dir)
229
+ return {"error": str(e), "searched_in": base, "suggestions": options}
230
+ await self._notify({"type": "status", "working_dir": self._c.working_dir})
231
+ # If the visible terminal window couldn't be opened, tell the user how to
232
+ # attach manually instead of leaving them with an invisible session.
233
+ hint = getattr(self._c, "window_hint", "")
234
+ if hint:
235
+ await self._notify({"type": "status", "status": hint})
236
+ return {"status": self._c.status, "working_dir": self._c.working_dir}
237
+
238
+ async def handle_tool_call(self, name: str, args: dict) -> dict:
239
+ if name in ("start_claude_session", "set_working_dir"):
240
+ key = "working_dir" if name == "start_claude_session" else "path"
241
+ return await self._start(args.get(key, ""))
242
+ if name == "list_dirs":
243
+ base, options = suggest_dirs(args.get("parent", "~"))
244
+ return {"path": base, "dirs": options}
245
+ if name == "make_dir":
246
+ target = os.path.abspath(os.path.expanduser(args.get("path", "")))
247
+ if not target:
248
+ return {"error": "no path given"}
249
+ try:
250
+ os.makedirs(target, exist_ok=True)
251
+ except OSError as e:
252
+ return {"error": str(e)}
253
+ return await self._start(target)
254
+ if name == "send_to_claude":
255
+ # Guard: if no Claude session is running yet, tell Gemini so it asks the
256
+ # user for a folder (the system prompt handles this) instead of firing a
257
+ # background send() that crashes with "call start() before send()".
258
+ if not getattr(self._c, "_started", True):
259
+ return {"error": "no_session",
260
+ "hint": "No Claude session is running. Ask the user which "
261
+ "folder to work in (or to create one) before sending."}
262
+ task = asyncio.create_task(self._c.send(args.get("text", "")))
263
+ self._bg.add(task)
264
+
265
+ def _send_done(t: asyncio.Task) -> None:
266
+ self._bg.discard(t)
267
+ exc = t.exception() # retrieve so it isn't an orphan warning
268
+ if exc:
269
+ logger.warning("send_to_claude failed: %s", exc)
270
+ task.add_done_callback(_send_done)
271
+ await self._notify({"type": "status", "status": "Claude working (mic paused)"})
272
+ return {"accepted": True, "status": "working"}
273
+ if name == "list_terminals":
274
+ from server.terminals import discover_claude_sessions
275
+ self._last_terminals = await asyncio.to_thread(discover_claude_sessions)
276
+ items = [
277
+ {"id": s["id"], "label": s["label"], "app": s["app"],
278
+ "cwd": s.get("cwd", ""), "controllable": s["controllable"]}
279
+ for s in self._last_terminals
280
+ ]
281
+ # Send both keys: the phone reads `terminals`, the web client reads `items`.
282
+ await self._notify({"type": "terminals", "terminals": items, "items": items})
283
+ return {"terminals": items}
284
+ if name == "attach_terminal":
285
+ sess = self._resolve_terminal(args)
286
+ if sess is None:
287
+ # The phone's tappable list is pushed by a direct discovery, which
288
+ # doesn't populate _last_terminals (and it resets on every reconnect,
289
+ # since the orchestrator is rebuilt). Re-discover and retry so tapping
290
+ # an open terminal works without a prior list_terminals tool call.
291
+ from server.terminals import discover_claude_sessions
292
+ self._last_terminals = await asyncio.to_thread(discover_claude_sessions)
293
+ sess = self._resolve_terminal(args)
294
+ if sess is None:
295
+ return {"error": "terminal not found; call list_terminals first"}
296
+ if not sess.get("controllable"):
297
+ return {"error": f"that {sess.get('app','')} terminal can't be driven; "
298
+ "run Claude inside tmux and I can attach"}
299
+ return await self._attach(sess)
300
+ if name == "get_claude_status":
301
+ return {"status": self._c.status, "working_dir": self._c.working_dir}
302
+ if name == "stop_claude":
303
+ await self.cancel_all()
304
+ return {"status": "idle"}
305
+ if name == "read_session":
306
+ import server.transcripts as transcripts
307
+ cwd = self._c.working_dir or ""
308
+ kwargs = {}
309
+ if args.get("last") is not None:
310
+ kwargs["last"] = args["last"]
311
+ if args.get("search"):
312
+ kwargs["search"] = args["search"]
313
+ return await asyncio.to_thread(
314
+ lambda: transcripts.read_session(cwd, **kwargs))
315
+ return {"error": f"unknown tool: {name}"}
server/push_routes.py ADDED
@@ -0,0 +1,50 @@
1
+ """Device registration + ring + call-decline endpoints, shared by the laptop
2
+ server and the cloud. In production these live on the cloud (so your APNs key
3
+ isn't on every customer's laptop).
4
+
5
+ Security model: these routes are scoped by the ACCOUNT id (an unguessable UUID),
6
+ NOT by a shared secret. In the hosted/relay model the phone and each laptop hold
7
+ the laptop's per-machine pairing token, which the cloud cannot verify (it never
8
+ sees it), so requiring a shared token here would break zero-config pairing. That
9
+ is why `auth_check` is accepted for compatibility but not enforced. The intended
10
+ hardening is per-ACCOUNT authentication (device attestation / a cloud-issued,
11
+ cloud-verifiable account token), which does not exist yet, see SECURITY.md.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from fastapi import Request
17
+
18
+
19
+ def add_push_routes(app, registry, call_manager, auth_check=None) -> None:
20
+ @app.post("/register")
21
+ async def register(request: Request):
22
+ body = await request.json() or {}
23
+ registry.register(body.get("token", ""), body.get("account", ""))
24
+ return {"ok": True}
25
+
26
+ @app.post("/unregister")
27
+ async def unregister(request: Request):
28
+ body = await request.json() or {}
29
+ registry.remove(body.get("token", ""))
30
+ return {"ok": True}
31
+
32
+ @app.post("/notify")
33
+ async def notify(request: Request):
34
+ """Ring or cancel an account's registered phone(s). Called by the laptop:
35
+ ring when a Claude terminal finishes, cancel when it is no longer relevant
36
+ (the user already handled it)."""
37
+ body = await request.json() or {}
38
+ account = body.get("account", "")
39
+ if not account:
40
+ return {"ok": True}
41
+ if body.get("cancel"):
42
+ await call_manager.cancel(account)
43
+ else:
44
+ await call_manager.ring(account, body.get("summary", "A task finished."))
45
+ return {"ok": True}
46
+
47
+ @app.post("/call/decline")
48
+ async def decline(request: Request):
49
+ await call_manager.decline((await request.json() or {}).get("call_id", ""))
50
+ return {"ok": True}
server/ratelimit.py ADDED
@@ -0,0 +1,41 @@
1
+ """A tiny in-memory sliding-window rate limiter.
2
+
3
+ Used to throttle creation of NEW anonymous device accounts (the free-trial grant),
4
+ so a client cannot farm unlimited free minutes by claiming many `d-<uuid>` ids.
5
+ It bounds new-account creation per client IP and globally. This is a mitigation,
6
+ not a substitute for device attestation (App Attest), which is the complete fix.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import threading
12
+ import time
13
+
14
+
15
+ class SlidingWindowLimiter:
16
+ def __init__(self, max_events: int, window_seconds: float, clock=time.monotonic):
17
+ self._max = max_events
18
+ self._window = window_seconds
19
+ self._clock = clock
20
+ self._events: dict[str, list[float]] = {}
21
+ self._lock = threading.Lock()
22
+
23
+ def allow(self, key: str) -> bool:
24
+ """Record an event for `key` and return True if it is within the limit."""
25
+ now = self._clock()
26
+ cutoff = now - self._window
27
+ with self._lock:
28
+ bucket = [t for t in self._events.get(key, ()) if t > cutoff]
29
+ if len(bucket) >= self._max:
30
+ self._events[key] = bucket # prune, but do not record the rejected event
31
+ return False
32
+ bucket.append(now)
33
+ self._events[key] = bucket
34
+ # Opportunistic cleanup so idle keys don't accumulate forever.
35
+ if len(self._events) > 10000:
36
+ self._events = {
37
+ k: [t for t in v if t > cutoff]
38
+ for k, v in self._events.items()
39
+ if any(t > cutoff for t in v)
40
+ }
41
+ return True