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
This commit is contained in:
Apple
2025-11-30 10:12:27 -08:00
parent 361d114a43
commit 7108985b55
4 changed files with 771 additions and 26 deletions

View File

@@ -91,6 +91,32 @@ class SetPresenceResponse(BaseModel):
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
@@ -441,6 +467,150 @@ async def get_user_token(request: UserTokenRequest):
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):
"""