diff --git a/app/main.py b/app/main.py index db176e8..45b95bc 100644 --- a/app/main.py +++ b/app/main.py @@ -451,6 +451,12 @@ def get_db() -> sqlite3.Connection: # used to throttle re-invites of members who never activated. if "welcome_sent_at" not in cols: conn.execute("ALTER TABLE members ADD COLUMN welcome_sent_at TEXT") + # Migration: matrix_mxid — an OPTIONAL extra Matrix account a member wants + # invited to the co-op Space (e.g. their existing federated account on + # another homeserver). Empty = only the local @username:inference.coop + # account is invited. At most one extra MXID per member. + if "matrix_mxid" not in cols: + conn.execute("ALTER TABLE members ADD COLUMN matrix_mxid TEXT") # Migration: credit_balance — purchased, NON-EXPIRING credits (USD). # Distinct from `balance` (the monthly allowance, which resets): credits # carry over indefinitely and are spent only after the allowance is @@ -787,6 +793,27 @@ async def get_active_member_emails() -> list[str]: return [r[0] for r in rows] +def get_member_matrix_mxid(email: str) -> str: + """Return the member's optional extra Matrix MXID, or "" if unset.""" + conn = get_db() + row = conn.execute( + "SELECT matrix_mxid FROM members WHERE email = ?", (email,) + ).fetchone() + conn.close() + return (row[0] or "").strip() if row else "" + + +def set_member_matrix_mxid(email: str, mxid: str) -> None: + """Set (or clear) the member's optional extra Matrix MXID.""" + conn = get_db() + conn.execute( + "UPDATE members SET matrix_mxid = ? WHERE email = ?", + (mxid.strip() if mxid else "", email), + ) + conn.commit() + conn.close() + + async def loomio_fix_usernames(client: httpx.AsyncClient) -> dict: """Rename Loomio users with auto-generated handles to their Cloudron username. @@ -922,6 +949,15 @@ async def matrix_member_mxids(client: httpx.AsyncClient): break page += 1 for email in await get_active_member_emails(): + # A member's own extra MXID (set on the dashboard) takes precedence — + # that's the account they want in the Space. Otherwise use their local + # @username:inference.coop account. + own = get_member_matrix_mxid(email) + if own: + if own not in seen: + seen.add(own) + yield email, own + continue username = users.get(email.lower()) if username: mxid = f"@{username}:inference.coop" @@ -2332,6 +2368,59 @@ async def broker_revoke(name: str, request: Request): return {"status": "revoked", "name": name} +# Matrix MXID (self-serve federated identity). A member can attach ONE extra +# Matrix account (e.g. their existing @user:matrix.org) to be invited to the +# co-op Space. Empty string clears it. Validation is loose on purpose — Synapse +# will simply reject a malformed MXID on invite, and we log that. +_MXID_RE = re.compile(r"^@[^\s:]+:[^\s]+\.[^\s]+$") + + +@app.get("/broker/matrix-mxid") +@limiter.limit("30/minute") +async def broker_get_matrix_mxid(request: Request): + email = _verify_broker_secret(request) + return {"matrix_mxid": get_member_matrix_mxid(email), "local_mxid": local_mxid_for_email(email)} + + +async def local_mxid_for_email(email: str) -> str: + """Resolve a member's local Matrix ID (@username:inference.coop), or '' if unknown.""" + import urllib.request + try: + req = urllib.request.Request( + f"{CLOUDRON_API}/api/v1/users?per_page=100", + headers={"Authorization": f"Bearer {CLOUDRON_TOKEN}"}, + ) + with urllib.request.urlopen(req, timeout=15) as r: + users = json.loads(r.read().decode()).get("users", []) + for u in users: + if (u.get("email") or "").lower() == email and u.get("username"): + return f"@{u['username']}:inference.coop" + except Exception as e: + logger.warning("local_mxid_for_email lookup failed: %s", e) + return "" + + +@app.post("/broker/matrix-mxid") +@limiter.limit("30/minute") +async def broker_set_matrix_mxid(request: Request): + email = _verify_broker_secret(request) + body = await request.json() + mxid = (body.get("matrix_mxid") or "").strip() + if mxid and not _MXID_RE.match(mxid): + return JSONResponse( + {"error": "Enter a Matrix ID like @username:server.org"}, status_code=400 + ) + set_member_matrix_mxid(email, mxid) + # Invite the new account into the Space immediately (best-effort; the daily + # sync will also pick it up). Clear = no invite (just removes the mapping). + if mxid: + try: + await sync_matrix_space() + except Exception as e: + logger.warning("matrix-mxid: immediate invite failed: %s", e) + return {"status": "saved", "matrix_mxid": mxid} + + async def broker_get_usage(email: str, days: int = 0) -> dict: """Return the member's spend vs. balance, plus per-model and token detail.