Misc fixes
This commit is contained in:
parent
3db77f91e5
commit
f8d6302f10
1 changed files with 149 additions and 223 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import asyncio
|
||||
import markdown
|
||||
import os
|
||||
import sys
|
||||
import markdown
|
||||
from keycloak import KeycloakAdmin
|
||||
from keycloak.exceptions import KeycloakError
|
||||
from nio import (
|
||||
|
|
@ -11,11 +11,11 @@ from nio import (
|
|||
ReactionEvent,
|
||||
RoomSendResponse,
|
||||
InviteMemberEvent,
|
||||
OlmUnverifiedDeviceError
|
||||
OlmUnverifiedDeviceError,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Configuration (Set these via environment variables or replace inline)
|
||||
# Configuration
|
||||
# ------------------------------------------------------------------------------
|
||||
MATRIX_HOMESERVER = os.getenv("MATRIX_HOMESERVER", "https://matrix.nest.local")
|
||||
MATRIX_BOT_USER = os.getenv("MATRIX_BOT_USER", "@access-bot:nest.local")
|
||||
|
|
@ -28,10 +28,7 @@ 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
|
||||
# ------------------------------------------------------------------------------
|
||||
# In-memory tracking of active requests
|
||||
pending_requests = {}
|
||||
|
||||
|
||||
|
|
@ -39,7 +36,7 @@ pending_requests = {}
|
|||
# Keycloak Helper Functions
|
||||
# ------------------------------------------------------------------------------
|
||||
def get_keycloak_client() -> KeycloakAdmin:
|
||||
"""Initializes and returns a KeycloakAdmin client using service account credentials."""
|
||||
"""Initializes KeycloakAdmin client using client credentials."""
|
||||
return KeycloakAdmin(
|
||||
server_url=KEYCLOAK_URL,
|
||||
client_id=KEYCLOAK_CLIENT_ID,
|
||||
|
|
@ -50,65 +47,23 @@ def get_keycloak_client() -> KeycloakAdmin:
|
|||
verify=True,
|
||||
)
|
||||
|
||||
def get_requestable_groups(username: str) -> tuple[bool, str]:
|
||||
"""Fetches available Keycloak groups, filtering out the 'admin' group, and checks user membership."""
|
||||
try:
|
||||
kc = get_keycloak_client()
|
||||
|
||||
# 1. Fetch User UUID to get their current groups
|
||||
users = kc.get_users(query={"username": username, "exact": True})
|
||||
if not users:
|
||||
# If the user doesn't exist in Keycloak, we just show groups normally
|
||||
user_group_names = set()
|
||||
else:
|
||||
user_id = users[0]["id"]
|
||||
user_groups = kc.get_user_groups(user_id=user_id)
|
||||
user_group_names = {g["name"] for g in user_groups}
|
||||
|
||||
# 2. Fetch all groups
|
||||
groups = kc.get_groups()
|
||||
|
||||
# Filter out 'admin' (case-insensitive)
|
||||
valid_groups = [g["name"] for g in groups if g["name"].lower() != "admin"]
|
||||
|
||||
if not valid_groups:
|
||||
return True, "No groups are currently available to request."
|
||||
|
||||
# 3. Format the output list with membership status
|
||||
group_lines = []
|
||||
for name in valid_groups:
|
||||
if name in user_group_names:
|
||||
group_lines.append(f"• `{name}` *(Already a member)*")
|
||||
else:
|
||||
group_lines.append(f"• `{name}`")
|
||||
|
||||
group_list = "\n".join(group_lines)
|
||||
return True, f"**Available Groups:**\n{group_list}"
|
||||
|
||||
except KeycloakError as e:
|
||||
return False, f"Keycloak API error: {str(e)}"
|
||||
except Exception as e:
|
||||
return False, f"Unexpected error: {str(e)}"
|
||||
|
||||
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."""
|
||||
"""Adds a Keycloak user to a target 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']}'."
|
||||
|
||||
|
|
@ -118,137 +73,57 @@ def add_user_to_kc_group(username: str, group_name: str) -> tuple[bool, str]:
|
|||
return False, f"Unexpected error: {str(e)}"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Matrix Event Handlers
|
||||
# ------------------------------------------------------------------------------
|
||||
async def on_invite(client: AsyncClient, room: MatrixRoom, event: InviteMemberEvent):
|
||||
"""Auto-joins rooms when invited by an authorized user/homeserver."""
|
||||
# Ensure the invite is actually meant for this bot
|
||||
if event.state_key != client.user_id:
|
||||
return
|
||||
def get_requestable_groups(username: str) -> tuple[bool, str]:
|
||||
"""Fetches available Keycloak groups, filtering out 'admin' and checking user membership."""
|
||||
try:
|
||||
kc = get_keycloak_client()
|
||||
|
||||
# Only accept invites from my domain
|
||||
if not event.sender.endswith(":starling.mynest.love"): return
|
||||
|
||||
print(f"Received invite for room {room.room_id} from {event.sender}. Joining...")
|
||||
|
||||
response = await client.join(room.room_id)
|
||||
if hasattr(response, "room_id"):
|
||||
print(f"Successfully joined room {room.room_id}")
|
||||
users = kc.get_users(query={"username": username, "exact": True})
|
||||
if not users:
|
||||
user_group_names = set()
|
||||
else:
|
||||
print(f"Failed to join room {room.room_id}: {response}")
|
||||
user_id = users[0]["id"]
|
||||
user_groups = kc.get_user_groups(user_id=user_id)
|
||||
user_group_names = {g["name"] for g in user_groups}
|
||||
|
||||
groups = kc.get_groups()
|
||||
valid_groups = [g["name"] for g in groups if g["name"].lower() != "admin"]
|
||||
|
||||
if not valid_groups:
|
||||
return True, "No groups are currently available to request."
|
||||
|
||||
group_lines = []
|
||||
for name in valid_groups:
|
||||
if name in user_group_names:
|
||||
group_lines.append(f"* `{name}` *(Already a member)*")
|
||||
else:
|
||||
group_lines.append(f"* `{name}`")
|
||||
|
||||
group_list = "\n".join(group_lines)
|
||||
return True, f"**Available Groups:**\n{group_list}"
|
||||
|
||||
except KeycloakError as e:
|
||||
return False, f"Keycloak API error: {str(e)}"
|
||||
except Exception as e:
|
||||
return False, f"Unexpected error: {str(e)}"
|
||||
|
||||
|
||||
async def on_message(client: AsyncClient, room: MatrixRoom, event: RoomMessageText):
|
||||
"""Listens for commands."""
|
||||
# Ignore messages sent by the bot itself
|
||||
if event.sender == client.user_id:
|
||||
return
|
||||
|
||||
body = event.body.strip()
|
||||
|
||||
# Extract username early so both commands can utilize it
|
||||
matrix_user = event.sender
|
||||
kc_username = matrix_user.split(":")[0].lstrip("@")
|
||||
|
||||
# --- COMMAND: !groups ---
|
||||
if body.startswith("!groups"):
|
||||
success, msg = get_requestable_groups(kc_username)
|
||||
await send_message_safe(
|
||||
client,
|
||||
room_id=room.room_id,
|
||||
content={
|
||||
"msgtype": "m.notice" if not success else "m.text",
|
||||
"body": msg,
|
||||
"format": "org.matrix.custom.html",
|
||||
"formatted_body": msg.replace("\n", "<br>")
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
# --- COMMAND: !request ---
|
||||
if not body.startswith("!request"):
|
||||
return
|
||||
|
||||
parts = body.split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
await send_message_safe(
|
||||
client,
|
||||
room_id=room.room_id,
|
||||
content={"msgtype": "m.notice", "body": "Usage: !request <group_name> or !groups"},
|
||||
)
|
||||
return
|
||||
|
||||
requested_group = parts[1].strip()
|
||||
|
||||
# Block 'admin' requests immediately
|
||||
if requested_group.lower() == "admin":
|
||||
await send_message_safe(
|
||||
client,
|
||||
room_id=room.room_id,
|
||||
content={
|
||||
"msgtype": "m.notice",
|
||||
"body": "❌ Requests for the 'admin' group are not permitted."
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
matrix_user = event.sender
|
||||
kc_username = matrix_user.split(":")[0].lstrip("@")
|
||||
|
||||
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 send_message_safe(
|
||||
client,
|
||||
room_id=ADMIN_ROOM_ID,
|
||||
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):
|
||||
pending_requests[res.event_id] = {
|
||||
"matrix_user": matrix_user,
|
||||
"kc_username": kc_username,
|
||||
"requested_group": requested_group,
|
||||
"origin_room_id": room.room_id,
|
||||
}
|
||||
|
||||
await send_message_safe(
|
||||
client,
|
||||
room_id=room.room_id,
|
||||
content={
|
||||
"msgtype": "m.notice",
|
||||
"body": f"Request for group '{requested_group}' submitted for admin approval.",
|
||||
},
|
||||
)
|
||||
|
||||
async def send_message_safe(
|
||||
client: AsyncClient,
|
||||
room_id: str,
|
||||
content: dict,
|
||||
message_type: str = "m.room.message"
|
||||
):
|
||||
"""Sends a message to a room, automatically trusting unverified devices on OlmUnverifiedDeviceError."""
|
||||
# ------------------------------------------------------------------------------
|
||||
# Matrix Message Sending Wrappers
|
||||
# ------------------------------------------------------------------------------
|
||||
async def send_message_safe(client: AsyncClient, room_id: str, content: dict):
|
||||
while True:
|
||||
try:
|
||||
return await client.room_send(
|
||||
room_id=room_id,
|
||||
message_type=message_type,
|
||||
message_type="m.room.message",
|
||||
content=content,
|
||||
)
|
||||
except OlmUnverifiedDeviceError as e:
|
||||
print(f"Auto-trusting unverified device '{e.device.device_id}' for user '{e.device.user_id}'...")
|
||||
client.verify_device(e.device)
|
||||
|
||||
|
||||
async def send_markdown_message(
|
||||
client: AsyncClient,
|
||||
room_id: str,
|
||||
|
|
@ -256,8 +131,6 @@ async def send_markdown_message(
|
|||
msgtype: str = "m.text",
|
||||
relates_to: dict = None,
|
||||
):
|
||||
"""Compiles Markdown to valid Matrix HTML payloads and handles message edits cleanly."""
|
||||
# Convert Markdown to HTML
|
||||
html_body = markdown.markdown(md_text, extensions=["extra", "sane_lists"])
|
||||
|
||||
content = {
|
||||
|
|
@ -267,7 +140,6 @@ async def send_markdown_message(
|
|||
"formatted_body": html_body,
|
||||
}
|
||||
|
||||
# Properly format Matrix m.replace edits (MSC2676)
|
||||
if relates_to:
|
||||
content["m.relates_to"] = relates_to
|
||||
content["m.new_content"] = {
|
||||
|
|
@ -281,95 +153,150 @@ async def send_markdown_message(
|
|||
|
||||
return await send_message_safe(client, room_id, content)
|
||||
|
||||
async def on_reaction(client: AsyncClient, room: MatrixRoom, event: ReactionEvent):
|
||||
"""Listens for reaction approvals/denials in the admin room."""
|
||||
print(f"[REACTION] Received '{event.key}' from {event.sender} on event '{event.reacts_to}'")
|
||||
|
||||
if room.room_id != ADMIN_ROOM_ID:
|
||||
# ------------------------------------------------------------------------------
|
||||
# Matrix Event Handlers
|
||||
# ------------------------------------------------------------------------------
|
||||
async def on_invite(client: AsyncClient, room: MatrixRoom, event: InviteMemberEvent):
|
||||
if event.state_key != client.user_id:
|
||||
return
|
||||
if not event.sender.endswith(":starling.mynest.love"):
|
||||
return
|
||||
|
||||
# Verify reactor is an authorized admin
|
||||
if event.sender not in ADMIN_MATRIX_IDS:
|
||||
print(f"[REACTION IGNORED] Sender '{event.sender}' is not in ADMIN_MATRIX_IDS.")
|
||||
print(f"Received invite for room {room.room_id} from {event.sender}. Joining...")
|
||||
await client.join(room.room_id)
|
||||
|
||||
|
||||
async def on_message(client: AsyncClient, room: MatrixRoom, event: RoomMessageText):
|
||||
if event.sender == client.user_id:
|
||||
return
|
||||
|
||||
body = event.body.strip()
|
||||
matrix_user = event.sender
|
||||
kc_username = matrix_user.split(":")[0].lstrip("@")
|
||||
|
||||
# --- COMMAND: !groups ---
|
||||
if body.startswith("!groups"):
|
||||
success, msg = get_requestable_groups(kc_username)
|
||||
await send_markdown_message(
|
||||
client,
|
||||
room_id=room.room_id,
|
||||
md_text=msg,
|
||||
msgtype="m.notice" if not success else "m.text",
|
||||
)
|
||||
return
|
||||
|
||||
# --- COMMAND: !request ---
|
||||
if not body.startswith("!request"):
|
||||
return
|
||||
|
||||
parts = body.split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
await send_markdown_message(
|
||||
client,
|
||||
room_id=room.room_id,
|
||||
md_text="Usage: `!request <group_name>` or `!groups`",
|
||||
msgtype="m.notice",
|
||||
)
|
||||
return
|
||||
|
||||
requested_group = parts[1].strip()
|
||||
|
||||
if requested_group.lower() == "admin":
|
||||
await send_markdown_message(
|
||||
client,
|
||||
room_id=room.room_id,
|
||||
md_text="❌ Requests for the **admin** group are not permitted.",
|
||||
msgtype="m.notice",
|
||||
)
|
||||
return
|
||||
|
||||
admin_msg_body = (
|
||||
f"📋 **Access Request**\n\n"
|
||||
f"**User:** `{matrix_user}` (KC: `{kc_username}`)\n"
|
||||
f"**Requested Group:** `{requested_group}`\n\n"
|
||||
f"React with 👍 to **Approve** or 👎 to **Deny**."
|
||||
)
|
||||
|
||||
res = await send_markdown_message(
|
||||
client,
|
||||
room_id=ADMIN_ROOM_ID,
|
||||
md_text=admin_msg_body,
|
||||
)
|
||||
|
||||
if isinstance(res, RoomSendResponse):
|
||||
pending_requests[res.event_id] = {
|
||||
"matrix_user": matrix_user,
|
||||
"kc_username": kc_username,
|
||||
"requested_group": requested_group,
|
||||
"origin_room_id": room.room_id,
|
||||
}
|
||||
|
||||
await send_markdown_message(
|
||||
client,
|
||||
room_id=room.room_id,
|
||||
md_text=f"Request for group `{requested_group}` submitted for admin approval.",
|
||||
msgtype="m.notice",
|
||||
)
|
||||
|
||||
|
||||
async def on_reaction(client: AsyncClient, room: MatrixRoom, event: ReactionEvent):
|
||||
if room.room_id != ADMIN_ROOM_ID or event.sender not in ADMIN_MATRIX_IDS:
|
||||
return
|
||||
|
||||
target_event_id = event.reacts_to
|
||||
emoji = event.key
|
||||
|
||||
# Check if this reaction targets an active pending request
|
||||
if not target_event_id or target_event_id not in pending_requests:
|
||||
print(f"[REACTION IGNORED] Event ID '{target_event_id}' not found in pending_requests.")
|
||||
return
|
||||
|
||||
request = pending_requests[target_event_id]
|
||||
|
||||
# Use substring check to catch Unicode variation selectors (e.g. 👍 vs 👍\ufe0f)
|
||||
if "👍" in 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"✅ **APPROVED** by `{event.sender}`\n\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}"
|
||||
f"🎉 Your request for access to group `{request['requested_group']}` has been approved!"
|
||||
if success else f"⚠️ Approval failed: {msg}"
|
||||
)
|
||||
await send_message_safe(
|
||||
await send_markdown_message(
|
||||
client,
|
||||
room_id=request["origin_room_id"],
|
||||
content={"msgtype": "m.notice", "body": user_msg},
|
||||
md_text=user_msg,
|
||||
msgtype="m.notice",
|
||||
)
|
||||
|
||||
elif "👎" in emoji:
|
||||
status_text = (
|
||||
f"❌ **DENIED** by `{event.sender}`\n"
|
||||
f"❌ **DENIED** by `{event.sender}`\n\n"
|
||||
f"**User:** `{request['matrix_user']}` → **Group:** `{request['requested_group']}`"
|
||||
)
|
||||
|
||||
await send_message_safe(
|
||||
await send_markdown_message(
|
||||
client,
|
||||
room_id=request["origin_room_id"],
|
||||
content={
|
||||
"msgtype": "m.notice",
|
||||
"body": f"Your request for group '{request['requested_group']}' was denied.",
|
||||
},
|
||||
md_text=f"Your request for group `{request['requested_group']}` was denied.",
|
||||
msgtype="m.notice",
|
||||
)
|
||||
else:
|
||||
print(f"[REACTION IGNORED] Emoji '{emoji}' is neither 👍 nor 👎.")
|
||||
return # Ignore other reactions
|
||||
return
|
||||
|
||||
# Update the original admin room message to reflect resolution status
|
||||
fallback_text = f"* {status_text}"
|
||||
|
||||
await send_message_safe(
|
||||
# Update original message via m.replace
|
||||
await send_markdown_message(
|
||||
client,
|
||||
room_id=ADMIN_ROOM_ID,
|
||||
content={
|
||||
"msgtype": "m.text",
|
||||
"body": fallback_text,
|
||||
"format": "org.matrix.custom.html",
|
||||
"formatted_body": fallback_text.replace("\n", "<br>"),
|
||||
"m.new_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
|
||||
},
|
||||
},
|
||||
md_text=status_text,
|
||||
relates_to={"rel_type": "m.replace", "event_id": target_event_id},
|
||||
)
|
||||
|
||||
# Clean up request state
|
||||
del pending_requests[target_event_id]
|
||||
|
||||
|
||||
|
|
@ -380,10 +307,9 @@ async def main():
|
|||
client = AsyncClient(
|
||||
MATRIX_HOMESERVER,
|
||||
MATRIX_BOT_USER,
|
||||
store_path="/app/store"
|
||||
store_path="/app/store",
|
||||
)
|
||||
|
||||
# Register callbacks
|
||||
client.add_event_callback(
|
||||
lambda room, event: on_message(client, room, event), RoomMessageText
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue