diff --git a/app/main.py b/app/main.py index 1c4cc79..a4f410a 100644 --- a/app/main.py +++ b/app/main.py @@ -70,6 +70,19 @@ MEMBERS_GROUP_ID = os.environ.get("MEMBERS_GROUP_ID", "") # being deleted) so they lose access but can reclaim their data on reactivation. INACTIVE_GROUP_ID = os.environ.get("INACTIVE_GROUP_ID", "") +# Loomio integration (forum.inference.coop). The portal reconciles the Loomio +# group to the current active-member list via the User API (/api/b2). +LOOMIO_BASE = os.environ.get("LOOMIO_BASE", "https://forum.inference.coop") +LOOMIO_API_KEY = os.environ.get("LOOMIO_API_KEY", "") +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. +LOOMIO_ALWAYS_KEEP = [ + e.strip() + for e in os.environ.get("LOOMIO_ALWAYS_KEEP", "info@inference.coop,bot@inference.coop").split(",") + if e.strip() +] + # Persistent store (SQLite) for email → LiteLLM key token mapping. # Lives in /app/data (Cloudron localstorage addon persists this). DB_PATH = os.environ.get("DB_PATH", "/app/data/members.db") @@ -160,6 +173,62 @@ async def is_active_member(slug: str) -> bool: return False +async def get_active_member_emails() -> list[str]: + """Return the list of active financial contributor emails from Open Collective.""" + query = ( + '{ collective(slug: "%s") { members(limit: 100) { nodes { role account { email slug } } } } }' + % OC_COLLECTIVE_SLUG + ) + async with httpx.AsyncClient() as client: + r = await client.post(OC_GRAPHQL_URL, json={"query": query}) + if r.status_code != 200: + logger.warning("OC member list fetch failed: %s", r.status_code) + return [] + nodes = r.json().get("data", {}).get("collective", {}).get("members", {}).get("nodes", []) + emails = [] + for n in nodes: + role = n.get("role", "") + acct = n.get("account", {}) or {} + email = acct.get("email") + if role in ("BACKER", "ADMIN") and email: + emails.append(email) + return emails + + +async def sync_loomio_memberships() -> None: + """Reconcile the Loomio group to the current active-member list. + + Uses POST /api/b2/memberships with remove_absent=1, which adds new members + and removes anyone not in the list. Operator accounts (LOOMIO_ALWAYS_KEEP) + are always included so they are never removed. + """ + if not LOOMIO_API_KEY or not LOOMIO_GROUP_ID: + logger.warning("Loomio not configured; skipping membership sync") + return + + emails = await get_active_member_emails() + # Always keep operator accounts in the group. + for keep in LOOMIO_ALWAYS_KEEP: + if keep not in emails: + emails.append(keep) + + async with httpx.AsyncClient() as client: + r = await client.post( + f"{LOOMIO_BASE}/api/b2/memberships", + headers={"Authorization": f"Bearer {LOOMIO_API_KEY}"}, + json={"group_id": int(LOOMIO_GROUP_ID), "emails": emails, "remove_absent": 1}, + ) + if r.status_code != 200: + logger.warning("Loomio membership sync failed: %s %s", r.status_code, r.text[:200]) + return + result = r.json() + logger.info( + "Loomio sync: added=%s removed=%s", + result.get("added_emails", []), + result.get("removed_emails", []), + ) + + def store_member(email: str, key_token: str, cloudron_user_id: str, slug: str = "") -> None: conn = get_db() conn.execute( @@ -400,6 +469,7 @@ async def opencollective_webhook(request: Request, token: str): await cloudron_set_group(member["cloudron_user_id"], INACTIVE_GROUP_ID) # Mark inactive in our DB (key injector will refuse requests) deactivate_member(member["email"]) + await sync_loomio_memberships() 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"}) @@ -591,6 +661,7 @@ async def oauth_callback(request: Request): key_token = await litellm_create_key(email, MEMBER_BUDGET) store_member(email, key_token, user_id, slug) await cloudron_send_invite(user_id, email) + await sync_loomio_memberships() logger.info("Provisioned member %s (user_id=%s)", email, user_id)