From af8b071325917ec026b39e7dfa179ae96210a1a2 Mon Sep 17 00:00:00 2001 From: Astra Logical Date: Wed, 22 Jul 2026 13:31:00 -0400 Subject: [PATCH] Initial commit --- matrix_keycloak_bot.py | 247 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 matrix_keycloak_bot.py diff --git a/matrix_keycloak_bot.py b/matrix_keycloak_bot.py new file mode 100644 index 0000000..4906b65 --- /dev/null +++ b/matrix_keycloak_bot.py @@ -0,0 +1,247 @@ +import asyncio +import os +import sys +from keycloak import KeycloakAdmin +from keycloak.exceptions import KeycloakError +from nio import ( + AsyncClient, + MatrixRoom, + RoomMessageText, + ReactionEvent, + RoomSendResponse, +) + +# ------------------------------------------------------------------------------ +# Configuration (Set these via environment variables or replace inline) +# ------------------------------------------------------------------------------ +MATRIX_HOMESERVER = os.getenv("MATRIX_HOMESERVER", "https://matrix.nest.local") +MATRIX_BOT_USER = os.getenv("MATRIX_BOT_USER", "@access-bot:nest.local") +MATRIX_BOT_PASSWORD = os.getenv("MATRIX_BOT_PASSWORD", "SuperSecretBotPassword") +ADMIN_ROOM_ID = os.getenv("ADMIN_ROOM_ID", "!adminroomid:nest.local") +ADMIN_MATRIX_IDS = set(os.getenv("ADMIN_MATRIX_IDS", "@you:nest.local").split(",")) + +KEYCLOAK_URL = os.getenv("KEYCLOAK_URL", "https://keycloak.nest.local/") +KEYCLOAK_REALM = os.getenv("KEYCLOAK_REALM", "master") +KEYCLOAK_CLIENT_ID = os.getenv("KEYCLOAK_CLIENT_ID", "nest-matrix-bot") +KEYCLOAK_CLIENT_SECRET = os.getenv("KEYCLOAK_CLIENT_SECRET", "your-client-secret") + +# ------------------------------------------------------------------------------ +# In-Memory State +# Maps target message event_id -> request metadata +# ------------------------------------------------------------------------------ +pending_requests = {} + + +# ------------------------------------------------------------------------------ +# Keycloak Helper Functions +# ------------------------------------------------------------------------------ +def get_keycloak_client() -> KeycloakAdmin: + """Initializes and returns a KeycloakAdmin client using service account credentials.""" + return KeycloakAdmin( + server_url=KEYCLOAK_URL, + client_id=KEYCLOAK_CLIENT_ID, + client_secret=KEYCLOAK_CLIENT_SECRET, + realm_name=KEYCLOAK_REALM, + user_realm_name=KEYCLOAK_REALM, + grant_type="client_credentials", + verify=True, + ) + + +def add_user_to_kc_group(username: str, group_name: str) -> tuple[bool, str]: + """Queries Keycloak for the user and group UUIDs and adds the user to the group.""" + try: + kc = get_keycloak_client() + + # 1. Fetch User UUID (Assumes Matrix localpart matches Keycloak username) + users = kc.get_users(query={"username": username, "exact": True}) + if not users: + return False, f"Keycloak user '{username}' not found." + user_id = users[0]["id"] + + # 2. Fetch Group UUID + groups = kc.get_groups() + target_group = next((g for g in groups if g["name"].lower() == group_name.lower()), None) + if not target_group: + return False, f"Keycloak group '{group_name}' not found." + group_id = target_group["id"] + + # 3. Assign User to Group + kc.group_user_add(user_id=user_id, group_id=group_id) + return True, f"Successfully added '{username}' to group '{target_group['name']}'." + + except KeycloakError as e: + return False, f"Keycloak API error: {str(e)}" + except Exception as e: + return False, f"Unexpected error: {str(e)}" + + +# ------------------------------------------------------------------------------ +# Matrix Event Handlers +# ------------------------------------------------------------------------------ +async def on_message(client: AsyncClient, room: MatrixRoom, event: RoomMessageText): + """Listens for `!request ` commands.""" + # Ignore messages sent by the bot itself + if event.sender == client.user_id: + return + + body = event.body.strip() + if not body.startswith("!request"): + return + + parts = body.split(maxsplit=1) + if len(parts) < 2: + await client.room_send( + room_id=room.room_id, + message_type="m.room.message", + content={"msgtype": "m.notice", "body": "Usage: !request "}, + ) + return + + requested_group = parts[1].strip() + matrix_user = event.sender + # Extract username (e.g., "@alice:nest.local" -> "alice") + kc_username = matrix_user.split(":")[0].lstrip("@") + + # Send notification message to the private Admin Room + admin_msg_body = ( + f"📋 **Access Request**\n" + f"**User:** `{matrix_user}` (KC Username: `{kc_username}`)\n" + f"**Requested Group:** `{requested_group}`\n\n" + f"React with 👍 to **Approve** or 👎 to **Deny**." + ) + + res = await client.room_send( + room_id=ADMIN_ROOM_ID, + message_type="m.room.message", + content={ + "msgtype": "m.text", + "body": admin_msg_body, + "format": "org.matrix.custom.html", + "formatted_body": admin_msg_body.replace("\n", "
"), + }, + ) + + if isinstance(res, RoomSendResponse): + # Store pending request keyed by the admin message event_id + pending_requests[res.event_id] = { + "matrix_user": matrix_user, + "kc_username": kc_username, + "requested_group": requested_group, + "origin_room_id": room.room_id, + } + + # Confirm request receipt to the user + await client.room_send( + room_id=room.room_id, + message_type="m.room.message", + content={ + "msgtype": "m.notice", + "body": f"Request for group '{requested_group}' submitted for admin approval.", + }, + ) + + +async def on_reaction(client: AsyncClient, room: MatrixRoom, event: ReactionEvent): + """Listens for reaction approvals/denials in the admin room.""" + if room.room_id != ADMIN_ROOM_ID: + return + + # Verify reactor is an authorized admin + if event.sender not in ADMIN_MATRIX_IDS: + return + + relates_to = event.content.get("m.relates_to", {}) + target_event_id = relates_to.get("event_id") + emoji = relates_to.get("key") + + # Check if this reaction targets an active pending request + if not target_event_id or target_event_id not in pending_requests: + return + + request = pending_requests[target_event_id] + + if emoji == "👍": + # Execute Keycloak Group Addition + success, msg = add_user_to_kc_group( + request["kc_username"], request["requested_group"] + ) + + status_text = ( + f"✅ **APPROVED** by `{event.sender}`\n" + f"**User:** `{request['matrix_user']}` → **Group:** `{request['requested_group']}`\n" + f"**Result:** {msg}" + ) + + # Notify requesting user + user_msg = ( + f"🎉 Your request for access to group '{request['requested_group']}' " + f"has been approved!" if success else f"⚠️ Approval failed: {msg}" + ) + await client.room_send( + room_id=request["origin_room_id"], + message_type="m.room.message", + content={"msgtype": "m.notice", "body": user_msg}, + ) + + elif emoji == "👎": + status_text = ( + f"❌ **DENIED** by `{event.sender}`\n" + f"**User:** `{request['matrix_user']}` → **Group:** `{request['requested_group']}`" + ) + + await client.room_send( + room_id=request["origin_room_id"], + message_type="m.room.message", + content={ + "msgtype": "m.notice", + "body": f"Your request for group '{request['requested_group']}' was denied.", + }, + ) + else: + return # Ignore other reactions + + # Update the original admin room message to reflect resolution status + await client.room_send( + room_id=ADMIN_ROOM_ID, + message_type="m.room.message", + content={ + "msgtype": "m.text", + "body": status_text, + "format": "org.matrix.custom.html", + "formatted_body": status_text.replace("\n", "
"), + "m.relates_to": {"rel_type": "m.replace", "event_id": target_event_id}, + }, + ) + + # Clean up request state + del pending_requests[target_event_id] + + +# ------------------------------------------------------------------------------ +# Main Event Loop +# ------------------------------------------------------------------------------ +async def main(): + client = AsyncClient(MATRIX_HOMESERVER, MATRIX_BOT_USER) + + # Register callbacks + client.add_event_callback( + lambda room, event: on_message(client, room, event), RoomMessageText + ) + client.add_event_callback( + lambda room, event: on_reaction(client, room, event), ReactionEvent + ) + + print(f"Connecting to Matrix homeserver {MATRIX_HOMESERVER}...") + await client.login(MATRIX_BOT_PASSWORD) + print(f"Logged in as {MATRIX_BOT_USER}. Syncing...") + + await client.sync_forever(timeout=30000, full_state=True) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\nBot stopped by user.") + sys.exit(0)