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/__init__.py +0 -0
- server/apns.py +89 -0
- server/app.py +589 -0
- server/appattest.py +310 -0
- server/appstore.py +141 -0
- server/attested_store.py +60 -0
- server/auth.py +70 -0
- server/ax_controller.py +202 -0
- server/billing.py +177 -0
- server/call_manager.py +91 -0
- server/certs/AppleRootCA-G3.pem +15 -0
- server/certs/Apple_App_Attestation_Root_CA.pem +14 -0
- server/claude_controller.py +156 -0
- server/cli.py +365 -0
- server/cloud_app.py +345 -0
- server/config.py +56 -0
- server/device_registry.py +52 -0
- server/gemini_operator.py +677 -0
- server/hooks.py +202 -0
- server/orchestrator.py +315 -0
- server/push_routes.py +50 -0
- server/ratelimit.py +41 -0
- server/relay.py +157 -0
- server/relay_client.py +89 -0
- server/remote_operator.py +128 -0
- server/session_hub.py +33 -0
- server/terminal_watcher.py +241 -0
- server/terminals.py +510 -0
- server/tmux_controller.py +580 -0
- server/transcript_monitor.py +134 -0
- server/transcripts.py +143 -0
- server/users.py +90 -0
- server/voxa_cloud.py +132 -0
- server/waitlist.py +130 -0
- static/app.js +388 -0
- static/favicon.svg +1 -0
- static/index.html +253 -0
- static/pcm-worklet.js +69 -0
- static/pro.html +29 -0
- static/pro2.html +33 -0
- static/voxa-mark-white.svg +1 -0
- voxa_code-0.1.0.dist-info/METADATA +227 -0
- voxa_code-0.1.0.dist-info/RECORD +47 -0
- voxa_code-0.1.0.dist-info/WHEEL +5 -0
- voxa_code-0.1.0.dist-info/entry_points.txt +2 -0
- voxa_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- voxa_code-0.1.0.dist-info/top_level.txt +2 -0
server/cloud_app.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Voxa Cloud: the central billing API (and home of the metered V2V proxy).
|
|
2
|
+
|
|
3
|
+
This is the service YOU host (not the customer's laptop), because it holds the
|
|
4
|
+
minute balances and, for the metered proxy, your Gemini key. Endpoints:
|
|
5
|
+
GET /billing/balance?account=... -> remaining minutes
|
|
6
|
+
POST /billing/purchase?account=... {jws} -> verify StoreKit2 txn, credit
|
|
7
|
+
GET /healthz
|
|
8
|
+
|
|
9
|
+
The metered Gemini proxy (/live) lives alongside this in production; see RUNBOOK.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import binascii
|
|
16
|
+
import contextlib
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import secrets
|
|
21
|
+
import time
|
|
22
|
+
import uuid
|
|
23
|
+
|
|
24
|
+
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
|
|
25
|
+
from fastapi.responses import JSONResponse
|
|
26
|
+
|
|
27
|
+
from server.appattest import verify_attestation
|
|
28
|
+
from server.appstore import verify_transaction
|
|
29
|
+
from server.attested_store import AttestedStore
|
|
30
|
+
from server.auth import bearer_user
|
|
31
|
+
from server.billing import PRODUCT_MINUTES, Billing, MeteredSession
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _default_cloud_operator_factory(account, handle_tool_call, voice=""):
|
|
35
|
+
"""The proxy's V2V brain: GeminiOperator using the CLOUD's key (never the
|
|
36
|
+
customer's). Tool calls are RPC'd back to the laptop via handle_tool_call."""
|
|
37
|
+
from types import SimpleNamespace
|
|
38
|
+
from server.gemini_operator import GeminiOperator
|
|
39
|
+
cfg = SimpleNamespace(
|
|
40
|
+
gemini_api_key=os.environ["GEMINI_API_KEY"],
|
|
41
|
+
gemini_live_model=os.environ.get("GEMINI_LIVE_MODEL", "gemini-3.1-flash-live-preview"),
|
|
42
|
+
)
|
|
43
|
+
return GeminiOperator(cfg, handle_tool_call, voice=voice)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def add_billing_routes(app: FastAPI, billing: Billing | None = None,
|
|
47
|
+
verifier=verify_transaction, operator_factory=None,
|
|
48
|
+
auth_secret: str | None = None,
|
|
49
|
+
attested_store: AttestedStore | None = None,
|
|
50
|
+
attest_verifier=verify_attestation) -> Billing:
|
|
51
|
+
"""Register billing + metered /live routes on an existing app. Returns the
|
|
52
|
+
Billing instance (so the combined app can share it). Bearer is required for
|
|
53
|
+
all accounts except anonymous device accounts (ids starting with 'd-'), which
|
|
54
|
+
may use ?account=<id> (balance) or body account field (purchase) without a token.
|
|
55
|
+
|
|
56
|
+
App Attest (device attestation) is added behind VOXA_REQUIRE_ATTESTATION. When
|
|
57
|
+
that flag is OFF (default) behavior is unchanged. When ON, a d- account must
|
|
58
|
+
have registered a verified App Attest key before it can mint a free trial
|
|
59
|
+
(balance) or open a metered session (/live)."""
|
|
60
|
+
billing = billing or Billing(os.environ.get("VOXA_BILLING_FILE", "billing.json"))
|
|
61
|
+
bundle = os.environ.get("APNS_BUNDLE_ID", "").strip()
|
|
62
|
+
proxy_token = os.environ.get("VOXA_PROXY_TOKEN", "").strip()
|
|
63
|
+
auth_secret = auth_secret if auth_secret is not None else os.environ.get("VOXA_AUTH_SECRET", "")
|
|
64
|
+
operator_factory = operator_factory or _default_cloud_operator_factory
|
|
65
|
+
app.state.billing = billing
|
|
66
|
+
|
|
67
|
+
# --- App Attest: device binding (feature-flagged; off => no behavior change) ---
|
|
68
|
+
attested_store = attested_store or AttestedStore()
|
|
69
|
+
app.state.attested_store = attested_store
|
|
70
|
+
require_attestation = os.environ.get("VOXA_REQUIRE_ATTESTATION", "").strip().lower() in (
|
|
71
|
+
"1", "true", "yes", "on")
|
|
72
|
+
# app_id for App Attest is "<TeamID>.<BundleID>"; rpIdHash = sha256(app_id).
|
|
73
|
+
attest_app_id = os.environ.get("VOXA_APPATTEST_APP_ID", "").strip()
|
|
74
|
+
_attest_allow_dev = os.environ.get("VOXA_APPATTEST_ALLOW_DEV", "1").strip().lower() in (
|
|
75
|
+
"1", "true", "yes", "on")
|
|
76
|
+
_challenge_ttl = float(os.environ.get("VOXA_ATTEST_CHALLENGE_TTL", "300"))
|
|
77
|
+
_challenges: dict[str, float] = {} # hex challenge -> issued monotonic time
|
|
78
|
+
|
|
79
|
+
def _issue_challenge() -> str:
|
|
80
|
+
now = time.monotonic()
|
|
81
|
+
# Opportunistically drop expired challenges so the set stays bounded.
|
|
82
|
+
for k in [k for k, t in _challenges.items() if now - t > _challenge_ttl]:
|
|
83
|
+
_challenges.pop(k, None)
|
|
84
|
+
ch = secrets.token_hex(32)
|
|
85
|
+
_challenges[ch] = now
|
|
86
|
+
return ch
|
|
87
|
+
|
|
88
|
+
def _consume_challenge(ch: str) -> bool:
|
|
89
|
+
"""Single-use: accept a still-valid challenge exactly once."""
|
|
90
|
+
issued = _challenges.pop(ch, None)
|
|
91
|
+
return issued is not None and (time.monotonic() - issued) <= _challenge_ttl
|
|
92
|
+
|
|
93
|
+
def _attested_ok(account: str) -> bool:
|
|
94
|
+
# When attestation is required, a d- account must have a bound key. Non-d-
|
|
95
|
+
# accounts (signed-in users) are unaffected. Flag off => always True.
|
|
96
|
+
if not require_attestation or not account.startswith("d-"):
|
|
97
|
+
return True
|
|
98
|
+
return attested_store.account_is_attested(account)
|
|
99
|
+
|
|
100
|
+
# Throttle creation of NEW anonymous (d-) accounts, i.e. the free-trial grant,
|
|
101
|
+
# so random ids can't farm free minutes or open /live at will. Existing accounts
|
|
102
|
+
# and signed-in users are never throttled. (Full per-device binding is App
|
|
103
|
+
# Attest; see SECURITY.md. This bounds the abuse in the meantime.)
|
|
104
|
+
from server.ratelimit import SlidingWindowLimiter
|
|
105
|
+
_WINDOW = 3600.0
|
|
106
|
+
_per_ip = SlidingWindowLimiter(int(os.environ.get("VOXA_NEW_ACCT_PER_IP", "5")), _WINDOW)
|
|
107
|
+
_global = SlidingWindowLimiter(int(os.environ.get("VOXA_NEW_ACCT_GLOBAL", "200")), _WINDOW)
|
|
108
|
+
|
|
109
|
+
def _client_ip(conn) -> str:
|
|
110
|
+
xff = conn.headers.get("x-forwarded-for")
|
|
111
|
+
if xff:
|
|
112
|
+
return xff.split(",")[0].strip()
|
|
113
|
+
return conn.client.host if conn.client else "unknown"
|
|
114
|
+
|
|
115
|
+
def _allow_new_anon(account: str, conn) -> bool:
|
|
116
|
+
# Gates ONLY the first-touch that would mint a new anonymous trial account.
|
|
117
|
+
if not account.startswith("d-") or billing.exists(account):
|
|
118
|
+
return True
|
|
119
|
+
return _per_ip.allow(_client_ip(conn)) and _global.allow("*")
|
|
120
|
+
|
|
121
|
+
@app.get("/billing/balance")
|
|
122
|
+
async def balance(request: Request):
|
|
123
|
+
account = bearer_user(request, auth_secret)
|
|
124
|
+
if not account:
|
|
125
|
+
anon = request.query_params.get("account", "")
|
|
126
|
+
if anon.startswith("d-"):
|
|
127
|
+
account = anon
|
|
128
|
+
else:
|
|
129
|
+
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
|
130
|
+
# A brand-new anonymous account over the creation limit: report 0 without
|
|
131
|
+
# minting a free trial (throttles free-minute farming).
|
|
132
|
+
if not _allow_new_anon(account, request):
|
|
133
|
+
return {"account": account, "minutes": 0.0}
|
|
134
|
+
# With attestation required, an unattested device account gets no trial.
|
|
135
|
+
if not _attested_ok(account):
|
|
136
|
+
return {"account": account, "minutes": 0.0}
|
|
137
|
+
return {"account": account, "minutes": billing.balance_minutes(account)}
|
|
138
|
+
|
|
139
|
+
@app.post("/attest/challenge")
|
|
140
|
+
async def attest_challenge():
|
|
141
|
+
"""Issue a single-use, short-TTL challenge for App Attest. No auth needed:
|
|
142
|
+
the challenge is meaningless without a valid Secure Enclave attestation."""
|
|
143
|
+
return {"challenge": _issue_challenge()}
|
|
144
|
+
|
|
145
|
+
@app.post("/attest/register")
|
|
146
|
+
async def attest_register(request: Request):
|
|
147
|
+
"""Bind an App-Attested Secure Enclave key to a d- account.
|
|
148
|
+
|
|
149
|
+
Body: {account, key_id(hex), attestation(base64), challenge(hex)}.
|
|
150
|
+
Verifies the attestation with real crypto; on success stores the device's
|
|
151
|
+
public key so the account counts as attested."""
|
|
152
|
+
body = await request.json()
|
|
153
|
+
account = (body or {}).get("account", "")
|
|
154
|
+
key_id_hex = (body or {}).get("key_id", "")
|
|
155
|
+
attestation_b64 = (body or {}).get("attestation", "")
|
|
156
|
+
challenge_hex = (body or {}).get("challenge", "")
|
|
157
|
+
if not account.startswith("d-"):
|
|
158
|
+
return JSONResponse({"error": "invalid account"}, status_code=400)
|
|
159
|
+
if not (key_id_hex and attestation_b64 and challenge_hex):
|
|
160
|
+
return JSONResponse({"error": "missing fields"}, status_code=400)
|
|
161
|
+
if not attest_app_id:
|
|
162
|
+
logger.error("App Attest: VOXA_APPATTEST_APP_ID not configured; cannot register")
|
|
163
|
+
return JSONResponse({"error": "attestation unavailable"}, status_code=400)
|
|
164
|
+
if not _consume_challenge(challenge_hex):
|
|
165
|
+
return JSONResponse({"error": "unknown or expired challenge"}, status_code=400)
|
|
166
|
+
import base64
|
|
167
|
+
try:
|
|
168
|
+
key_id = binascii.unhexlify(key_id_hex)
|
|
169
|
+
challenge = binascii.unhexlify(challenge_hex)
|
|
170
|
+
attestation = base64.b64decode(attestation_b64)
|
|
171
|
+
except Exception:
|
|
172
|
+
return JSONResponse({"error": "malformed input"}, status_code=400)
|
|
173
|
+
result = attest_verifier(attestation, key_id, challenge, attest_app_id,
|
|
174
|
+
allow_dev=_attest_allow_dev)
|
|
175
|
+
if not result:
|
|
176
|
+
return JSONResponse({"error": "attestation failed"}, status_code=400)
|
|
177
|
+
attested_store.put(key_id_hex, account, result["public_key_pem"],
|
|
178
|
+
result["counter"])
|
|
179
|
+
return {"ok": True}
|
|
180
|
+
|
|
181
|
+
@app.post("/auth/merge")
|
|
182
|
+
async def merge(request: Request):
|
|
183
|
+
account = bearer_user(request, auth_secret)
|
|
184
|
+
if not account:
|
|
185
|
+
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
|
186
|
+
body = await request.json()
|
|
187
|
+
device = (body or {}).get("device_account", "")
|
|
188
|
+
if not device:
|
|
189
|
+
return JSONResponse({"error": "missing device_account"}, status_code=400)
|
|
190
|
+
if not device.startswith("d-"):
|
|
191
|
+
return JSONResponse({"error": "invalid device_account"}, status_code=400)
|
|
192
|
+
minutes = billing.merge_account(account, device)
|
|
193
|
+
return {"ok": True, "account": account, "minutes": minutes}
|
|
194
|
+
|
|
195
|
+
@app.post("/billing/purchase")
|
|
196
|
+
async def purchase(request: Request):
|
|
197
|
+
body = await request.json()
|
|
198
|
+
account = bearer_user(request, auth_secret)
|
|
199
|
+
if not account:
|
|
200
|
+
anon = (body or {}).get("account", "")
|
|
201
|
+
if anon.startswith("d-"):
|
|
202
|
+
account = anon
|
|
203
|
+
else:
|
|
204
|
+
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
|
205
|
+
jws = (body or {}).get("jws", "")
|
|
206
|
+
if not jws:
|
|
207
|
+
return JSONResponse({"error": "missing jws"}, status_code=400)
|
|
208
|
+
payload = verifier(jws, bundle_id=bundle or None)
|
|
209
|
+
if not payload:
|
|
210
|
+
return JSONResponse({"error": "invalid transaction"}, status_code=400)
|
|
211
|
+
product = payload.get("productId", "")
|
|
212
|
+
if product not in PRODUCT_MINUTES:
|
|
213
|
+
return JSONResponse({"error": f"unknown product {product}"}, status_code=400)
|
|
214
|
+
minutes = billing.apply_purchase(
|
|
215
|
+
account, product, payload.get("transactionId", "")
|
|
216
|
+
)
|
|
217
|
+
return {"ok": True, "product": product, "minutes": minutes}
|
|
218
|
+
|
|
219
|
+
@app.websocket("/live")
|
|
220
|
+
async def live(websocket: WebSocket):
|
|
221
|
+
"""Metered V2V relay: the laptop connects here instead of Google. We run the
|
|
222
|
+
Gemini bridge with the cloud key, meter the account's minutes, and cut off
|
|
223
|
+
at zero. Tool calls are RPC'd back to the laptop over this same socket."""
|
|
224
|
+
account = websocket.query_params.get("account", "")
|
|
225
|
+
if (proxy_token and websocket.query_params.get("token") != proxy_token) or not account:
|
|
226
|
+
await websocket.close(code=4401)
|
|
227
|
+
return
|
|
228
|
+
# Don't let a never-seen anonymous id spin up a metered session on the
|
|
229
|
+
# cloud's Gemini key beyond the creation limit (free-minute farming guard).
|
|
230
|
+
if not _allow_new_anon(account, websocket):
|
|
231
|
+
await websocket.close(code=4403)
|
|
232
|
+
return
|
|
233
|
+
# With attestation required, refuse unattested device accounts outright.
|
|
234
|
+
if not _attested_ok(account):
|
|
235
|
+
await websocket.close(code=4405)
|
|
236
|
+
return
|
|
237
|
+
if not billing.has_minutes(account):
|
|
238
|
+
await websocket.accept()
|
|
239
|
+
await websocket.send_json({"type": "status", "status": "no minutes"})
|
|
240
|
+
await websocket.close(code=4402)
|
|
241
|
+
return
|
|
242
|
+
await websocket.accept()
|
|
243
|
+
|
|
244
|
+
loop = asyncio.get_event_loop()
|
|
245
|
+
pending: dict[str, asyncio.Future] = {}
|
|
246
|
+
|
|
247
|
+
async def handle_tool_call(name: str, args: dict) -> dict:
|
|
248
|
+
rid = uuid.uuid4().hex
|
|
249
|
+
fut = loop.create_future()
|
|
250
|
+
pending[rid] = fut
|
|
251
|
+
await websocket.send_json({"type": "tool", "id": rid, "name": name, "args": args})
|
|
252
|
+
try:
|
|
253
|
+
return await asyncio.wait_for(fut, timeout=600)
|
|
254
|
+
finally:
|
|
255
|
+
pending.pop(rid, None)
|
|
256
|
+
|
|
257
|
+
# The phone's chosen voice rode through to here; pass it to factories that
|
|
258
|
+
# accept it (keeps simpler 2-arg factories working).
|
|
259
|
+
import inspect
|
|
260
|
+
voice = websocket.query_params.get("voice", "")
|
|
261
|
+
_args = [account, handle_tool_call]
|
|
262
|
+
if "voice" in inspect.signature(operator_factory).parameters:
|
|
263
|
+
_args.append(voice)
|
|
264
|
+
|
|
265
|
+
meter = MeteredSession(billing, account)
|
|
266
|
+
async with _as_cm(operator_factory(*_args)) as op:
|
|
267
|
+
op.set_audio_out(lambda pcm: websocket.send_bytes(pcm))
|
|
268
|
+
op.set_text_out(lambda m: websocket.send_json(m))
|
|
269
|
+
# The laptop always drives the spoken opening (contextual or a generic
|
|
270
|
+
# greeting). Never let the cloud brain auto-greet, or it races ahead with a
|
|
271
|
+
# generic "what would you like to do?" before the laptop's opening arrives.
|
|
272
|
+
if hasattr(op, "suppress_greeting"):
|
|
273
|
+
op.suppress_greeting()
|
|
274
|
+
|
|
275
|
+
async def recv_loop():
|
|
276
|
+
try:
|
|
277
|
+
while True:
|
|
278
|
+
msg = await websocket.receive()
|
|
279
|
+
if msg["type"] == "websocket.disconnect":
|
|
280
|
+
break
|
|
281
|
+
if msg.get("bytes") is not None:
|
|
282
|
+
await op.send_audio(msg["bytes"])
|
|
283
|
+
elif msg.get("text"):
|
|
284
|
+
data = json.loads(msg["text"])
|
|
285
|
+
mtype = data.get("type")
|
|
286
|
+
if mtype == "tool_result":
|
|
287
|
+
# Guard against a late result arriving after its future was
|
|
288
|
+
# cancelled/timed out: set_result on a done future raises
|
|
289
|
+
# InvalidStateError and would kill this loop.
|
|
290
|
+
fut = pending.get(data.get("id"))
|
|
291
|
+
if fut is not None and not fut.done():
|
|
292
|
+
fut.set_result(data.get("result", {}))
|
|
293
|
+
elif mtype == "speak":
|
|
294
|
+
await op.speak(data.get("text", ""),
|
|
295
|
+
immediate=bool(data.get("immediate")))
|
|
296
|
+
elif mtype == "suppress_greeting":
|
|
297
|
+
op.suppress_greeting()
|
|
298
|
+
elif mtype == "user_text":
|
|
299
|
+
await op.send_text(data.get("text", ""))
|
|
300
|
+
except (WebSocketDisconnect, RuntimeError):
|
|
301
|
+
pass
|
|
302
|
+
|
|
303
|
+
async def metering():
|
|
304
|
+
while True:
|
|
305
|
+
await asyncio.sleep(5)
|
|
306
|
+
meter.tick()
|
|
307
|
+
if meter.out_of_minutes():
|
|
308
|
+
with contextlib.suppress(Exception):
|
|
309
|
+
await websocket.send_json(
|
|
310
|
+
{"type": "status", "status": "out of minutes"})
|
|
311
|
+
return
|
|
312
|
+
|
|
313
|
+
tasks = [asyncio.ensure_future(op.run()),
|
|
314
|
+
asyncio.ensure_future(recv_loop()),
|
|
315
|
+
asyncio.ensure_future(metering())]
|
|
316
|
+
try:
|
|
317
|
+
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
318
|
+
finally:
|
|
319
|
+
for t in tasks:
|
|
320
|
+
t.cancel()
|
|
321
|
+
meter.flush()
|
|
322
|
+
|
|
323
|
+
return billing
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def create_cloud_app(billing: Billing | None = None, verifier=verify_transaction,
|
|
327
|
+
operator_factory=None) -> FastAPI:
|
|
328
|
+
app = FastAPI()
|
|
329
|
+
|
|
330
|
+
@app.get("/healthz")
|
|
331
|
+
async def healthz():
|
|
332
|
+
return {"ok": True}
|
|
333
|
+
|
|
334
|
+
add_billing_routes(app, billing=billing, verifier=verifier,
|
|
335
|
+
operator_factory=operator_factory)
|
|
336
|
+
return app
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@contextlib.asynccontextmanager
|
|
340
|
+
async def _as_cm(obj):
|
|
341
|
+
if hasattr(obj, "__aenter__"):
|
|
342
|
+
async with obj as entered:
|
|
343
|
+
yield entered
|
|
344
|
+
else:
|
|
345
|
+
yield obj
|
server/config.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Mapping
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Config:
|
|
10
|
+
gemini_api_key: str
|
|
11
|
+
gemini_live_model: str
|
|
12
|
+
auth_token: str
|
|
13
|
+
host: str
|
|
14
|
+
port: int
|
|
15
|
+
apns_key_path: str = ""
|
|
16
|
+
apns_key: str = "" # the .p8 contents (preferred on container hosts)
|
|
17
|
+
apns_key_id: str = ""
|
|
18
|
+
apns_team_id: str = ""
|
|
19
|
+
apns_bundle_id: str = ""
|
|
20
|
+
apns_sandbox: bool = False
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def push_enabled(self) -> bool:
|
|
24
|
+
return all([
|
|
25
|
+
(self.apns_key or self.apns_key_path), self.apns_key_id,
|
|
26
|
+
self.apns_team_id, self.apns_bundle_id,
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_config(env: Mapping[str, str] | None = None) -> Config:
|
|
31
|
+
env = os.environ if env is None else env
|
|
32
|
+
api_key = env.get("GEMINI_API_KEY", "").strip()
|
|
33
|
+
auth_token = env.get("VOXA_AUTH_TOKEN", "").strip()
|
|
34
|
+
# GEMINI_API_KEY is required only in DIRECT mode (Gemini runs locally). In the
|
|
35
|
+
# metered/relay product the laptop has no key, the cloud /live proxy holds it,
|
|
36
|
+
# so it's optional here and only the server enforces having one.
|
|
37
|
+
if not api_key and not env.get("VOXA_LIVE_PROXY", "").strip():
|
|
38
|
+
raise ValueError("GEMINI_API_KEY is required (or set VOXA_LIVE_PROXY for metered mode)")
|
|
39
|
+
if not auth_token:
|
|
40
|
+
raise ValueError("VOXA_AUTH_TOKEN is required")
|
|
41
|
+
apns = {
|
|
42
|
+
"apns_key_path": env.get("APNS_KEY_PATH", "").strip(),
|
|
43
|
+
"apns_key": env.get("APNS_KEY", ""),
|
|
44
|
+
"apns_key_id": env.get("APNS_KEY_ID", "").strip(),
|
|
45
|
+
"apns_team_id": env.get("APNS_TEAM_ID", "").strip(),
|
|
46
|
+
"apns_bundle_id": env.get("APNS_BUNDLE_ID", "").strip(),
|
|
47
|
+
"apns_sandbox": env.get("APNS_SANDBOX", "").strip().lower() in ("1", "true", "yes"),
|
|
48
|
+
}
|
|
49
|
+
return Config(
|
|
50
|
+
gemini_api_key=api_key,
|
|
51
|
+
gemini_live_model=env.get("GEMINI_LIVE_MODEL", "gemini-2.0-flash-live-001").strip(),
|
|
52
|
+
auth_token=auth_token,
|
|
53
|
+
host=env.get("VOXA_HOST", "127.0.0.1").strip(),
|
|
54
|
+
port=int(env.get("VOXA_PORT", "8787")),
|
|
55
|
+
**apns,
|
|
56
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DeviceRegistry:
|
|
8
|
+
"""VoIP push tokens, keyed by account so the cloud can ring the right phone
|
|
9
|
+
for the right customer. Persisted as {account: [tokens]} (a legacy flat list
|
|
10
|
+
is migrated under account "")."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, path: str):
|
|
13
|
+
self._path = path
|
|
14
|
+
self._by_account: dict[str, set[str]] = {}
|
|
15
|
+
if os.path.exists(path):
|
|
16
|
+
try:
|
|
17
|
+
data = json.load(open(path))
|
|
18
|
+
if isinstance(data, dict):
|
|
19
|
+
self._by_account = {a: set(t) for a, t in data.items()}
|
|
20
|
+
elif isinstance(data, list): # legacy flat list
|
|
21
|
+
self._by_account = {"": set(data)}
|
|
22
|
+
except (ValueError, OSError):
|
|
23
|
+
self._by_account = {}
|
|
24
|
+
|
|
25
|
+
def register(self, token: str, account: str = "") -> None:
|
|
26
|
+
if not token:
|
|
27
|
+
return
|
|
28
|
+
s = self._by_account.setdefault(account, set())
|
|
29
|
+
if token not in s:
|
|
30
|
+
s.add(token)
|
|
31
|
+
self._flush()
|
|
32
|
+
|
|
33
|
+
def remove(self, token: str) -> None:
|
|
34
|
+
changed = False
|
|
35
|
+
for s in self._by_account.values():
|
|
36
|
+
if token in s:
|
|
37
|
+
s.discard(token)
|
|
38
|
+
changed = True
|
|
39
|
+
if changed:
|
|
40
|
+
self._flush()
|
|
41
|
+
|
|
42
|
+
def tokens(self, account: str | None = None) -> list[str]:
|
|
43
|
+
"""Tokens for one account, or all tokens if account is None."""
|
|
44
|
+
if account is None:
|
|
45
|
+
return [t for s in self._by_account.values() for t in s]
|
|
46
|
+
return list(self._by_account.get(account, set()))
|
|
47
|
+
|
|
48
|
+
def _flush(self) -> None:
|
|
49
|
+
tmp = self._path + ".tmp"
|
|
50
|
+
with open(tmp, "w") as f:
|
|
51
|
+
json.dump({a: sorted(t) for a, t in self._by_account.items()}, f)
|
|
52
|
+
os.replace(tmp, self._path)
|