from datetime import datetime, timezone from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response, status from sqlalchemy import and_, or_, select from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session from api.models.agent_message import BROADCAST, MESSAGE_KINDS, AgentMessage from api.schemas.agent_message import ( MessageAck, MessageCreate, MessageMarkRead, MessageRead, MessageReply, NoticeStatus, ) from api.services.legacy_meter import identity_from_request, record_legacy_usage from api.services.message_receipts import ( NEWS_DEFAULT_TTL, broadcast_unread_clause, is_broadcast, notice_statuses, receipts_for, upsert_receipt, utcnow, ) from api.services.repository_aliases import ( canonicalize_repository_slug, resolve_repository_slug, ) from hub_core.message_identity import resolve_message_reference from hub_core.models.message_identity_alias import MessageIdentityAlias router = APIRouter(prefix="/messages", tags=["messages"]) UNATTRIBUTED_BROADCAST_READ_KEY = "rest_api:PATCH /messages/{id}/read broadcast-without-reader" UNATTRIBUTED_BROADCAST_READ_REPLACEMENT = "PATCH /messages/{id}/read?reader=" UNATTRIBUTED_BROADCAST_WARNING = '299 - "broadcast mark-read needs ?reader="' async def _reader_values(session: AsyncSession, reader: str) -> tuple[str, tuple[str, ...]]: """Canonical reader slug plus every historical slug it answers to.""" resolution = await resolve_repository_slug(session, reader, required=False) if resolution is None: return reader, (reader,) return resolution.canonical_slug, tuple(resolution.slug_values) async def _read_view( session: AsyncSession, message: AgentMessage, reader_values: tuple[str, ...] | None, reader: str | None, ) -> MessageRead: view = MessageRead.model_validate(message) if reader_values is None or not is_broadcast(message): return view state = (await receipts_for(session, [message.id], reader_values)).get(message.id, {}) return view.model_copy( update={ "reader": reader, "delivered_at": state.get("delivered_at"), "read_at": state.get("read_at"), "acknowledged_at": state.get("acknowledged_at"), } ) async def _get_message(reference: str, session: AsyncSession) -> AgentMessage: message_id = await resolve_message_reference( session, reference, alias_model=MessageIdentityAlias ) if message_id is None: raise HTTPException( status_code=404, detail=f"Message reference {reference!r} not found" ) message = await session.get(AgentMessage, message_id) if message is None: raise HTTPException( status_code=404, detail=f"Message reference {reference!r} not found" ) return message @router.post("/", response_model=MessageRead, status_code=status.HTTP_201_CREATED) async def send_message( body: MessageCreate, session: AsyncSession = Depends(get_session), ) -> AgentMessage: if body.thread_id and await session.get(AgentMessage, body.thread_id) is None: raise HTTPException(status_code=404, detail=f"Thread root {body.thread_id} not found") payload = body.model_dump() payload["from_agent"] = await canonicalize_repository_slug(session, body.from_agent) payload["to_agent"] = await canonicalize_repository_slug(session, body.to_agent) if payload["to_agent"] == BROADCAST: kind = body.kind or "news" if kind == "message": kind = "news" if kind not in MESSAGE_KINDS: raise HTTPException( status_code=422, detail=f"kind must be one of {', '.join(MESSAGE_KINDS[1:])} for broadcasts", ) payload["kind"] = kind if kind == "news" and body.expires_at is None: payload["expires_at"] = utcnow() + NEWS_DEFAULT_TTL if body.supersedes_id is not None: predecessor = await session.get(AgentMessage, body.supersedes_id) if predecessor is None or not is_broadcast(predecessor): raise HTTPException( status_code=404, detail=f"Superseded broadcast {body.supersedes_id} not found", ) if predecessor.archived_at is None: predecessor.archived_at = utcnow() else: if body.kind not in (None, "message"): raise HTTPException( status_code=422, detail="kind news/standing is only valid for broadcasts" ) if body.expires_at is not None or body.supersedes_id is not None: raise HTTPException( status_code=422, detail="expires_at and supersedes_id are only valid for broadcasts", ) payload["kind"] = "message" message = AgentMessage(**payload) session.add(message) await session.commit() await session.refresh(message) return message @router.get("/", response_model=list[MessageRead]) async def list_messages( to_agent: str | None = None, from_agent: str | None = None, unread_only: bool = False, limit: int = 50, session: AsyncSession = Depends(get_session), ) -> list[MessageRead]: now = utcnow() query = select(AgentMessage).where(AgentMessage.archived_at.is_(None)) reader: str | None = None reader_values: tuple[str, ...] | None = None if to_agent: reader, reader_values = await _reader_values(session, to_agent) direct = AgentMessage.to_agent.in_(reader_values) if unread_only: query = query.where( or_( and_(direct, AgentMessage.read_at.is_(None)), broadcast_unread_clause(reader_values, now), ) ) else: query = query.where( or_( direct, and_( AgentMessage.to_agent == BROADCAST, or_(AgentMessage.expires_at.is_(None), AgentMessage.expires_at > now), ), ) ) elif unread_only: query = query.where(AgentMessage.read_at.is_(None)) if from_agent: resolution = await resolve_repository_slug(session, from_agent, required=False) values = resolution.slug_values if resolution else (from_agent,) query = query.where(AgentMessage.from_agent.in_(values)) result = await session.execute( query.order_by(AgentMessage.created_at.desc()).limit(limit) ) messages = list(result.scalars().all()) if reader_values is None or reader is None: return [MessageRead.model_validate(m) for m in messages] broadcast_ids = [m.id for m in messages if is_broadcast(m)] if unread_only and broadcast_ids and reader != BROADCAST: # D3 (founder-approved 2026-09-22): the orientation inbox call records # an idempotent delivery receipt for each broadcast it returns. await upsert_receipt(session, broadcast_ids, reader, delivered=True, at=now) await session.commit() receipts = await receipts_for(session, broadcast_ids, reader_values) views = [] for message in messages: view = MessageRead.model_validate(message) if is_broadcast(message): state = receipts.get(message.id, {}) view = view.model_copy( update={ "reader": reader, "delivered_at": state.get("delivered_at"), "read_at": state.get("read_at"), "acknowledged_at": state.get("acknowledged_at"), } ) views.append(view) return views @router.get("/notices", response_model=list[NoticeStatus]) async def list_notices( session: AsyncSession = Depends(get_session), ) -> list[NoticeStatus]: """Live standing notices with acknowledged / delivered-only / unreached repos.""" return await notice_statuses(session) @router.get("/thread/{thread_id}", response_model=list[MessageRead]) async def get_thread( thread_id: str, session: AsyncSession = Depends(get_session), ) -> list[AgentMessage]: resolved = await resolve_message_reference( session, thread_id, alias_model=MessageIdentityAlias ) if resolved is None: raise HTTPException( status_code=404, detail=f"Message reference {thread_id!r} not found" ) result = await session.execute( select(AgentMessage) .where(or_(AgentMessage.id == resolved, AgentMessage.thread_id == resolved)) .order_by(AgentMessage.created_at) ) return list(result.scalars().all()) @router.patch("/{message_id}/read", response_model=MessageRead) async def mark_read( message_id: str, request: Request, response: Response, reader: str | None = None, ack: bool = False, body: MessageMarkRead | None = Body(default=None), session: AsyncSession = Depends(get_session), ) -> AgentMessage | MessageRead: message = await _get_message(message_id, session) if not is_broadcast(message): if message.read_at is None: message.read_at = datetime.now(timezone.utc) await session.commit() await session.refresh(message) return message reader = reader or (body.reader if body else None) ack = ack or bool(body and body.ack) if not reader: view = MessageRead.model_validate(message) # D2: an unattributed mark-read never hides a broadcast from anyone. response.headers["Deprecation"] = "true" response.headers["Warning"] = UNATTRIBUTED_BROADCAST_WARNING response.headers["X-StateHub-Replacement"] = UNATTRIBUTED_BROADCAST_READ_REPLACEMENT try: await record_legacy_usage( session, interface_key=UNATTRIBUTED_BROADCAST_READ_KEY, interface_kind="rest_api", replacement_ref=UNATTRIBUTED_BROADCAST_READ_REPLACEMENT, owner_component="state-hub.api", replacement_verified=True, identity=identity_from_request(request), ) except Exception: await session.rollback() return view canonical, values = await _reader_values(session, reader) await upsert_receipt(session, [message.id], canonical, read=True, acknowledged=ack) await session.commit() return await _read_view(session, message, values, canonical) @router.post("/{message_id}/ack", response_model=MessageRead) async def acknowledge_message( message_id: str, body: MessageAck, session: AsyncSession = Depends(get_session), ) -> MessageRead: """Acknowledge a broadcast (clears a standing notice for that agent).""" message = await _get_message(message_id, session) if not is_broadcast(message): raise HTTPException( status_code=400, detail="ack applies to broadcasts only; use PATCH /messages/{id}/read", ) canonical, values = await _reader_values(session, body.agent) await upsert_receipt(session, [message.id], canonical, acknowledged=True) await session.commit() return await _read_view(session, message, values, canonical) @router.patch("/{message_id}/archive", response_model=MessageRead) async def archive_message( message_id: str, session: AsyncSession = Depends(get_session), ) -> AgentMessage: message = await _get_message(message_id, session) message.archived_at = datetime.now(timezone.utc) # Broadcast archive is a global withdrawal; it no longer stamps read_at. if message.read_at is None and not is_broadcast(message): message.read_at = message.archived_at await session.commit() await session.refresh(message) return message @router.post( "/{message_id}/reply", response_model=MessageRead, status_code=status.HTTP_201_CREATED, ) async def reply_to_message( message_id: str, body: MessageReply, session: AsyncSession = Depends(get_session), ) -> AgentMessage: original = await _get_message(message_id, session) replier = await canonicalize_repository_slug(session, body.from_agent) if is_broadcast(original): await upsert_receipt(session, [original.id], replier, read=True) elif original.read_at is None: original.read_at = datetime.now(timezone.utc) reply = AgentMessage( from_agent=replier, to_agent=await canonicalize_repository_slug(session, original.from_agent), subject=f"Re: {original.subject}", body=body.body, thread_id=original.thread_id or original.id, ) session.add(reply) await session.commit() await session.refresh(reply) return reply __all__ = ["router"]