Files
microdao-daarion/services/matrix-gateway/main.py
Apple 7108985b55 feat: Matrix Gateway integration for Rooms Layer
Matrix Gateway:
- Add POST /internal/matrix/room/join endpoint
- Add POST /internal/matrix/message/send endpoint
- Add GET /internal/matrix/rooms/{room_id}/messages endpoint

City Service:
- Add POST /rooms/sync/matrix endpoint for bulk sync
- Update get_agent_chat_room to auto-create Matrix rooms
- Update get_node_chat_room to auto-create Matrix rooms
- Update get_microdao_chat_room to auto-create Matrix rooms
- Add join_user_to_room, send_message_to_room, get_room_messages to matrix_client
- Add ensure_room_has_matrix helper function

This enables:
- Automatic Matrix room creation for all entity types
- chat_available = true when Matrix room exists
- Real-time messaging via Matrix
2025-11-30 10:12:27 -08:00

667 lines
24 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
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")
@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)