Matrix: member-set extra MXID (matrix_mxid column + broker get/set + sync prefers it)

This commit is contained in:
inference-bot committed 2026-10-03 10:48:13 -06:00
1 parent 3cdc250d52
commit 563881d63c
1 file changed
+89
+89
View File
@@ -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.