diff --git a/app/main.py b/app/main.py index ee9122f..f9c4f98 100644 --- a/app/main.py +++ b/app/main.py @@ -175,6 +175,16 @@ LOOMIO_API_KEY = os.environ.get("LOOMIO_API_KEY", "") # mangled auto-handle (email local-part + random suffix). The B3 users API # lets us rename them to the member's Cloudron username. LOOMIO_B3_KEY = os.environ.get("LOOMIO_B3_KEY", "") + +# Matrix homeserver (self-hosted Synapse at matrix.inference.coop). The bot +# account (admin) invites every active member to the co-op Space on sync. +# Members authenticate via Cloudron SSO; their homeserver MXID is +# @:inference.coop. Federated MXIDs (external servers) +# can also be invited directly. +MATRIX_HOMESERVER = os.environ.get("MATRIX_HOMESERVER", "https://matrix.inference.coop") +MATRIX_BOT_TOKEN = os.environ.get("MATRIX_BOT_TOKEN", "") +MATRIX_SPACE_ID = os.environ.get("MATRIX_SPACE_ID", "") +MATRIX_GENERAL_ROOM = os.environ.get("MATRIX_GENERAL_ROOM", "") LOOMIO_GROUP_ID = os.environ.get("LOOMIO_GROUP_ID", "") # Operator accounts that must always remain in the Loomio group (never removed # by the remove_absent reconciliation), comma-separated. @@ -823,6 +833,80 @@ async def loomio_fix_usernames(client: httpx.AsyncClient) -> dict: return {"fixed": fixed, "skipped": skipped} +async def sync_matrix_space() -> dict: + """Invite every active member to the co-op Matrix Space (and General room). + + Members log in via Cloudron SSO, so their local MXID is + @:inference.coop. We resolve the Cloudron username for each + active member email and invite them if not already in the space. + Best-effort: missing config just skips; failures log and continue. + """ + if not (MATRIX_BOT_TOKEN and MATRIX_SPACE_ID): + logger.info("Matrix space sync skipped (not configured)") + return {"skipped": True} + H = {"Authorization": f"Bearer {MATRIX_BOT_TOKEN}", "Content-Type": "application/json"} + + async def invite(mxid: str, room: str) -> None: + r = await client.post( + f"{MATRIX_HOMESERVER}/_matrix/client/v3/rooms/{room}/invite", + headers=H, json={"user_id": mxid}, + ) + # 200 = invited; 403 M_FORBIDDEN with 'already in room' is fine too + if r.status_code not in (200, 403) and "already" not in r.text: + logger.warning("Matrix invite %s -> %s failed: %s %s", + mxid, room, r.status_code, r.text[:120]) + + async with httpx.AsyncClient(timeout=30) as client: + # current members of the space (to avoid duplicate invites) + r = await client.get( + f"{MATRIX_HOMESERVER}/_matrix/client/v3/rooms/{MATRIX_SPACE_ID}/joined_members", + headers=H) + joined = set(r.json().get("joined", {}).keys()) if r.status_code == 200 else set() + # also pending invites + r = await client.get( + f"{MATRIX_HOMESERVER}/_matrix/client/v3/rooms/{MATRIX_SPACE_ID}/members?membership=invite", + headers=H) + invited = {m["state_key"] for m in r.json().get("chunk", [])} if r.status_code == 200 else set() + + invited_count = 0 + async for email, mxid in matrix_member_mxids(client): + if not mxid or mxid in joined or mxid in invited: + continue + await invite(mxid, MATRIX_SPACE_ID) + if MATRIX_GENERAL_ROOM: + await invite(mxid, MATRIX_GENERAL_ROOM) + invited_count += 1 + if invited_count: + logger.info("Matrix space invites sent: %s", invited_count) + return {"invited": invited_count} + + +async def matrix_member_mxids(client: httpx.AsyncClient): + """Yield (email, mxid) for each active member. + + Local members log in via Cloudron SSO and Synapse names them by Cloudron + username → @:inference.coop. Emails that don't resolve to a + Cloudron user (e.g. federated/external members) are skipped here but can + be added via the MATRIX_EXTRA_MXIDS env (comma list of full MXIDs). + """ + extra = [m.strip() for m in os.environ.get("MATRIX_EXTRA_MXIDS", "").split(",") if m.strip()] + seen: set[str] = set() + r = await client.get( + f"{CLOUDRON_API}/api/v1/users", + headers=cloudron_headers(), + ) + users = {u["email"].lower(): u["username"] for u in r.json()} if r.status_code == 200 else {} + for email in await get_active_member_emails(): + username = users.get(email.lower()) + if username: + mxid = f"@{username}:inference.coop" + seen.add(mxid) + yield email, mxid + for mxid in extra: + if mxid not in seen: + yield mxid, mxid + + async def sync_loomio_memberships() -> None: """Reconcile the Loomio group to the current active-member list. @@ -1139,6 +1223,7 @@ async def provision_member(email: str, name: str, slug: str = "") -> str: logger.warning("Held-credit grant for %s failed: %s", email, e) await sync_loomio_memberships() + await sync_matrix_space() logger.info("Provisioned member %s (user_id=%s)", email, user_id) return user_id @@ -1581,6 +1666,7 @@ async def opencollective_webhook(request: Request, token: str): deactivate_member(member["email"]) await listmonk_sync(member["email"], subscribe=False) await sync_loomio_memberships() + await sync_matrix_space() logger.info("Deactivated member %s (slug=%s)", member["email"], slug) return JSONResponse({"status": "deactivated", "email": member["email"]}) return JSONResponse({"status": "deactivated", "note": "no matching member"}) @@ -1739,6 +1825,7 @@ async def admin_sync_loomio(request: Request): _verify_admin_token(request) await sync_loomio_memberships() + await sync_matrix_space() return {"status": "synced"} @@ -2456,6 +2543,7 @@ async def reconcile_memberships() -> dict: # 3. Sync Loomio. await sync_loomio_memberships() + await sync_matrix_space() return { "status": "reconciled",