Matrix: invite all active members to co-op Space + General room on every sync (SSO usernames via Cloudron lookup; MATRIX_EXTRA_MXIDS for federated accounts)

This commit is contained in:
inference-bot committed 2026-10-02 12:27:45 -06:00
1 parent cddd9b6e3c
commit 82ada8c95b
1 file changed
+88
+88
View File
@@ -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 # mangled auto-handle (email local-part + random suffix). The B3 users API
# lets us rename them to the member's Cloudron username. # lets us rename them to the member's Cloudron username.
LOOMIO_B3_KEY = os.environ.get("LOOMIO_B3_KEY", "") 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
# @<cloudron-username>: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", "") LOOMIO_GROUP_ID = os.environ.get("LOOMIO_GROUP_ID", "")
# Operator accounts that must always remain in the Loomio group (never removed # Operator accounts that must always remain in the Loomio group (never removed
# by the remove_absent reconciliation), comma-separated. # 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} 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
@<username>: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 → @<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: async def sync_loomio_memberships() -> None:
"""Reconcile the Loomio group to the current active-member list. """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) logger.warning("Held-credit grant for %s failed: %s", email, e)
await sync_loomio_memberships() await sync_loomio_memberships()
await sync_matrix_space()
logger.info("Provisioned member %s (user_id=%s)", email, user_id) logger.info("Provisioned member %s (user_id=%s)", email, user_id)
return user_id return user_id
@@ -1581,6 +1666,7 @@ async def opencollective_webhook(request: Request, token: str):
deactivate_member(member["email"]) deactivate_member(member["email"])
await listmonk_sync(member["email"], subscribe=False) await listmonk_sync(member["email"], subscribe=False)
await sync_loomio_memberships() await sync_loomio_memberships()
await sync_matrix_space()
logger.info("Deactivated member %s (slug=%s)", member["email"], slug) logger.info("Deactivated member %s (slug=%s)", member["email"], slug)
return JSONResponse({"status": "deactivated", "email": member["email"]}) return JSONResponse({"status": "deactivated", "email": member["email"]})
return JSONResponse({"status": "deactivated", "note": "no matching member"}) return JSONResponse({"status": "deactivated", "note": "no matching member"})
@@ -1739,6 +1825,7 @@ async def admin_sync_loomio(request: Request):
_verify_admin_token(request) _verify_admin_token(request)
await sync_loomio_memberships() await sync_loomio_memberships()
await sync_matrix_space()
return {"status": "synced"} return {"status": "synced"}
@@ -2456,6 +2543,7 @@ async def reconcile_memberships() -> dict:
# 3. Sync Loomio. # 3. Sync Loomio.
await sync_loomio_memberships() await sync_loomio_memberships()
await sync_matrix_space()
return { return {
"status": "reconciled", "status": "reconciled",