2026-07-22 13:31:00 -04:00
|
|
|
|
import asyncio
|
|
|
|
|
|
import os
|
|
|
|
|
|
import sys
|
2026-07-22 18:21:32 -04:00
|
|
|
|
import markdown
|
2026-07-23 11:27:18 -04:00
|
|
|
|
import json
|
2026-07-23 13:32:12 -04:00
|
|
|
|
import pickle
|
2026-07-23 11:45:09 -04:00
|
|
|
|
import logging
|
2026-07-22 13:31:00 -04:00
|
|
|
|
from keycloak import KeycloakAdmin
|
|
|
|
|
|
from keycloak.exceptions import KeycloakError
|
2026-07-23 11:45:09 -04:00
|
|
|
|
|
|
|
|
|
|
from mautrix.client import Client
|
2026-07-23 13:47:32 -04:00
|
|
|
|
from mautrix.crypto import OlmMachine
|
|
|
|
|
|
from mautrix.crypto.account import Account
|
2026-07-23 13:32:12 -04:00
|
|
|
|
from mautrix.crypto.store import MemoryCryptoStore
|
|
|
|
|
|
from mautrix.client.state_store import MemoryStateStore
|
2026-07-23 11:45:09 -04:00
|
|
|
|
from mautrix.types import (
|
|
|
|
|
|
EventType, MessageType, TextMessageEventContent, Format,
|
2026-07-23 12:01:14 -04:00
|
|
|
|
Event, RoomID, Membership, RelationType, UserID
|
2026-07-22 13:31:00 -04:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------------------
|
2026-07-22 18:21:32 -04:00
|
|
|
|
# Configuration
|
2026-07-22 13:31:00 -04:00
|
|
|
|
# ------------------------------------------------------------------------------
|
2026-07-23 11:45:09 -04:00
|
|
|
|
MATRIX_HOMESERVER = os.getenv("MATRIX_HOMESERVER", "https://matrix.mynest.love")
|
|
|
|
|
|
MATRIX_BOT_USER = os.getenv("MATRIX_BOT_USER", "@access-bot:mynest.love")
|
2026-07-22 13:31:00 -04:00
|
|
|
|
MATRIX_BOT_PASSWORD = os.getenv("MATRIX_BOT_PASSWORD", "SuperSecretBotPassword")
|
2026-07-23 11:45:09 -04:00
|
|
|
|
ADMIN_ROOM_ID = os.getenv("ADMIN_ROOM_ID", "!adminroomid:mynest.love")
|
|
|
|
|
|
ADMIN_MATRIX_IDS = set(os.getenv("ADMIN_MATRIX_IDS", "@you:mynest.love").split(","))
|
2026-07-22 13:31:00 -04:00
|
|
|
|
|
2026-07-23 11:45:09 -04:00
|
|
|
|
KEYCLOAK_URL = os.getenv("KEYCLOAK_URL", "https://keycloak.mynest.love/")
|
2026-07-22 13:31:00 -04:00
|
|
|
|
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")
|
|
|
|
|
|
|
2026-07-23 13:32:12 -04:00
|
|
|
|
SESSION_FILE = os.getenv("MATRIX_SESSION_FILE", "/app/store/bot_session.json")
|
|
|
|
|
|
CRYPTO_STORE_FILE = os.getenv("MATRIX_CRYPTO_STORE", "/app/store/crypto_store.pkl")
|
2026-07-23 13:28:43 -04:00
|
|
|
|
|
2026-07-23 11:45:09 -04:00
|
|
|
|
# In-memory tracking of active requests (Event ID -> Request Info)
|
2026-07-22 13:31:00 -04:00
|
|
|
|
pending_requests = {}
|
|
|
|
|
|
|
2026-07-23 11:45:09 -04:00
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
|
|
|
|
log = logging.getLogger("keycloak_bot")
|
|
|
|
|
|
|
2026-07-22 13:31:00 -04:00
|
|
|
|
|
2026-07-23 12:01:14 -04:00
|
|
|
|
# ------------------------------------------------------------------------------
|
2026-07-23 13:32:12 -04:00
|
|
|
|
# Custom Pickle-Backed Crypto Store for Persistence
|
2026-07-23 12:01:14 -04:00
|
|
|
|
# ------------------------------------------------------------------------------
|
2026-07-23 13:32:12 -04:00
|
|
|
|
class PickleCryptoStore(MemoryCryptoStore):
|
2026-07-23 13:35:33 -04:00
|
|
|
|
def __init__(self, filepath: str, account_id: str = "bot", pickle_key: str = ""):
|
|
|
|
|
|
super().__init__(account_id=account_id, pickle_key=pickle_key)
|
2026-07-23 13:32:12 -04:00
|
|
|
|
self.filepath = filepath
|
|
|
|
|
|
self.load()
|
|
|
|
|
|
|
|
|
|
|
|
def load(self):
|
|
|
|
|
|
if os.path.exists(self.filepath):
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(self.filepath, "rb") as f:
|
|
|
|
|
|
data = pickle.load(f)
|
|
|
|
|
|
self.accounts = data.get("accounts", {})
|
|
|
|
|
|
self.sessions = data.get("sessions", {})
|
|
|
|
|
|
self.inbound_group_sessions = data.get("inbound_group_sessions", {})
|
|
|
|
|
|
self.outbound_group_sessions = data.get("outbound_group_sessions", {})
|
|
|
|
|
|
self.devices = data.get("devices", {})
|
|
|
|
|
|
self.cross_signing_keys = data.get("cross_signing_keys", {})
|
|
|
|
|
|
log.info(f"Loaded crypto store from {self.filepath}")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
log.warning(f"Failed to load pickle crypto store: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
def save(self):
|
|
|
|
|
|
try:
|
|
|
|
|
|
data = {
|
|
|
|
|
|
"accounts": self.accounts,
|
|
|
|
|
|
"sessions": self.sessions,
|
|
|
|
|
|
"inbound_group_sessions": self.inbound_group_sessions,
|
|
|
|
|
|
"outbound_group_sessions": self.outbound_group_sessions,
|
|
|
|
|
|
"devices": self.devices,
|
|
|
|
|
|
"cross_signing_keys": self.cross_signing_keys,
|
|
|
|
|
|
}
|
|
|
|
|
|
directory = os.path.dirname(self.filepath)
|
|
|
|
|
|
if directory:
|
|
|
|
|
|
os.makedirs(directory, exist_ok=True)
|
|
|
|
|
|
with open(self.filepath, "wb") as f:
|
|
|
|
|
|
pickle.dump(data, f)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
log.error(f"Failed to save pickle crypto store: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
async def put_account(self, *args, **kwargs):
|
|
|
|
|
|
await super().put_account(*args, **kwargs)
|
|
|
|
|
|
self.save()
|
|
|
|
|
|
|
|
|
|
|
|
async def put_session(self, *args, **kwargs):
|
|
|
|
|
|
await super().put_session(*args, **kwargs)
|
|
|
|
|
|
self.save()
|
|
|
|
|
|
|
|
|
|
|
|
async def put_inbound_group_session(self, *args, **kwargs):
|
|
|
|
|
|
await super().put_inbound_group_session(*args, **kwargs)
|
|
|
|
|
|
self.save()
|
|
|
|
|
|
|
|
|
|
|
|
async def put_outbound_group_session(self, *args, **kwargs):
|
|
|
|
|
|
await super().put_outbound_group_session(*args, **kwargs)
|
|
|
|
|
|
self.save()
|
|
|
|
|
|
|
|
|
|
|
|
async def put_device(self, *args, **kwargs):
|
|
|
|
|
|
await super().put_device(*args, **kwargs)
|
|
|
|
|
|
self.save()
|
|
|
|
|
|
|
|
|
|
|
|
async def put_cross_signing_key(self, user_id, usage, key):
|
|
|
|
|
|
await super().put_cross_signing_key(user_id, usage, key)
|
|
|
|
|
|
self.save()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------------------
|
|
|
|
|
|
# Custom State Store supporting find_shared_rooms
|
|
|
|
|
|
# ------------------------------------------------------------------------------
|
|
|
|
|
|
class CustomMemoryStateStore(MemoryStateStore):
|
|
|
|
|
|
def __init__(self, bot_mxid: str):
|
|
|
|
|
|
super().__init__()
|
2026-07-23 12:01:14 -04:00
|
|
|
|
self.bot_mxid = bot_mxid
|
|
|
|
|
|
|
|
|
|
|
|
async def find_shared_rooms(self, user_id: UserID) -> list[RoomID]:
|
|
|
|
|
|
shared = []
|
|
|
|
|
|
states = getattr(self, "states", {})
|
|
|
|
|
|
for room_id in states.keys():
|
|
|
|
|
|
try:
|
|
|
|
|
|
rid = RoomID(room_id)
|
|
|
|
|
|
user_membership = await self.get_membership(rid, user_id)
|
|
|
|
|
|
bot_membership = await self.get_membership(rid, UserID(self.bot_mxid))
|
|
|
|
|
|
if user_membership == Membership.JOIN and bot_membership == Membership.JOIN:
|
|
|
|
|
|
shared.append(rid)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return shared
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-22 13:31:00 -04:00
|
|
|
|
# ------------------------------------------------------------------------------
|
|
|
|
|
|
# Keycloak Helper Functions
|
|
|
|
|
|
# ------------------------------------------------------------------------------
|
|
|
|
|
|
def get_keycloak_client() -> KeycloakAdmin:
|
2026-07-22 18:21:32 -04:00
|
|
|
|
"""Initializes KeycloakAdmin client using client credentials."""
|
2026-07-22 13:31:00 -04:00
|
|
|
|
return KeycloakAdmin(
|
|
|
|
|
|
server_url=KEYCLOAK_URL,
|
|
|
|
|
|
client_id=KEYCLOAK_CLIENT_ID,
|
2026-07-22 17:37:31 -04:00
|
|
|
|
client_secret_key=KEYCLOAK_CLIENT_SECRET,
|
2026-07-22 13:31:00 -04:00
|
|
|
|
realm_name=KEYCLOAK_REALM,
|
|
|
|
|
|
user_realm_name=KEYCLOAK_REALM,
|
|
|
|
|
|
grant_type="client_credentials",
|
|
|
|
|
|
verify=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-22 18:29:35 -04:00
|
|
|
|
def check_group_membership(username: str, group_name: str) -> tuple[bool, str]:
|
|
|
|
|
|
try:
|
|
|
|
|
|
kc = get_keycloak_client()
|
|
|
|
|
|
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"]
|
|
|
|
|
|
user_groups = kc.get_user_groups(user_id=user_id)
|
|
|
|
|
|
user_group_names = {g["name"].lower() for g in user_groups}
|
|
|
|
|
|
|
|
|
|
|
|
if group_name.lower() in user_group_names:
|
|
|
|
|
|
return True, f"ℹ️ You are already a member of the `{group_name}` group."
|
|
|
|
|
|
|
|
|
|
|
|
return False, ""
|
|
|
|
|
|
except KeycloakError as e:
|
|
|
|
|
|
return False, f"⚠️ Keycloak API error: {str(e)}"
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return False, f"⚠️ Unexpected error: {str(e)}"
|
|
|
|
|
|
|
2026-07-22 18:21:32 -04:00
|
|
|
|
def add_user_to_kc_group(username: str, group_name: str) -> tuple[bool, str]:
|
|
|
|
|
|
try:
|
|
|
|
|
|
kc = get_keycloak_client()
|
|
|
|
|
|
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"]
|
|
|
|
|
|
|
|
|
|
|
|
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"]
|
|
|
|
|
|
|
|
|
|
|
|
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)}"
|
|
|
|
|
|
|
2026-07-22 18:13:55 -04:00
|
|
|
|
def get_requestable_groups(username: str) -> tuple[bool, str]:
|
2026-07-22 18:08:51 -04:00
|
|
|
|
try:
|
|
|
|
|
|
kc = get_keycloak_client()
|
2026-07-22 18:13:55 -04:00
|
|
|
|
users = kc.get_users(query={"username": username, "exact": True})
|
|
|
|
|
|
if not users:
|
|
|
|
|
|
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}
|
|
|
|
|
|
|
2026-07-22 18:08:51 -04:00
|
|
|
|
groups = kc.get_groups()
|
|
|
|
|
|
valid_groups = [g["name"] for g in groups if g["name"].lower() != "admin"]
|
2026-07-22 18:21:32 -04:00
|
|
|
|
|
2026-07-22 18:08:51 -04:00
|
|
|
|
if not valid_groups:
|
|
|
|
|
|
return True, "No groups are currently available to request."
|
2026-07-22 18:21:32 -04:00
|
|
|
|
|
2026-07-22 18:13:55 -04:00
|
|
|
|
group_lines = []
|
|
|
|
|
|
for name in valid_groups:
|
|
|
|
|
|
if name in user_group_names:
|
2026-07-22 18:21:32 -04:00
|
|
|
|
group_lines.append(f"* `{name}` *(Already a member)*")
|
2026-07-22 18:13:55 -04:00
|
|
|
|
else:
|
2026-07-22 18:21:32 -04:00
|
|
|
|
group_lines.append(f"* `{name}`")
|
|
|
|
|
|
|
2026-07-22 18:13:55 -04:00
|
|
|
|
group_list = "\n".join(group_lines)
|
2026-07-22 18:25:46 -04:00
|
|
|
|
return True, f"**Available Groups:**\n\n{group_list}"
|
2026-07-22 18:08:51 -04:00
|
|
|
|
except KeycloakError as e:
|
|
|
|
|
|
return False, f"Keycloak API error: {str(e)}"
|
|
|
|
|
|
except Exception as e:
|
2026-07-23 12:04:10 -04:00
|
|
|
|
return False, f"⚠️ Unexpected error: {str(e)}"
|
2026-07-22 13:31:00 -04:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 10:23:09 -04:00
|
|
|
|
# ------------------------------------------------------------------------------
|
2026-07-23 11:45:09 -04:00
|
|
|
|
# Matrix Helper Functions & Event Handlers
|
2026-07-22 18:21:32 -04:00
|
|
|
|
# ------------------------------------------------------------------------------
|
2026-07-23 11:45:09 -04:00
|
|
|
|
client: Client = None # Global client instance
|
2026-07-22 13:31:00 -04:00
|
|
|
|
|
2026-07-22 18:21:32 -04:00
|
|
|
|
async def send_markdown_message(
|
|
|
|
|
|
room_id: str,
|
|
|
|
|
|
md_text: str,
|
2026-07-23 11:45:09 -04:00
|
|
|
|
msgtype: MessageType = MessageType.TEXT,
|
|
|
|
|
|
edit_event_id: str = None,
|
2026-07-22 18:21:32 -04:00
|
|
|
|
):
|
2026-07-23 11:45:09 -04:00
|
|
|
|
"""Sends a markdown formatted message, optionally replacing an older message."""
|
2026-07-22 18:25:46 -04:00
|
|
|
|
html_body = markdown.markdown(md_text, extensions=["extra", "sane_lists", "nl2br"])
|
2026-07-23 11:45:09 -04:00
|
|
|
|
content = TextMessageEventContent(
|
|
|
|
|
|
msgtype=msgtype,
|
|
|
|
|
|
body=md_text,
|
|
|
|
|
|
format=Format.HTML,
|
|
|
|
|
|
formatted_body=html_body,
|
|
|
|
|
|
)
|
|
|
|
|
|
if edit_event_id:
|
|
|
|
|
|
content.set_edit(edit_event_id)
|
2026-07-22 13:31:00 -04:00
|
|
|
|
|
2026-07-23 11:45:09 -04:00
|
|
|
|
return await client.send_message(room_id, content)
|
2026-07-22 13:31:00 -04:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 11:45:09 -04:00
|
|
|
|
async def on_invite(evt: Event):
|
|
|
|
|
|
"""Auto-joins rooms when invited."""
|
|
|
|
|
|
if evt.state_key == client.mxid and evt.content.membership == Membership.INVITE:
|
|
|
|
|
|
log.info(f"Received invite for room {evt.room_id}. Joining...")
|
|
|
|
|
|
await client.join_room(evt.room_id)
|
2026-07-22 16:30:55 -04:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 11:45:09 -04:00
|
|
|
|
async def on_message(evt: Event):
|
|
|
|
|
|
"""Parses incoming text messages for commands."""
|
|
|
|
|
|
if evt.sender == client.mxid:
|
|
|
|
|
|
return
|
2026-07-22 16:30:55 -04:00
|
|
|
|
|
2026-07-23 11:45:09 -04:00
|
|
|
|
if not hasattr(evt.content, 'body') or not isinstance(evt.content.body, str):
|
2026-07-22 13:31:00 -04:00
|
|
|
|
return
|
|
|
|
|
|
|
2026-07-23 11:45:09 -04:00
|
|
|
|
body = evt.content.body.strip()
|
|
|
|
|
|
matrix_user = evt.sender
|
2026-07-22 18:13:55 -04:00
|
|
|
|
kc_username = matrix_user.split(":")[0].lstrip("@")
|
2026-07-22 18:21:32 -04:00
|
|
|
|
|
2026-07-22 18:08:51 -04:00
|
|
|
|
if body.startswith("!groups"):
|
2026-07-22 18:13:55 -04:00
|
|
|
|
success, msg = get_requestable_groups(kc_username)
|
2026-07-22 18:21:32 -04:00
|
|
|
|
await send_markdown_message(
|
2026-07-23 11:45:09 -04:00
|
|
|
|
room_id=evt.room_id,
|
2026-07-22 18:21:32 -04:00
|
|
|
|
md_text=msg,
|
2026-07-23 11:45:09 -04:00
|
|
|
|
msgtype=MessageType.NOTICE if not success else MessageType.TEXT,
|
2026-07-22 18:08:51 -04:00
|
|
|
|
)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-07-22 13:31:00 -04:00
|
|
|
|
if not body.startswith("!request"):
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
parts = body.split(maxsplit=1)
|
|
|
|
|
|
if len(parts) < 2:
|
2026-07-22 18:21:32 -04:00
|
|
|
|
await send_markdown_message(
|
2026-07-23 11:45:09 -04:00
|
|
|
|
room_id=evt.room_id,
|
2026-07-22 18:21:32 -04:00
|
|
|
|
md_text="Usage: `!request <group_name>` or `!groups`",
|
2026-07-23 11:45:09 -04:00
|
|
|
|
msgtype=MessageType.NOTICE,
|
2026-07-22 13:31:00 -04:00
|
|
|
|
)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
requested_group = parts[1].strip()
|
2026-07-22 18:21:32 -04:00
|
|
|
|
|
2026-07-22 18:08:51 -04:00
|
|
|
|
if requested_group.lower() == "admin":
|
2026-07-22 18:21:32 -04:00
|
|
|
|
await send_markdown_message(
|
2026-07-23 11:45:09 -04:00
|
|
|
|
room_id=evt.room_id,
|
2026-07-22 18:21:32 -04:00
|
|
|
|
md_text="❌ Requests for the **admin** group are not permitted.",
|
2026-07-23 11:45:09 -04:00
|
|
|
|
msgtype=MessageType.NOTICE,
|
2026-07-22 18:08:51 -04:00
|
|
|
|
)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-07-22 18:29:35 -04:00
|
|
|
|
already_member, membership_msg = check_group_membership(kc_username, requested_group)
|
|
|
|
|
|
if already_member or membership_msg:
|
|
|
|
|
|
await send_markdown_message(
|
2026-07-23 11:45:09 -04:00
|
|
|
|
room_id=evt.room_id,
|
2026-07-22 18:29:35 -04:00
|
|
|
|
md_text=membership_msg,
|
2026-07-23 11:45:09 -04:00
|
|
|
|
msgtype=MessageType.NOTICE,
|
2026-07-22 18:29:35 -04:00
|
|
|
|
)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-07-22 13:31:00 -04:00
|
|
|
|
admin_msg_body = (
|
2026-07-22 18:21:32 -04:00
|
|
|
|
f"📋 **Access Request**\n\n"
|
|
|
|
|
|
f"**User:** `{matrix_user}` (KC: `{kc_username}`)\n"
|
2026-07-22 13:31:00 -04:00
|
|
|
|
f"**Requested Group:** `{requested_group}`\n\n"
|
|
|
|
|
|
f"React with 👍 to **Approve** or 👎 to **Deny**."
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-23 11:45:09 -04:00
|
|
|
|
event_id = await send_markdown_message(
|
2026-07-22 13:31:00 -04:00
|
|
|
|
room_id=ADMIN_ROOM_ID,
|
2026-07-22 18:21:32 -04:00
|
|
|
|
md_text=admin_msg_body,
|
2026-07-22 13:31:00 -04:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-23 11:45:09 -04:00
|
|
|
|
if event_id:
|
|
|
|
|
|
pending_requests[event_id] = {
|
2026-07-22 13:31:00 -04:00
|
|
|
|
"matrix_user": matrix_user,
|
|
|
|
|
|
"kc_username": kc_username,
|
|
|
|
|
|
"requested_group": requested_group,
|
2026-07-23 11:45:09 -04:00
|
|
|
|
"origin_room_id": evt.room_id,
|
2026-07-22 13:31:00 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 18:21:32 -04:00
|
|
|
|
await send_markdown_message(
|
2026-07-23 11:45:09 -04:00
|
|
|
|
room_id=evt.room_id,
|
2026-07-22 18:21:32 -04:00
|
|
|
|
md_text=f"Request for group `{requested_group}` submitted for admin approval.",
|
2026-07-23 11:45:09 -04:00
|
|
|
|
msgtype=MessageType.NOTICE,
|
2026-07-22 13:31:00 -04:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-22 18:19:10 -04:00
|
|
|
|
|
2026-07-23 11:45:09 -04:00
|
|
|
|
async def on_reaction(evt: Event):
|
|
|
|
|
|
"""Handles admin approval/denial via emoji reactions."""
|
|
|
|
|
|
if evt.room_id != ADMIN_ROOM_ID or evt.sender not in ADMIN_MATRIX_IDS:
|
2026-07-22 13:31:00 -04:00
|
|
|
|
return
|
|
|
|
|
|
|
2026-07-23 11:45:09 -04:00
|
|
|
|
target_event_id = evt.content.relates_to.event_id
|
|
|
|
|
|
emoji = evt.content.relates_to.key
|
2026-07-22 13:31:00 -04:00
|
|
|
|
|
|
|
|
|
|
if not target_event_id or target_event_id not in pending_requests:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
request = pending_requests[target_event_id]
|
|
|
|
|
|
|
2026-07-22 17:30:48 -04:00
|
|
|
|
if "👍" in emoji:
|
2026-07-23 11:45:09 -04:00
|
|
|
|
success, msg = add_user_to_kc_group(request["kc_username"], request["requested_group"])
|
2026-07-22 13:31:00 -04:00
|
|
|
|
status_text = (
|
2026-07-23 11:45:09 -04:00
|
|
|
|
f"✅ **APPROVED** by `{evt.sender}`\n\n"
|
2026-07-22 13:31:00 -04:00
|
|
|
|
f"**User:** `{request['matrix_user']}` → **Group:** `{request['requested_group']}`\n"
|
|
|
|
|
|
f"**Result:** {msg}"
|
|
|
|
|
|
)
|
|
|
|
|
|
user_msg = (
|
2026-07-22 18:21:32 -04:00
|
|
|
|
f"🎉 Your request for access to group `{request['requested_group']}` has been approved!"
|
|
|
|
|
|
if success else f"⚠️ Approval failed: {msg}"
|
2026-07-22 13:31:00 -04:00
|
|
|
|
)
|
2026-07-22 18:21:32 -04:00
|
|
|
|
await send_markdown_message(
|
2026-07-22 13:31:00 -04:00
|
|
|
|
room_id=request["origin_room_id"],
|
2026-07-22 18:21:32 -04:00
|
|
|
|
md_text=user_msg,
|
2026-07-23 11:45:09 -04:00
|
|
|
|
msgtype=MessageType.NOTICE,
|
2026-07-22 13:31:00 -04:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-22 17:30:48 -04:00
|
|
|
|
elif "👎" in emoji:
|
2026-07-22 13:31:00 -04:00
|
|
|
|
status_text = (
|
2026-07-23 11:45:09 -04:00
|
|
|
|
f"❌ **DENIED** by `{evt.sender}`\n\n"
|
2026-07-22 13:31:00 -04:00
|
|
|
|
f"**User:** `{request['matrix_user']}` → **Group:** `{request['requested_group']}`"
|
|
|
|
|
|
)
|
2026-07-22 18:21:32 -04:00
|
|
|
|
await send_markdown_message(
|
2026-07-22 13:31:00 -04:00
|
|
|
|
room_id=request["origin_room_id"],
|
2026-07-22 18:21:32 -04:00
|
|
|
|
md_text=f"Your request for group `{request['requested_group']}` was denied.",
|
2026-07-23 11:45:09 -04:00
|
|
|
|
msgtype=MessageType.NOTICE,
|
2026-07-22 13:31:00 -04:00
|
|
|
|
)
|
|
|
|
|
|
else:
|
2026-07-22 18:21:32 -04:00
|
|
|
|
return
|
2026-07-22 13:31:00 -04:00
|
|
|
|
|
2026-07-22 18:21:32 -04:00
|
|
|
|
await send_markdown_message(
|
2026-07-22 13:31:00 -04:00
|
|
|
|
room_id=ADMIN_ROOM_ID,
|
2026-07-22 18:21:32 -04:00
|
|
|
|
md_text=status_text,
|
2026-07-23 11:45:09 -04:00
|
|
|
|
edit_event_id=target_event_id,
|
2026-07-22 13:31:00 -04:00
|
|
|
|
)
|
|
|
|
|
|
del pending_requests[target_event_id]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------------------
|
2026-07-23 11:45:09 -04:00
|
|
|
|
# Main Execution & Setup
|
2026-07-22 13:31:00 -04:00
|
|
|
|
# ------------------------------------------------------------------------------
|
|
|
|
|
|
async def main():
|
2026-07-23 11:45:09 -04:00
|
|
|
|
global client
|
2026-07-22 13:31:00 -04:00
|
|
|
|
|
2026-07-23 13:28:43 -04:00
|
|
|
|
# Load session if it exists
|
|
|
|
|
|
session_data = {}
|
|
|
|
|
|
if os.path.exists(SESSION_FILE):
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(SESSION_FILE, "r") as f:
|
|
|
|
|
|
session_data = json.load(f)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
log.warning(f"Could not load session file: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
# Initialize client with persisted token and device ID if available
|
|
|
|
|
|
client = Client(
|
|
|
|
|
|
base_url=MATRIX_HOMESERVER,
|
|
|
|
|
|
mxid=MATRIX_BOT_USER,
|
2026-07-23 13:34:02 -04:00
|
|
|
|
token=session_data.get("access_token"),
|
2026-07-23 13:28:43 -04:00
|
|
|
|
device_id=session_data.get("device_id", "KEYCLOAK_BOT_DEVICE")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-23 13:32:12 -04:00
|
|
|
|
# Initialize stores using Pickle and Custom Memory State Stores
|
|
|
|
|
|
state_store = CustomMemoryStateStore(client.mxid)
|
2026-07-23 13:35:33 -04:00
|
|
|
|
crypto_store = PickleCryptoStore(CRYPTO_STORE_FILE, account_id=client.mxid)
|
2026-07-23 13:32:12 -04:00
|
|
|
|
|
2026-07-23 11:45:09 -04:00
|
|
|
|
client.state_store = state_store
|
2026-07-23 13:28:43 -04:00
|
|
|
|
client.crypto = OlmMachine(client, crypto_store, state_store)
|
|
|
|
|
|
|
|
|
|
|
|
# Authenticate if access token is missing
|
2026-07-23 13:37:28 -04:00
|
|
|
|
if not client.api.token:
|
2026-07-23 13:28:43 -04:00
|
|
|
|
log.info("Logging in to Matrix homeserver...")
|
|
|
|
|
|
await client.login(
|
|
|
|
|
|
password=MATRIX_BOT_PASSWORD,
|
|
|
|
|
|
device_name="KEYCLOAK_ACCESS_BOT",
|
|
|
|
|
|
device_id="KEYCLOAK_BOT_DEVICE"
|
|
|
|
|
|
)
|
|
|
|
|
|
# Save session data for future restarts
|
|
|
|
|
|
session_dir = os.path.dirname(SESSION_FILE)
|
|
|
|
|
|
if session_dir:
|
|
|
|
|
|
os.makedirs(session_dir, exist_ok=True)
|
|
|
|
|
|
with open(SESSION_FILE, "w") as f:
|
|
|
|
|
|
json.dump({
|
2026-07-23 13:37:28 -04:00
|
|
|
|
"access_token": client.api.token,
|
2026-07-23 13:28:43 -04:00
|
|
|
|
"device_id": client.device_id
|
|
|
|
|
|
}, f)
|
2026-07-23 13:44:11 -04:00
|
|
|
|
|
2026-07-23 13:45:57 -04:00
|
|
|
|
# Ensure an Olm crypto account exists in the store
|
|
|
|
|
|
account = await client.crypto.store.get_account(client.mxid)
|
|
|
|
|
|
if not account:
|
2026-07-23 13:44:11 -04:00
|
|
|
|
log.info("Creating new Olm account...")
|
2026-07-23 13:45:57 -04:00
|
|
|
|
account = Account.generate()
|
|
|
|
|
|
await client.crypto.store.put_account(account)
|
2026-07-23 13:28:43 -04:00
|
|
|
|
|
2026-07-23 13:32:12 -04:00
|
|
|
|
# Register Event Handlers
|
2026-07-23 11:45:09 -04:00
|
|
|
|
client.add_event_handler(EventType.ROOM_MEMBER, on_invite)
|
|
|
|
|
|
client.add_event_handler(EventType.ROOM_MESSAGE, on_message)
|
|
|
|
|
|
client.add_event_handler(EventType.REACTION, on_reaction)
|
|
|
|
|
|
|
2026-07-23 13:32:12 -04:00
|
|
|
|
# Start Sync Loop
|
2026-07-23 11:45:09 -04:00
|
|
|
|
log.info("Starting Mautrix sync loop...")
|
2026-07-23 11:58:23 -04:00
|
|
|
|
await client.start(None)
|
2026-07-22 13:31:00 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
try:
|
|
|
|
|
|
asyncio.run(main())
|
|
|
|
|
|
except KeyboardInterrupt:
|
|
|
|
|
|
print("\nBot stopped by user.")
|
|
|
|
|
|
sys.exit(0)
|