Initial commit
This commit is contained in:
commit
af8b071325
1 changed files with 247 additions and 0 deletions
247
matrix_keycloak_bot.py
Normal file
247
matrix_keycloak_bot.py
Normal file
|
|
@ -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 <group_name>` 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 <group_name>"},
|
||||||
|
)
|
||||||
|
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", "<br>"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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", "<br>"),
|
||||||
|
"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)
|
||||||
Loading…
Add table
Reference in a new issue