Files
microdao-daarion/services/matrix-gateway/main.py
2025-11-26 13:15:01 -08:00

435 lines
16 KiB
Python

"""
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
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")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=settings.port)