ai-robot-core/ai-service/app/api/mid/share.py

442 lines
14 KiB
Python

"""
Share Controller for Mid Platform.
[AC-IDMP-SHARE] Share session via unique token with expiration and concurrent user limits.
"""
import logging
import uuid
from datetime import datetime, timedelta, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.database import get_session
from app.core.tenant import get_tenant_id
from app.models.entities import ChatMessage, SharedSession
from app.models.mid.schemas import (
CreateShareRequest,
ShareListResponse,
ShareListItem,
ShareResponse,
SharedMessageRequest,
SharedSessionInfo,
HistoryMessage,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/mid", tags=["Mid Platform Share"])
settings = get_settings()
def _utcnow() -> datetime:
"""Get current UTC time without timezone info (for DB compatibility)."""
return datetime.utcnow()
def _normalize_datetime(dt: datetime) -> datetime:
"""Normalize datetime to offset-naive UTC for comparison."""
if dt.tzinfo is not None:
return dt.replace(tzinfo=None)
return dt
def _generate_share_url(share_token: str) -> str:
"""Generate full share URL from token."""
base_url = getattr(settings, 'frontend_base_url', 'http://localhost:3000')
return f"{base_url}/share/{share_token}"
@router.post(
"/sessions/{session_id}/share",
operation_id="createShare",
summary="Create a share link for session",
description="""
[AC-IDMP-SHARE] Create a share link for a chat session.
Returns share_token and share_url for accessing the shared conversation.
""",
responses={
200: {"description": "Share created successfully", "model": ShareResponse},
400: {"description": "Invalid request"},
404: {"description": "Session not found"},
},
)
async def create_share(
session_id: Annotated[str, Path(description="Session ID to share")],
request: CreateShareRequest,
db: Annotated[AsyncSession, Depends(get_session)],
) -> ShareResponse:
"""Create a share link for a session."""
tenant_id = get_tenant_id()
if not tenant_id:
from app.core.exceptions import MissingTenantIdException
raise MissingTenantIdException()
share_token = str(uuid.uuid4())
expires_at = _utcnow() + timedelta(days=request.expires_in_days)
shared_session = SharedSession(
share_token=share_token,
session_id=session_id,
tenant_id=tenant_id,
title=request.title,
description=request.description,
expires_at=expires_at,
max_concurrent_users=request.max_concurrent_users,
current_users=0,
is_active=True,
)
db.add(shared_session)
await db.commit()
await db.refresh(shared_session)
logger.info(
f"[AC-IDMP-SHARE] Share created: tenant={tenant_id}, "
f"session={session_id}, token={share_token}, expires={expires_at}"
)
return ShareResponse(
share_token=share_token,
share_url=_generate_share_url(share_token),
expires_at=expires_at.isoformat(),
title=request.title,
description=request.description,
max_concurrent_users=request.max_concurrent_users,
)
@router.get(
"/share/{share_token}",
operation_id="getSharedSession",
summary="Get shared session info",
description="""
[AC-IDMP-SHARE] Get shared session information by share token.
Returns session info with history messages. Public endpoint (no tenant required).
""",
responses={
200: {"description": "Shared session info", "model": SharedSessionInfo},
404: {"description": "Share not found or expired"},
410: {"description": "Share expired or inactive"},
429: {"description": "Too many concurrent users"},
},
)
async def get_shared_session(
share_token: Annotated[str, Path(description="Share token")],
db: Annotated[AsyncSession, Depends(get_session)],
) -> SharedSessionInfo:
"""Get shared session info by token (public endpoint)."""
result = await db.execute(
select(SharedSession).where(SharedSession.share_token == share_token)
)
shared = result.scalar_one_or_none()
if not shared:
raise HTTPException(status_code=404, detail="Share not found")
if not shared.is_active:
raise HTTPException(status_code=410, detail="Share is inactive")
if _normalize_datetime(shared.expires_at) < _utcnow():
raise HTTPException(status_code=410, detail="Share has expired")
if shared.current_users >= shared.max_concurrent_users:
raise HTTPException(status_code=429, detail="Maximum concurrent users reached")
messages_result = await db.execute(
select(ChatMessage)
.where(
and_(
ChatMessage.tenant_id == shared.tenant_id,
ChatMessage.session_id == shared.session_id,
)
)
.order_by(ChatMessage.created_at.asc())
)
messages = messages_result.scalars().all()
history = [
HistoryMessage(role=msg.role, content=msg.content)
for msg in messages
]
return SharedSessionInfo(
session_id=shared.session_id,
title=shared.title,
description=shared.description,
expires_at=shared.expires_at.isoformat(),
max_concurrent_users=shared.max_concurrent_users,
current_users=shared.current_users,
history=history,
)
@router.get(
"/sessions/{session_id}/shares",
operation_id="listShares",
summary="List all shares for a session",
description="""
[AC-IDMP-SHARE] List all share links for a session.
""",
responses={
200: {"description": "List of shares", "model": ShareListResponse},
},
)
async def list_shares(
session_id: Annotated[str, Path(description="Session ID")],
db: Annotated[AsyncSession, Depends(get_session)],
include_expired: Annotated[bool, Query(description="Include expired shares")] = False,
) -> ShareListResponse:
"""List all shares for a session."""
tenant_id = get_tenant_id()
if not tenant_id:
from app.core.exceptions import MissingTenantIdException
raise MissingTenantIdException()
query = select(SharedSession).where(
and_(
SharedSession.tenant_id == tenant_id,
SharedSession.session_id == session_id,
)
)
now = _utcnow()
if not include_expired:
query = query.where(SharedSession.expires_at > now)
query = query.order_by(SharedSession.created_at.desc())
result = await db.execute(query)
shares = result.scalars().all()
items = [
ShareListItem(
share_token=s.share_token,
share_url=_generate_share_url(s.share_token),
title=s.title,
description=s.description,
expires_at=s.expires_at.isoformat(),
is_active=s.is_active and _normalize_datetime(s.expires_at) > now,
max_concurrent_users=s.max_concurrent_users,
current_users=s.current_users,
created_at=s.created_at.isoformat(),
)
for s in shares
]
return ShareListResponse(shares=items)
@router.delete(
"/share/{share_token}",
operation_id="deleteShare",
summary="Delete a share",
description="""
[AC-IDMP-SHARE] Delete (deactivate) a share link.
""",
responses={
200: {"description": "Share deleted"},
404: {"description": "Share not found"},
},
)
async def delete_share(
share_token: Annotated[str, Path(description="Share token")],
db: Annotated[AsyncSession, Depends(get_session)],
) -> dict:
"""Delete a share (set is_active=False)."""
tenant_id = get_tenant_id()
if not tenant_id:
from app.core.exceptions import MissingTenantIdException
raise MissingTenantIdException()
result = await db.execute(
select(SharedSession).where(
and_(
SharedSession.share_token == share_token,
SharedSession.tenant_id == tenant_id,
)
)
)
shared = result.scalar_one_or_none()
if not shared:
raise HTTPException(status_code=404, detail="Share not found")
shared.is_active = False
await db.commit()
logger.info(f"[AC-IDMP-SHARE] Share deleted: token={share_token}")
return {"success": True, "message": "Share deleted"}
@router.post(
"/share/{share_token}/join",
operation_id="joinSharedSession",
summary="Join a shared session",
description="""
[AC-IDMP-SHARE] Join a shared session (increment current_users).
Returns updated session info.
""",
responses={
200: {"description": "Joined successfully", "model": SharedSessionInfo},
404: {"description": "Share not found"},
410: {"description": "Share expired or inactive"},
429: {"description": "Too many concurrent users"},
},
)
async def join_shared_session(
share_token: Annotated[str, Path(description="Share token")],
db: Annotated[AsyncSession, Depends(get_session)],
) -> SharedSessionInfo:
"""Join a shared session (increment current_users)."""
result = await db.execute(
select(SharedSession).where(SharedSession.share_token == share_token)
)
shared = result.scalar_one_or_none()
if not shared:
raise HTTPException(status_code=404, detail="Share not found")
if not shared.is_active:
raise HTTPException(status_code=410, detail="Share is inactive")
if _normalize_datetime(shared.expires_at) < _utcnow():
raise HTTPException(status_code=410, detail="Share has expired")
if shared.current_users >= shared.max_concurrent_users:
raise HTTPException(status_code=429, detail="Maximum concurrent users reached")
shared.current_users += 1
shared.updated_at = _utcnow()
await db.commit()
messages_result = await db.execute(
select(ChatMessage)
.where(
and_(
ChatMessage.tenant_id == shared.tenant_id,
ChatMessage.session_id == shared.session_id,
)
)
.order_by(ChatMessage.created_at.asc())
)
messages = messages_result.scalars().all()
history = [
HistoryMessage(role=msg.role, content=msg.content)
for msg in messages
]
logger.info(f"[AC-IDMP-SHARE] User joined: token={share_token}, users={shared.current_users}")
return SharedSessionInfo(
session_id=shared.session_id,
title=shared.title,
description=shared.description,
expires_at=shared.expires_at.isoformat(),
max_concurrent_users=shared.max_concurrent_users,
current_users=shared.current_users,
history=history,
)
@router.post(
"/share/{share_token}/leave",
operation_id="leaveSharedSession",
summary="Leave a shared session",
description="""
[AC-IDMP-SHARE] Leave a shared session (decrement current_users).
""",
responses={
200: {"description": "Left successfully"},
404: {"description": "Share not found"},
},
)
async def leave_shared_session(
share_token: Annotated[str, Path(description="Share token")],
db: Annotated[AsyncSession, Depends(get_session)],
) -> dict:
"""Leave a shared session (decrement current_users)."""
result = await db.execute(
select(SharedSession).where(SharedSession.share_token == share_token)
)
shared = result.scalar_one_or_none()
if not shared:
raise HTTPException(status_code=404, detail="Share not found")
if shared.current_users > 0:
shared.current_users -= 1
shared.updated_at = _utcnow()
await db.commit()
logger.info(f"[AC-IDMP-SHARE] User left: token={share_token}, users={shared.current_users}")
return {"success": True, "current_users": shared.current_users}
@router.post(
"/share/{share_token}/message",
operation_id="sendSharedMessage",
summary="Send message via shared session",
description="""
[AC-IDMP-SHARE] Send a message via shared session.
Creates a new message in the shared session and returns the response.
""",
responses={
200: {"description": "Message sent successfully"},
404: {"description": "Share not found"},
410: {"description": "Share expired or inactive"},
},
)
async def send_shared_message(
share_token: Annotated[str, Path(description="Share token")],
request: SharedMessageRequest,
db: Annotated[AsyncSession, Depends(get_session)],
) -> dict:
"""Send a message via shared session."""
result = await db.execute(
select(SharedSession).where(SharedSession.share_token == share_token)
)
shared = result.scalar_one_or_none()
if not shared:
raise HTTPException(status_code=404, detail="Share not found")
if not shared.is_active:
raise HTTPException(status_code=410, detail="Share is inactive")
if _normalize_datetime(shared.expires_at) < _utcnow():
raise HTTPException(status_code=410, detail="Share has expired")
user_message = ChatMessage(
tenant_id=shared.tenant_id,
session_id=shared.session_id,
role="user",
content=request.user_message,
)
db.add(user_message)
await db.commit()
logger.info(
f"[AC-IDMP-SHARE] Message sent via share: token={share_token}, "
f"session={shared.session_id}"
)
return {
"success": True,
"message": "Message sent successfully",
"session_id": shared.session_id,
}