415 lines
14 KiB
Python
415 lines
14 KiB
Python
"""
|
||
City Backend API Routes
|
||
"""
|
||
|
||
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,
|
||
CityRoomCreate,
|
||
CityRoomDetail,
|
||
CityRoomMessageRead,
|
||
CityRoomMessageCreate,
|
||
CityFeedEventRead
|
||
)
|
||
import repo_city
|
||
from common.redis_client import PresenceRedis, get_redis
|
||
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"])
|
||
|
||
|
||
# =============================================================================
|
||
# City Rooms API
|
||
# =============================================================================
|
||
|
||
@router.get("/rooms", response_model=List[CityRoomRead])
|
||
async def get_city_rooms(limit: int = 100, offset: int = 0):
|
||
"""
|
||
Отримати список всіх City Rooms
|
||
"""
|
||
try:
|
||
rooms = await repo_city.get_all_rooms(limit=limit, offset=offset)
|
||
|
||
# Додати online count (приблизно)
|
||
online_count = await PresenceRedis.get_online_count()
|
||
|
||
result = []
|
||
for room in rooms:
|
||
result.append({
|
||
**room,
|
||
"members_online": online_count if room.get("is_default") else max(1, online_count // 2),
|
||
"last_event": None # TODO: з останнього повідомлення
|
||
})
|
||
|
||
return result
|
||
except Exception as e:
|
||
logger.error(f"Failed to get city rooms: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to get city rooms")
|
||
|
||
|
||
@router.post("/rooms", response_model=CityRoomRead)
|
||
async def create_city_room(payload: CityRoomCreate):
|
||
"""
|
||
Створити нову City Room (автоматично створює Matrix room)
|
||
"""
|
||
try:
|
||
# TODO: витягнути user_id з JWT
|
||
created_by = "u_system" # Mock для MVP
|
||
|
||
# Перевірити чи не існує вже
|
||
existing = await repo_city.get_room_by_slug(payload.slug)
|
||
if existing:
|
||
raise HTTPException(status_code=409, detail="Room with this slug already exists")
|
||
|
||
# Створити Matrix room
|
||
matrix_room_id, matrix_room_alias = await create_matrix_room(
|
||
slug=payload.slug,
|
||
name=payload.name,
|
||
visibility="public"
|
||
)
|
||
|
||
if not matrix_room_id:
|
||
logger.warning(f"Failed to create Matrix room for {payload.slug}, proceeding without Matrix")
|
||
|
||
room = await repo_city.create_room(
|
||
slug=payload.slug,
|
||
name=payload.name,
|
||
description=payload.description,
|
||
created_by=created_by,
|
||
matrix_room_id=matrix_room_id,
|
||
matrix_room_alias=matrix_room_alias
|
||
)
|
||
|
||
# Додати початкове повідомлення
|
||
await repo_city.create_room_message(
|
||
room_id=room["id"],
|
||
body=f"Кімната '{payload.name}' створена! Ласкаво просимо! 🎉",
|
||
author_agent_id="ag_system"
|
||
)
|
||
|
||
# Додати в feed
|
||
await repo_city.create_feed_event(
|
||
kind="system",
|
||
room_id=room["id"],
|
||
payload={"action": "room_created", "room_name": payload.name, "matrix_room_id": matrix_room_id}
|
||
)
|
||
|
||
return {**room, "members_online": 1, "last_event": None}
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to create city room: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to create city room")
|
||
|
||
|
||
@router.get("/rooms/{room_id}", response_model=CityRoomDetail)
|
||
async def get_city_room(room_id: str):
|
||
"""
|
||
Отримати деталі City Room з повідомленнями
|
||
"""
|
||
try:
|
||
room = await repo_city.get_room_by_id(room_id)
|
||
if not room:
|
||
raise HTTPException(status_code=404, detail="Room not found")
|
||
|
||
messages = await repo_city.get_room_messages(room_id, limit=50)
|
||
|
||
# Додати username до повідомлень
|
||
for msg in messages:
|
||
if msg.get("author_user_id"):
|
||
msg["username"] = f"User-{msg['author_user_id'][-4:]}" # Mock
|
||
elif msg.get("author_agent_id"):
|
||
msg["username"] = "System Agent"
|
||
else:
|
||
msg["username"] = "Anonymous"
|
||
|
||
online_users = await PresenceRedis.get_all_online()
|
||
|
||
return {
|
||
**room,
|
||
"members_online": len(online_users),
|
||
"last_event": None,
|
||
"messages": messages,
|
||
"online_members": online_users[:20] # Перші 20
|
||
}
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to get city room: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to get city room")
|
||
|
||
|
||
@router.post("/rooms/{room_id}/messages", response_model=CityRoomMessageRead)
|
||
async def send_city_room_message(room_id: str, payload: CityRoomMessageCreate):
|
||
"""
|
||
Надіслати повідомлення в City Room
|
||
"""
|
||
try:
|
||
# Перевірити чи кімната існує
|
||
room = await repo_city.get_room_by_id(room_id)
|
||
if not room:
|
||
raise HTTPException(status_code=404, detail="Room not found")
|
||
|
||
# TODO: витягнути user_id з JWT
|
||
author_user_id = "u_mock_user" # Mock для MVP
|
||
|
||
# Створити повідомлення
|
||
message = await repo_city.create_room_message(
|
||
room_id=room_id,
|
||
body=payload.body,
|
||
author_user_id=author_user_id
|
||
)
|
||
|
||
# Додати в feed
|
||
await repo_city.create_feed_event(
|
||
kind="room_message",
|
||
room_id=room_id,
|
||
user_id=author_user_id,
|
||
payload={"body": payload.body[:100], "message_id": message["id"]}
|
||
)
|
||
|
||
# TODO: Broadcast WS event
|
||
# await ws_manager.broadcast_to_room(room_id, {
|
||
# "event": "room.message",
|
||
# "message": message
|
||
# })
|
||
|
||
# Додати username
|
||
message["username"] = f"User-{author_user_id[-4:]}"
|
||
|
||
return message
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to send room message: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to send message")
|
||
|
||
|
||
@router.post("/rooms/{room_id}/join")
|
||
async def join_city_room(room_id: str):
|
||
"""
|
||
Приєднатися до City Room (для tracking)
|
||
"""
|
||
# TODO: витягнути user_id з JWT
|
||
user_id = "u_mock_user"
|
||
|
||
# Для MVP просто повертаємо success
|
||
# У production можна зберігати active memberships в Redis
|
||
|
||
logger.info(f"User {user_id} joined room {room_id}")
|
||
return {"status": "joined", "room_id": room_id}
|
||
|
||
|
||
@router.post("/rooms/{room_id}/leave")
|
||
async def leave_city_room(room_id: str):
|
||
"""
|
||
Покинути City Room
|
||
"""
|
||
# TODO: витягнути user_id з JWT
|
||
user_id = "u_mock_user"
|
||
|
||
logger.info(f"User {user_id} left room {room_id}")
|
||
return {"status": "left", "room_id": room_id}
|
||
|
||
|
||
# =============================================================================
|
||
# Matrix Backfill API (Internal)
|
||
# =============================================================================
|
||
|
||
@router.post("/matrix/backfill")
|
||
async def backfill_matrix_rooms():
|
||
"""
|
||
Backfill Matrix rooms for existing City Rooms that don't have Matrix integration.
|
||
This is an internal endpoint for admin use.
|
||
"""
|
||
try:
|
||
rooms_without_matrix = await repo_city.get_rooms_without_matrix()
|
||
|
||
results = {
|
||
"processed": 0,
|
||
"created": 0,
|
||
"found": 0,
|
||
"failed": 0,
|
||
"details": []
|
||
}
|
||
|
||
for room in rooms_without_matrix:
|
||
results["processed"] += 1
|
||
slug = room["slug"]
|
||
name = room["name"]
|
||
room_id = room["id"]
|
||
|
||
# Спочатку спробувати знайти існуючу Matrix room
|
||
alias = f"#city_{slug}:daarion.space"
|
||
matrix_room_id, matrix_room_alias = await find_matrix_room_by_alias(alias)
|
||
|
||
if matrix_room_id:
|
||
# Знайдено існуючу
|
||
await repo_city.update_room_matrix(room_id, matrix_room_id, matrix_room_alias)
|
||
results["found"] += 1
|
||
results["details"].append({
|
||
"room_id": room_id,
|
||
"slug": slug,
|
||
"status": "found",
|
||
"matrix_room_id": matrix_room_id
|
||
})
|
||
else:
|
||
# Створити нову
|
||
matrix_room_id, matrix_room_alias = await create_matrix_room(slug, name, "public")
|
||
|
||
if matrix_room_id:
|
||
await repo_city.update_room_matrix(room_id, matrix_room_id, matrix_room_alias)
|
||
results["created"] += 1
|
||
results["details"].append({
|
||
"room_id": room_id,
|
||
"slug": slug,
|
||
"status": "created",
|
||
"matrix_room_id": matrix_room_id
|
||
})
|
||
else:
|
||
results["failed"] += 1
|
||
results["details"].append({
|
||
"room_id": room_id,
|
||
"slug": slug,
|
||
"status": "failed",
|
||
"error": "Could not create Matrix room"
|
||
})
|
||
|
||
logger.info(f"Matrix backfill completed: {results['processed']} processed, "
|
||
f"{results['created']} created, {results['found']} found, {results['failed']} failed")
|
||
|
||
return results
|
||
|
||
except Exception as e:
|
||
logger.error(f"Matrix backfill failed: {e}")
|
||
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.post(
|
||
f"{AUTH_SERVICE_URL}/api/auth/introspect",
|
||
json={"token": token}
|
||
)
|
||
if resp.status_code == 200:
|
||
data = resp.json()
|
||
if data.get("active"):
|
||
return {"user_id": data.get("sub"), "email": data.get("email"), "roles": data.get("roles", [])}
|
||
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
|
||
# =============================================================================
|
||
|
||
@router.get("/feed", response_model=List[CityFeedEventRead])
|
||
async def get_city_feed(limit: int = 20, offset: int = 0):
|
||
"""
|
||
Отримати City Feed (останні події)
|
||
"""
|
||
try:
|
||
events = await repo_city.get_feed_events(limit=limit, offset=offset)
|
||
return events
|
||
except Exception as e:
|
||
logger.error(f"Failed to get city feed: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to get city feed")
|
||
|