""" Matrix Gateway Service Provides internal API for Matrix operations (room creation, lookup, etc.) """ from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from typing import Optional import httpx import hashlib import hmac import logging from config import get_settings # Setup logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) settings = get_settings() app = FastAPI( title="DAARION Matrix Gateway", description="Internal API for Matrix operations", version=settings.service_version ) # CORS (internal service, but add for flexibility) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Admin access token (will be set on first request) _admin_token: Optional[str] = None # Models class CreateRoomRequest(BaseModel): slug: str name: str visibility: str = "public" topic: Optional[str] = None class CreateRoomResponse(BaseModel): matrix_room_id: str matrix_room_alias: str class FindRoomResponse(BaseModel): matrix_room_id: str matrix_room_alias: str class ErrorResponse(BaseModel): error: str detail: Optional[str] = None class HealthResponse(BaseModel): status: str synapse: str server_name: str class UserTokenRequest(BaseModel): user_id: str # DAARION user_id (UUID) class UserTokenResponse(BaseModel): matrix_user_id: str access_token: str device_id: str home_server: str class SetPresenceRequest(BaseModel): matrix_user_id: str # "@user:daarion.space" access_token: str # User's Matrix access token status: str = "online" # "online" | "unavailable" | "offline" status_msg: Optional[str] = None class SetPresenceResponse(BaseModel): ok: bool matrix_user_id: str status: str # NEW: Room Join Request/Response class RoomJoinRequest(BaseModel): room_id: str # Matrix room ID (!abc:daarion.space) user_id: str # Matrix user ID (@user:daarion.space) class RoomJoinResponse(BaseModel): ok: bool room_id: str user_id: str # NEW: Send Message Request/Response class SendMessageRequest(BaseModel): room_id: str # Matrix room ID sender: str # Matrix user ID (must be bot or have permissions) body: str # Message text msgtype: str = "m.text" # Message type class SendMessageResponse(BaseModel): ok: bool event_id: str room_id: str async def get_admin_token() -> str: """Get or create admin access token for Matrix operations.""" global _admin_token if _admin_token and settings.synapse_admin_token: return settings.synapse_admin_token if _admin_token: return _admin_token # Try to use provided token if settings.synapse_admin_token: _admin_token = settings.synapse_admin_token return _admin_token # Create admin user and get token try: async with httpx.AsyncClient() as client: # Get nonce nonce_resp = await client.get( f"{settings.synapse_url}/_synapse/admin/v1/register" ) nonce_resp.raise_for_status() nonce = nonce_resp.json()["nonce"] # Generate MAC mac = hmac.new( key=settings.synapse_registration_secret.encode('utf-8'), digestmod=hashlib.sha1 ) mac.update(nonce.encode('utf-8')) mac.update(b"\x00") mac.update(b"daarion_admin") mac.update(b"\x00") mac.update(b"admin_password_2024") mac.update(b"\x00") mac.update(b"admin") # Register admin register_resp = await client.post( f"{settings.synapse_url}/_synapse/admin/v1/register", json={ "nonce": nonce, "username": "daarion_admin", "password": "admin_password_2024", "admin": True, "mac": mac.hexdigest() } ) if register_resp.status_code == 200: result = register_resp.json() _admin_token = result.get("access_token") logger.info("Admin user created successfully") return _admin_token elif register_resp.status_code == 400: # User already exists, try to login login_resp = await client.post( f"{settings.synapse_url}/_matrix/client/v3/login", json={ "type": "m.login.password", "user": "daarion_admin", "password": "admin_password_2024" } ) login_resp.raise_for_status() result = login_resp.json() _admin_token = result.get("access_token") logger.info("Admin user logged in successfully") return _admin_token else: raise Exception(f"Failed to create admin: {register_resp.text}") except Exception as e: logger.error(f"Failed to get admin token: {e}") raise HTTPException(status_code=500, detail=f"Failed to get admin token: {e}") @app.get("/healthz", response_model=HealthResponse) async def health_check(): """Health check endpoint.""" synapse_status = "unknown" try: async with httpx.AsyncClient(timeout=5.0) as client: resp = await client.get(f"{settings.synapse_url}/_matrix/client/versions") if resp.status_code == 200: synapse_status = "connected" else: synapse_status = "error" except Exception: synapse_status = "unavailable" return HealthResponse( status="ok" if synapse_status == "connected" else "degraded", synapse=synapse_status, server_name=settings.matrix_server_name ) @app.post("/internal/matrix/rooms/create", response_model=CreateRoomResponse) async def create_room(request: CreateRoomRequest): """ Create a Matrix room for a City Room. This is an internal endpoint - should only be called by city-service. """ admin_token = await get_admin_token() room_alias_name = f"city_{request.slug}" room_name = f"DAARION City — {request.name}" async with httpx.AsyncClient() as client: try: # Create room create_resp = await client.post( f"{settings.synapse_url}/_matrix/client/v3/createRoom", headers={"Authorization": f"Bearer {admin_token}"}, json={ "name": room_name, "room_alias_name": room_alias_name, "topic": request.topic or f"City room: {request.name}", "preset": "public_chat" if request.visibility == "public" else "private_chat", "visibility": "public" if request.visibility == "public" else "private", "creation_content": { "m.federate": False # Don't federate for now }, "initial_state": [ { "type": "m.room.history_visibility", "content": {"history_visibility": "shared"} }, { "type": "m.room.guest_access", "content": {"guest_access": "can_join"} } ] } ) if create_resp.status_code == 200: result = create_resp.json() matrix_room_id = result["room_id"] matrix_room_alias = f"#city_{request.slug}:{settings.matrix_server_name}" logger.info(f"Created Matrix room: {matrix_room_id} ({matrix_room_alias})") return CreateRoomResponse( matrix_room_id=matrix_room_id, matrix_room_alias=matrix_room_alias ) elif create_resp.status_code == 400: error = create_resp.json() if "M_ROOM_IN_USE" in str(error): # Room already exists, find it alias = f"#city_{request.slug}:{settings.matrix_server_name}" find_resp = await client.get( f"{settings.synapse_url}/_matrix/client/v3/directory/room/{alias.replace('#', '%23').replace(':', '%3A')}", headers={"Authorization": f"Bearer {admin_token}"} ) if find_resp.status_code == 200: room_info = find_resp.json() return CreateRoomResponse( matrix_room_id=room_info["room_id"], matrix_room_alias=alias ) logger.error(f"Failed to create room: {create_resp.text}") raise HTTPException(status_code=400, detail=f"Matrix error: {error.get('error', 'Unknown')}") else: logger.error(f"Failed to create room: {create_resp.text}") raise HTTPException(status_code=500, detail="Failed to create Matrix room") except httpx.RequestError as e: logger.error(f"Matrix request error: {e}") raise HTTPException(status_code=503, detail="Matrix unavailable") @app.get("/internal/matrix/rooms/find-by-alias", response_model=FindRoomResponse) async def find_room_by_alias(alias: str = Query(..., description="Matrix room alias")): """ Find a Matrix room by its alias. Example: ?alias=#city_general:daarion.space """ admin_token = await get_admin_token() # URL encode the alias encoded_alias = alias.replace("#", "%23").replace(":", "%3A") async with httpx.AsyncClient() as client: try: resp = await client.get( f"{settings.synapse_url}/_matrix/client/v3/directory/room/{encoded_alias}", headers={"Authorization": f"Bearer {admin_token}"} ) if resp.status_code == 200: result = resp.json() return FindRoomResponse( matrix_room_id=result["room_id"], matrix_room_alias=alias ) elif resp.status_code == 404: raise HTTPException(status_code=404, detail="Room not found") else: logger.error(f"Failed to find room: {resp.text}") raise HTTPException(status_code=500, detail="Failed to find room") except httpx.RequestError as e: logger.error(f"Matrix request error: {e}") raise HTTPException(status_code=503, detail="Matrix unavailable") @app.get("/internal/matrix/rooms/{room_id}") async def get_room_info(room_id: str): """Get information about a Matrix room.""" admin_token = await get_admin_token() async with httpx.AsyncClient() as client: try: resp = await client.get( f"{settings.synapse_url}/_matrix/client/v3/rooms/{room_id}/state", headers={"Authorization": f"Bearer {admin_token}"} ) if resp.status_code == 200: state = resp.json() # Extract room info from state name = None topic = None for event in state: if event.get("type") == "m.room.name": name = event.get("content", {}).get("name") elif event.get("type") == "m.room.topic": topic = event.get("content", {}).get("topic") return { "room_id": room_id, "name": name, "topic": topic } else: raise HTTPException(status_code=resp.status_code, detail="Failed to get room info") except httpx.RequestError as e: logger.error(f"Matrix request error: {e}") raise HTTPException(status_code=503, detail="Matrix unavailable") @app.post("/internal/matrix/users/token", response_model=UserTokenResponse) async def get_user_token(request: UserTokenRequest): """ Get or create Matrix access token for a DAARION user. This is used for chat bootstrap - allows frontend to connect to Matrix on behalf of the user. """ # Generate Matrix username from DAARION user_id user_id_short = request.user_id[:8].replace('-', '') matrix_username = f"daarion_{user_id_short}" matrix_user_id = f"@{matrix_username}:{settings.matrix_server_name}" # Generate password (deterministic, based on user_id + secret) matrix_password = hashlib.sha256( f"{request.user_id}:{settings.synapse_registration_secret}".encode() ).hexdigest()[:32] async with httpx.AsyncClient(timeout=30.0) as client: try: # Try to login first login_resp = await client.post( f"{settings.synapse_url}/_matrix/client/v3/login", json={ "type": "m.login.password", "identifier": { "type": "m.id.user", "user": matrix_username }, "password": matrix_password, "device_id": f"DAARION_{user_id_short}", "initial_device_display_name": "DAARION Web" } ) if login_resp.status_code == 200: result = login_resp.json() logger.info(f"Matrix user logged in: {matrix_user_id}") return UserTokenResponse( matrix_user_id=result["user_id"], access_token=result["access_token"], device_id=result.get("device_id", f"DAARION_{user_id_short}"), home_server=settings.matrix_server_name ) # User doesn't exist, create via admin API logger.info(f"Creating Matrix user: {matrix_username}") # Get nonce nonce_resp = await client.get( f"{settings.synapse_url}/_synapse/admin/v1/register" ) nonce_resp.raise_for_status() nonce = nonce_resp.json()["nonce"] # Generate MAC mac = hmac.new( key=settings.synapse_registration_secret.encode('utf-8'), digestmod=hashlib.sha1 ) mac.update(nonce.encode('utf-8')) mac.update(b"\x00") mac.update(matrix_username.encode('utf-8')) mac.update(b"\x00") mac.update(matrix_password.encode('utf-8')) mac.update(b"\x00") mac.update(b"notadmin") # Register user register_resp = await client.post( f"{settings.synapse_url}/_synapse/admin/v1/register", json={ "nonce": nonce, "username": matrix_username, "password": matrix_password, "admin": False, "mac": mac.hexdigest() } ) if register_resp.status_code == 200: result = register_resp.json() logger.info(f"Matrix user created: {result['user_id']}") return UserTokenResponse( matrix_user_id=result["user_id"], access_token=result["access_token"], device_id=result.get("device_id", f"DAARION_{user_id_short}"), home_server=settings.matrix_server_name ) else: error = register_resp.json() logger.error(f"Failed to create Matrix user: {error}") raise HTTPException(status_code=500, detail=f"Failed to create Matrix user: {error.get('error', 'Unknown')}") except httpx.RequestError as e: logger.error(f"Matrix request error: {e}") raise HTTPException(status_code=503, detail="Matrix unavailable") @app.post("/internal/matrix/room/join", response_model=RoomJoinResponse) async def join_room(request: RoomJoinRequest): """ Join a user to a Matrix room. Uses admin token to invite and join the user. """ admin_token = await get_admin_token() async with httpx.AsyncClient(timeout=30.0) as client: try: # First, invite the user to the room (admin action) invite_resp = await client.post( f"{settings.synapse_url}/_matrix/client/v3/rooms/{request.room_id}/invite", headers={"Authorization": f"Bearer {admin_token}"}, json={"user_id": request.user_id} ) # 200 = invited, 403 = already member (OK), 400 = already invited (OK) if invite_resp.status_code not in (200, 403, 400): logger.warning(f"Invite response: {invite_resp.status_code} - {invite_resp.text}") # Now join the user to the room via admin API # Use the synapse admin API to force-join join_resp = await client.post( f"{settings.synapse_url}/_synapse/admin/v1/join/{request.room_id}", headers={"Authorization": f"Bearer {admin_token}"}, json={"user_id": request.user_id} ) if join_resp.status_code == 200: logger.info(f"User {request.user_id} joined room {request.room_id}") return RoomJoinResponse( ok=True, room_id=request.room_id, user_id=request.user_id ) elif join_resp.status_code == 400: # Already in room logger.info(f"User {request.user_id} already in room {request.room_id}") return RoomJoinResponse( ok=True, room_id=request.room_id, user_id=request.user_id ) else: error = join_resp.json() if join_resp.text else {} logger.error(f"Failed to join room: {join_resp.status_code} - {join_resp.text}") raise HTTPException( status_code=500, detail=f"Failed to join room: {error.get('error', 'Unknown')}" ) except httpx.RequestError as e: logger.error(f"Matrix request error: {e}") raise HTTPException(status_code=503, detail="Matrix unavailable") @app.post("/internal/matrix/message/send", response_model=SendMessageResponse) async def send_message(request: SendMessageRequest): """ Send a message to a Matrix room. Uses admin token to send on behalf of the bot. """ admin_token = await get_admin_token() import time txn_id = f"daarion_{int(time.time() * 1000)}" async with httpx.AsyncClient(timeout=30.0) as client: try: # Send message via admin token (as the bot user) send_resp = await client.put( f"{settings.synapse_url}/_matrix/client/v3/rooms/{request.room_id}/send/m.room.message/{txn_id}", headers={"Authorization": f"Bearer {admin_token}"}, json={ "msgtype": request.msgtype, "body": request.body } ) if send_resp.status_code == 200: result = send_resp.json() event_id = result.get("event_id", "") logger.info(f"Message sent to {request.room_id}: {event_id}") return SendMessageResponse( ok=True, event_id=event_id, room_id=request.room_id ) else: error = send_resp.json() if send_resp.text else {} logger.error(f"Failed to send message: {send_resp.status_code} - {send_resp.text}") raise HTTPException( status_code=500, detail=f"Failed to send message: {error.get('error', 'Unknown')}" ) except httpx.RequestError as e: logger.error(f"Matrix request error: {e}") raise HTTPException(status_code=503, detail="Matrix unavailable") @app.get("/internal/matrix/rooms/{room_id}/messages") async def get_room_messages(room_id: str, limit: int = 50): """ Get recent messages from a Matrix room. """ admin_token = await get_admin_token() async with httpx.AsyncClient(timeout=30.0) as client: try: resp = await client.get( f"{settings.synapse_url}/_matrix/client/v3/rooms/{room_id}/messages", headers={"Authorization": f"Bearer {admin_token}"}, params={ "dir": "b", # backwards from end "limit": limit } ) if resp.status_code == 200: data = resp.json() messages = [] for event in data.get("chunk", []): if event.get("type") == "m.room.message": content = event.get("content", {}) messages.append({ "event_id": event.get("event_id"), "sender": event.get("sender"), "body": content.get("body", ""), "msgtype": content.get("msgtype", "m.text"), "timestamp": event.get("origin_server_ts", 0) }) return {"messages": messages, "room_id": room_id} else: raise HTTPException(status_code=resp.status_code, detail="Failed to get messages") except httpx.RequestError as e: logger.error(f"Matrix request error: {e}") raise HTTPException(status_code=503, detail="Matrix unavailable") # Presence Response Model class GetPresenceResponse(BaseModel): user_id: str presence: str # "online" | "offline" | "unavailable" last_active_ago_ms: Optional[int] = None status_msg: Optional[str] = None @app.get("/internal/matrix/presence/{matrix_user_id:path}") async def get_user_presence(matrix_user_id: str): """ Get Matrix presence status for a user. Args: matrix_user_id: Matrix user ID (e.g., @daarwizz:daarion.space) Returns: Presence status: online, offline, or unavailable (away) """ admin_token = await get_admin_token() # URL encode the user_id for the path encoded_user_id = matrix_user_id.replace("@", "%40").replace(":", "%3A") async with httpx.AsyncClient(timeout=10.0) as client: try: resp = await client.get( f"{settings.synapse_url}/_matrix/client/v3/presence/{encoded_user_id}/status", headers={"Authorization": f"Bearer {admin_token}"} ) if resp.status_code == 200: data = resp.json() # Normalize presence state raw_presence = data.get("presence", "offline") if raw_presence == "online": presence = "online" elif raw_presence == "unavailable": presence = "away" else: presence = "offline" return GetPresenceResponse( user_id=matrix_user_id, presence=presence, last_active_ago_ms=data.get("last_active_ago"), status_msg=data.get("status_msg") ) elif resp.status_code == 404: # User not found or no presence info logger.info(f"No presence info for {matrix_user_id}") return GetPresenceResponse( user_id=matrix_user_id, presence="offline", last_active_ago_ms=None ) else: logger.warning(f"Presence query failed for {matrix_user_id}: {resp.status_code}") return GetPresenceResponse( user_id=matrix_user_id, presence="offline", last_active_ago_ms=None ) except httpx.RequestError as e: logger.error(f"Matrix request error: {e}") return GetPresenceResponse( user_id=matrix_user_id, presence="offline", last_active_ago_ms=None ) @app.get("/internal/matrix/presence/bulk") async def get_bulk_presence(user_ids: str): """ Get presence for multiple users at once. Args: user_ids: Comma-separated list of Matrix user IDs Returns: List of presence statuses """ admin_token = await get_admin_token() ids = [uid.strip() for uid in user_ids.split(",") if uid.strip()] results = [] async with httpx.AsyncClient(timeout=30.0) as client: for matrix_user_id in ids: try: encoded_user_id = matrix_user_id.replace("@", "%40").replace(":", "%3A") resp = await client.get( f"{settings.synapse_url}/_matrix/client/v3/presence/{encoded_user_id}/status", headers={"Authorization": f"Bearer {admin_token}"} ) if resp.status_code == 200: data = resp.json() raw_presence = data.get("presence", "offline") if raw_presence == "online": presence = "online" elif raw_presence == "unavailable": presence = "away" else: presence = "offline" results.append({ "user_id": matrix_user_id, "presence": presence, "last_active_ago_ms": data.get("last_active_ago") }) else: results.append({ "user_id": matrix_user_id, "presence": "offline", "last_active_ago_ms": None }) except Exception as e: logger.warning(f"Failed to get presence for {matrix_user_id}: {e}") results.append({ "user_id": matrix_user_id, "presence": "offline", "last_active_ago_ms": None }) return {"presences": results} @app.post("/internal/matrix/presence/online", response_model=SetPresenceResponse) async def set_presence_online(request: SetPresenceRequest): """ Set Matrix presence status for a user. This endpoint is called by the frontend heartbeat to keep users "online". The user's own access token is used to set their presence. """ async with httpx.AsyncClient(timeout=10.0) as client: try: # URL encode the user_id for the path encoded_user_id = request.matrix_user_id.replace("@", "%40").replace(":", "%3A") payload = { "presence": request.status, } if request.status_msg: payload["status_msg"] = request.status_msg resp = await client.put( f"{settings.synapse_url}/_matrix/client/v3/presence/{encoded_user_id}/status", headers={"Authorization": f"Bearer {request.access_token}"}, json=payload ) if resp.status_code in (200, 204): logger.info(f"Set presence for {request.matrix_user_id}: {request.status}") return SetPresenceResponse( ok=True, matrix_user_id=request.matrix_user_id, status=request.status ) else: error_text = resp.text logger.error(f"Failed to set presence: {resp.status_code} - {error_text}") raise HTTPException( status_code=500, detail={ "message": "Failed to set presence", "matrix_status": resp.status_code, "matrix_body": error_text } ) except httpx.RequestError as e: logger.error(f"Matrix request error: {e}") raise HTTPException(status_code=503, detail="Matrix unavailable") if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=settings.port)