Add Loomio membership sync (reconcile group to active OC members via /api/b2/memberships)
This commit is contained in:
1 parent
5c28176d30
commit
57991cd9f7
1 file changed
+71
+71
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user