"""Bounded, process-local conversation state for the single-worker Vocero API.""" import asyncio import hashlib import secrets from dataclasses import dataclass, field from time import monotonic from typing import Any from urllib.parse import urlsplit from starlette.requests import HTTPConnection from vocero_api.core import config as settings from vocero_api.models.mcp import BackendLogin, CallerIdentity, IntegrationError, McpToolDefinition COOKIE_NAME = "vocero_browser" CONVERSATION_HEADER = "x-vocero-conversation" @dataclass class Conversation: id: str owner: str = field(repr=False) backend_token: str = field(repr=False) profile: CallerIdentity expires_at: float catalog: list[McpToolDefinition] = field(default_factory=list) history: list[list[dict[str, str]]] = field(default_factory=list, repr=False) generation: int = 0 client_turn: int = 0 pending: asyncio.Task[Any] | None = field(default=None, repr=False) socket_attached: bool = False closed: asyncio.Event = field(default_factory=asyncio.Event, repr=False) def require_active(self) -> None: if self.closed.is_set() or monotonic() >= self.expires_at: raise IntegrationError(401, "La conversación expiró. Inicia una nueva sesión.") def interrupt(self) -> int: self.generation += 1 if self.pending is not None and not self.pending.done() and self.pending is not asyncio.current_task(): self.pending.cancel() self.pending = None return self.generation def close(self) -> None: self.interrupt() self.closed.set() self.backend_token = "" self.history.clear() self.catalog.clear() class ConversationStore: def __init__(self) -> None: self._items: dict[str, Conversation] = {} def prune(self) -> None: for item in list(self._items.values()): if item.closed.is_set() or monotonic() >= item.expires_at: self.remove(item.id) def create(self, browser: str, login: BackendLogin) -> Conversation: self.prune() if len(self._items) >= settings.S2S_MAX_CONVERSATIONS: raise IntegrationError(503, "No hay capacidad para otra conversación. Intenta más tarde.") item = Conversation( id=secrets.token_urlsafe(24), owner=self._owner(browser), backend_token=login.sessionToken.get_secret_value(), profile=login.user, expires_at=monotonic() + settings.S2S_CONVERSATION_TTL_SEC, ) self._items[item.id] = item return item @staticmethod def _owner(browser: str) -> str: return hashlib.sha256(browser.encode()).hexdigest() def get(self, browser: str, conversation_id: str) -> Conversation: self.prune() item = self._items.get(conversation_id) if not browser or item is None or not secrets.compare_digest(item.owner, self._owner(browser)): raise IntegrationError(401, "La conversación no es válida. Inicia una nueva sesión.") item.require_active() return item def remove(self, conversation_id: str) -> None: item = self._items.pop(conversation_id, None) if item is not None: item.close() def clear(self) -> None: for conversation_id in list(self._items): self.remove(conversation_id) store = ConversationStore() def check_origin(connection: HTTPConnection) -> None: origin = connection.headers.get("origin") if origin: parsed = urlsplit(origin) if parsed.scheme not in {"http", "https"} or parsed.netloc != connection.headers.get("host"): raise IntegrationError(403, "El origen de la solicitud no está permitido.") def resolve(connection: HTTPConnection) -> Conversation: check_origin(connection) conversation_id = connection.headers.get(CONVERSATION_HEADER, "") if connection.scope["type"] == "websocket": conversation_id = connection.query_params.get("conversation_id", "") return store.get(connection.cookies.get(COOKIE_NAME, ""), conversation_id)