Add Loomio membership sync (reconcile group to active OC members via /api/b2/memberships)

This commit is contained in:
inference-bot committed 2026-09-06 14:24:53 -06:00
1 parent 5c28176d30
commit 57991cd9f7
1 file changed
+71
+71
View File
@@ -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)