feat: Implement Matrix Chat Client

This commit is contained in:
Apple
2025-11-26 13:15:01 -08:00
parent 871812ef92
commit e9c04f6bcd
7 changed files with 1264 additions and 8 deletions

View File

@@ -2,9 +2,11 @@
City Backend API Routes
"""
from fastapi import APIRouter, HTTPException, Depends, Body
from fastapi import APIRouter, HTTPException, Depends, Body, Header, Query
from typing import List, Optional
import logging
import httpx
import os
from models_city import (
CityRoomRead,
@@ -20,6 +22,10 @@ from matrix_client import create_matrix_room, find_matrix_room_by_alias
logger = logging.getLogger(__name__)
# JWT validation (simplified for MVP)
AUTH_SERVICE_URL = os.getenv("AUTH_SERVICE_URL", "http://daarion-auth:7020")
MATRIX_GATEWAY_URL = os.getenv("MATRIX_GATEWAY_URL", "http://daarion-matrix-gateway:7025")
router = APIRouter(prefix="/city", tags=["city"])
@@ -293,6 +299,101 @@ async def backfill_matrix_rooms():
raise HTTPException(status_code=500, detail=f"Backfill failed: {str(e)}")
# =============================================================================
# Chat Bootstrap API (Matrix Integration)
# =============================================================================
async def validate_jwt_token(authorization: str) -> Optional[dict]:
"""Validate JWT token via auth-service introspect endpoint."""
if not authorization or not authorization.startswith("Bearer "):
return None
token = authorization.replace("Bearer ", "")
async with httpx.AsyncClient(timeout=10.0) as client:
try:
resp = await client.get(
f"{AUTH_SERVICE_URL}/api/auth/introspect",
headers={"Authorization": f"Bearer {token}"}
)
if resp.status_code == 200:
return resp.json()
return None
except Exception as e:
logger.error(f"JWT validation error: {e}")
return None
@router.get("/chat/bootstrap")
async def chat_bootstrap(
room_slug: str = Query(..., description="City room slug"),
authorization: Optional[str] = Header(None)
):
"""
Bootstrap Matrix chat for a city room.
Returns Matrix credentials and room info for the authenticated user.
"""
# Validate JWT
user_info = await validate_jwt_token(authorization)
if not user_info:
raise HTTPException(status_code=401, detail="Invalid or missing authorization token")
user_id = user_info.get("user_id")
if not user_id:
raise HTTPException(status_code=401, detail="Invalid token: missing user_id")
# Get room by slug
room = await repo_city.get_room_by_slug(room_slug)
if not room:
raise HTTPException(status_code=404, detail=f"Room '{room_slug}' not found")
# Check if room has Matrix integration
matrix_room_id = room.get("matrix_room_id")
matrix_room_alias = room.get("matrix_room_alias")
if not matrix_room_id:
raise HTTPException(
status_code=400,
detail="Room does not have Matrix integration. Run /city/matrix/backfill first."
)
# Get Matrix user token from matrix-gateway
async with httpx.AsyncClient(timeout=30.0) as client:
try:
token_resp = await client.post(
f"{MATRIX_GATEWAY_URL}/internal/matrix/users/token",
json={"user_id": user_id}
)
if token_resp.status_code != 200:
error = token_resp.json()
logger.error(f"Failed to get Matrix token: {error}")
raise HTTPException(status_code=500, detail="Failed to get Matrix credentials")
matrix_creds = token_resp.json()
except httpx.RequestError as e:
logger.error(f"Matrix gateway request error: {e}")
raise HTTPException(status_code=503, detail="Matrix service unavailable")
# Return bootstrap data
return {
"matrix_hs_url": f"https://app.daarion.space", # Through nginx proxy
"matrix_user_id": matrix_creds["matrix_user_id"],
"matrix_access_token": matrix_creds["access_token"],
"matrix_device_id": matrix_creds["device_id"],
"matrix_room_id": matrix_room_id,
"matrix_room_alias": matrix_room_alias,
"room": {
"id": room["id"],
"slug": room["slug"],
"name": room["name"],
"description": room.get("description")
}
}
# =============================================================================
# City Feed API
# =============================================================================

View File

@@ -67,6 +67,17 @@ class HealthResponse(BaseModel):
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
@@ -318,6 +329,105 @@ async def get_room_info(room_id: str):
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)