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:
1 parent
cddd9b6e3c
commit
82ada8c95b
1 file changed
+88
+88
@@ -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
|
||||
# @<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", "")
|
||||
# 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
|
||||
@<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:
|
||||
"""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",
|
||||
|
||||
Reference in new issue
Block a user