from pathlib import Path from typing import Any from fastapi import Request, Response, WebSocket, WebSocketDisconnect from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from app.config import Config, settings from app.models import ( ConversationState, MessageEnvelope, MessageType, SenderType, TextMessage, User, UserSettings, ) from app.utils import linkify templates = Jinja2Templates(directory=Path(__file__).parent / "templates") templates.env.filters["linkify"] = linkify class IndexHandler: async def handle(self, request: Request, user: User) -> Response: # Get the root path for generating correct URLs behind a proxy root_path = request.scope.get("root_path", "") template = templates.get_template("index.html") html = template.render(root_path=root_path) response = HTMLResponse(html) if user.new_user: response.set_cookie( key="user_id", value=user.id, max_age=60 * 60 * 24 * 30, httponly=False, samesite="lax", path="/", ) user.new_user = False return response class WebSocketHandler: def __init__(self, config: Config = settings): self.config = config self._seq_counter = 0 def _next_seq(self) -> int: self._seq_counter += 1 return self._seq_counter async def send_envelope( self, web_socket: WebSocket, msg_type: MessageType | str, data: dict[str, Any], ref: str | None = None, capabilities: list[str] | None = None, ) -> MessageEnvelope: # Allow passing raw string types for compatibility mt = ( MessageType(msg_type) if not isinstance(msg_type, MessageType) else msg_type ) envelope = MessageEnvelope( type=mt, seq=self._next_seq(), ref=ref, data=data, ) if capabilities is not None: envelope.meta.capabilities = capabilities await web_socket.send_json(envelope.for_send()) return envelope async def handle(self, web_socket: WebSocket, user: User): await web_socket.accept() await self.send_init_message(web_socket, user) connected = True while connected: try: await self.handle_incoming_message(web_socket, user) except WebSocketDisconnect: connected = False async def send_init_message(self, web_socket: WebSocket, user: User) -> None: payload = user.model_dump( include={"settings", "messages", "client_settings_needed", "id"} ) # add current conversation state for UI display payload["state"] = user.state.value await self.send_envelope( web_socket, MessageType.init, payload, capabilities=["envelope", "settings_push", "attachments"], ) if user.client_settings_needed: await self.send_envelope( web_socket, MessageType.settings_request, {"reason": "rehydration", "user_id": user.id}, ) async def handle_incoming_message(self, web_socket: WebSocket, user: User) -> None: raw = await web_socket.receive_json() envelope = MessageEnvelope.model_validate(raw) msg_type = envelope.type.value incoming = envelope.data if msg_type == "chat": await self.handle_chat_message(web_socket, user, incoming, envelope) elif msg_type == "reset": await self.handle_reset_message(web_socket, user, envelope) elif msg_type == "settings": await self.handle_settings_message(web_socket, user, incoming, envelope) elif msg_type == "settings_push": await self.handle_settings_push_message( web_socket, user, incoming, envelope ) elif msg_type == "ping": ts_val = incoming.get("ts") if isinstance(incoming, dict) else None await self.send_envelope( web_socket, MessageType.pong, {"ts": ts_val}, ref=envelope.id ) async def handle_reset_message( self, web_socket: WebSocket, user: User, envelope: MessageEnvelope | None ) -> None: # Only clear messages and reset conversation state; preserve existing user.settings user.messages = [] user.connector.fansifter_connector.state = ConversationState.GREETING payload = user.model_dump(include={"settings", "messages", "id"}) payload["state"] = user.state.value await self.send_envelope( web_socket, MessageType.reset, payload, ref=envelope.id if envelope else None, ) async def handle_settings_message( self, web_socket: WebSocket, user: User, data: dict[str, Any], envelope: MessageEnvelope | None, ) -> None: incoming_settings: dict[str, Any] = ( data.get("settings", {}) if isinstance(data, dict) else {} ) try: user.settings = UserSettings.model_validate(incoming_settings) # Enforce presence of built-in non-removable Twilio opt-in keyword 'unstop' tw = user.settings.twilio_settings if "unstop" not in {k.lower() for k in tw.opt_in_keywords}: tw.opt_in_keywords.append("unstop") except Exception: await self.send_envelope( web_socket, MessageType.error, {"sender": "system", "event": "settings update error"}, ref=envelope.id if envelope else None, ) return await self.send_envelope( web_socket, MessageType.settings_ack, user.model_dump(include={"settings", "id"}), ref=envelope.id if envelope else None, ) async def handle_settings_push_message( self, web_socket: WebSocket, user: User, data: dict[str, Any], envelope: MessageEnvelope | None, ) -> None: incoming_settings: dict[str, Any] = ( data.get("settings", {}) if isinstance(data, dict) else {} ) try: user.settings = UserSettings.model_validate(incoming_settings) user.client_settings_needed = False tw = user.settings.twilio_settings if "unstop" not in {k.lower() for k in tw.opt_in_keywords}: tw.opt_in_keywords.append("unstop") except Exception: await self.send_envelope( web_socket, MessageType.error, {"sender": "system", "event": "settings push error"}, ref=envelope.id if envelope else None, ) return await self.send_envelope( web_socket, MessageType.settings_ack, user.model_dump(include={"settings", "id"}), ref=envelope.id if envelope else None, ) async def handle_chat_message( self, web_socket: WebSocket, user: User, data: dict[str, Any], envelope: MessageEnvelope | None, ) -> None: text = str(data.get("text", "")).strip() if isinstance(data, dict) else "" if not text: return fan_msg = TextMessage(sender=SenderType.fan, text=text) user.messages.append(fan_msg) fan_payload = fan_msg.model_dump() ack_env = await self.send_envelope( web_socket, MessageType.chat, fan_payload, ref=envelope.id if envelope else None, ) state = user.state.value produced = await user.connector.handle_incoming_message(user, text) if state != user.state.value: # emit dedicated state_update for UI, plus log_event for history await self.send_envelope( web_socket, MessageType.state_update, {"state": user.state.value}, ref=ack_env.id, ) await self.send_envelope( web_socket, MessageType.log_event, { "sender": "fansifter", "event": f"state changed to {user.state.value}", }, ref=ack_env.id, ) for message in produced: user.messages.append(message) payload_msg = message.model_dump() reply_env = await self.send_envelope( web_socket, MessageType.chat, payload_msg, ref=ack_env.id ) if user.settings.app_settings.show_events: await self.send_envelope( web_socket, MessageType.log_event, {"sender": message.sender, "event": "reply sent"}, ref=reply_env.id, )