""" Copyright 2024-2026 ReplyPier Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ import datetime import re from types import SimpleNamespace from fastapi import APIRouter from socketio.exceptions import ConnectionRefusedError as SocketRefused from app.channels.constants import MessageSource from app.core.socketio import sio from app.core.logger import get_logger import traceback from app.agents.chat_agent import ChatAgent, ChatResponse from app.core.auth_utils import ( authenticate_socket, authenticate_socket_conversation_token, widget_socket_identity, ) from app.database import get_db from app.repositories.ai_config import AIConfigRepository from app.repositories.widget import WidgetRepository from app.repositories.agent_shopify_config_repository import AgentShopifyConfigRepository from app.core.security import decrypt_api_key, verify_conversation_token from app.repositories.session_to_agent import SessionToAgentRepository from app.repositories.chat import ChatRepository from app.repositories.customer import CustomerRepository import uuid from app.services.socket_rate_limit import socket_rate_limit from app.services import conversation_token as conversation_token_service from app.services.workflow_chat import WorkflowChatService from app.services.file_upload_service import FileUploadService from app.services.file_storage import resolve_public_url from app.models.session_to_agent import ACTIVE_STATUSES, SessionStatus from app.agents.transfer_agent import get_agent_availability_response from app.repositories.agent import AgentRepository from app.services.agent_rooms import ( CHAT_WATCH_PREFIX, ORG_CHAT_UPDATES_PREFIX, is_org_inbox_room, may_join_chat_updates, may_join_org_inbox, may_watch_chat, notify_conversation_changed, ) from app.services.chat_notifications import notify_new_chat from app.services.contact_capture import retain_unstored_email from app.services.human_routing import ( HUMAN_ONLY_ACK, QUEUED_FOR_HUMAN_NOTICE, notify_customer, ) from app.services.lead_capture import record_lead_from_response from app.services.widget_identity import can_act_as, may_stand_for from app.services.message_delivery import deliver_to_customer from app.repositories.rating import RatingRepository from app.services.ticket_csat import record_conversation_rating from app.repositories.jira import JiraRepository from app.models.ai_config import AIModelType from app.services.workflow_execution import WorkflowExecutionService # Try to import enterprise modules try: from app.enterprise.services.message_limit import check_message_limit HAS_ENTERPRISE = True except ImportError: HAS_ENTERPRISE = False from app.utils.sanitize import sanitize_message router = APIRouter() logger = get_logger(__name__) # The inbox's own id for a message it sends (a UUID), echoed back on the # stored-message ack so it can match the row it became MAX_CLIENT_ID_LENGTH = 64 async def _emit_bot_typing(session_id: str) -> None: """Tell the widget a reply is actually being produced. The widget owns no opinion about this: it shows the typing indicator only between this event and the reply that follows. Every path that answers nothing — a human-only agent, a chat someone has taken over, a closed session — simply never calls this, so the indicator cannot be left spinning over a conversation nobody is answering. """ await sio.emit('bot_typing', {'session_id': session_id}, room=session_id, namespace='/widget') # Rating feedback comes from an anonymous widget visitor and is stored, shown # to agents and copied onto the linked ticket's activity feed — bound it. MAX_RATING_FEEDBACK_LENGTH = 2000 def format_datetime(dt): """Convert datetime to ISO format string""" return dt.isoformat() if dt else None async def _client_attachments(uploaded_files: list) -> list: """Socket payload for freshly uploaded files, each with a signed URL.""" return [ { 'filename': file_info['filename'], 'file_url': await resolve_public_url(file_info['file_url']), 'content_type': file_info['content_type'], 'file_size': file_info['size'], } for file_info in uploaded_files or [] ] def validate_email(email: str) -> bool: """Validate email format""" if not email: return False email_pattern = r'^[^\s@]+@[^\s@]+\.[^\s@]+$' return re.match(email_pattern, email) is not None def validate_phone_number(phone: str) -> bool: """Validate phone number format""" if not phone: return False # Remove all non-digit characters clean_phone = re.sub(r'\D', '', phone) # Check if it's between 7 and 15 digits (international standard) return 7 <= len(clean_phone) <= 15 def validate_form_field(field_config: dict, value: any) -> str: """Validate a single form field based on its configuration""" field_name = field_config.get('name', 'Field') field_label = field_config.get('label', field_name) field_type = field_config.get('type', 'text') required = field_config.get('required', False) min_length = field_config.get('minLength', 0) max_length = field_config.get('maxLength', None) # Required field validation if required and (value is None or str(value).strip() == ''): return f"{field_label} is required" # Skip further validation if field is empty and not required if value is None or str(value).strip() == '': return None value_str = str(value).strip() # Email validation if field_type == 'email' and not validate_email(value_str): return f"Please enter a valid email address for {field_label}" # Phone number validation if field_type == 'tel' and not validate_phone_number(value_str): return f"Please enter a valid phone number for {field_label}" # Length validation for text fields if field_type in ['text', 'textarea']: if min_length and len(value_str) < min_length: return f"{field_label} must be at least {min_length} characters" if max_length and len(value_str) > max_length: return f"{field_label} must not exceed {max_length} characters" # Number validation if field_type == 'number': try: num_value = float(value_str) if min_length is not None and num_value < min_length: return f"{field_label} must be at least {min_length}" if max_length is not None and num_value > max_length: return f"{field_label} must not exceed {max_length}" except ValueError: return f"{field_label} must be a valid number" return None def validate_form_data(form_fields: list, form_data: dict) -> list: """Validate form data against field configurations""" errors = [] for field_config in form_fields: field_name = field_config.get('name') if not field_name: continue value = form_data.get(field_name) error = validate_form_field(field_config, value) if error: errors.append(error) return errors def _refuse_connection(error: str, error_type: str): """Reject the connect with a reason the widget can act on. It has to travel in the refusal itself: socket.io clients drop events emitted to a connection that is then refused, so an 'error' emit never reached a browser.""" raise SocketRefused(error, {'error': error, 'type': error_type}) def _own_session(session_repo: SessionToAgentRepository, session: dict, active_only: bool = True): """The conversation this socket connected to, while it still belongs to it.""" return session_repo.get_widget_session( session.get('session_id'), session.get('customer_id'), session.get('conversation_scope'), active_only=active_only) @sio.on('connect', namespace='/widget') async def widget_connect(sid, environ, auth): db = None try: logger.info(f"Widget client connected: {auth}") # Authenticate using conversation token from Authorization header widget_id, org_id, customer_id, conversation_token = await authenticate_socket_conversation_token(sid, auth) if not widget_id or not org_id: raise ValueError("Widget authentication failed") # The widget page hands out an anonymous token; GET /widgets/{id} binds it # to a customer. A conversation can't be stored without one, so say so # instead of failing on the insert: the widget re-authorizes and retries. # (Legacy tokens carry the string "None", as GET /widgets/{id} also allows.) if not customer_id or customer_id == "None": _refuse_connection('Visitor not authorized yet', 'customer_required') # The token's scope decides which conversations this socket may join, so # an unverifiable token fails the connection instead of being shrugged off. token_data = verify_conversation_token(conversation_token) if not token_data: raise ValueError("Conversation token could not be verified") scope = conversation_token_service.conversation_scope(token_data) source = token_data.get("source") is_identified = conversation_token_service.is_identified(token_data) # Get widget and verify it exists db = next(get_db()) widget_repo = WidgetRepository(db) widget = widget_repo.get_widget(widget_id) if not widget: raise ValueError("Invalid widget ID") # A widget-issued token may not stand for a person the embedding app has # since signed in. Refused like an unbound token, so the widget authorizes # again - and GET /widgets/{id} swaps it for a customer of its own. if not may_stand_for(CustomerRepository(db).get_by_id(customer_id), scope): _refuse_connection('Visitor not authorized yet', 'customer_required') # Get AI config for widget's organization ai_config_repo = AIConfigRepository(db) ai_config = ai_config_repo.get_active_config(org_id) if not ai_config: _refuse_connection('AI configuration required', 'ai_config_missing') message_limit_reached = False # Check message limits if enterprise module is available if HAS_ENTERPRISE and ai_config.model_type == AIModelType.REPLYPIER: if not await check_message_limit(db, org_id, sid, sio): message_limit_reached = True session_repo = SessionToAgentRepository(db) # Resume only a conversation this token's scope created active_session = session_repo.get_active_widget_session( customer_id=customer_id, agent_id=widget.agent_id, scope=scope ) session_created = active_session is None if active_session: session_id = str(active_session.session_id) logger.debug(f"Active session: {session_id}") else: # Create new session if none exists. Stamp the channel now, the way # the messaging channels do: an agent wired to a Shopify store is # serving that storefront, and the inbox labels the conversation # accordingly. Still a widget surface — see WIDGET_CHANNELS. new_session_id = str(uuid.uuid4()) shopify_config = AgentShopifyConfigRepository(db).get_agent_shopify_config( str(widget.agent_id) ) session_repo.create_session( session_id=new_session_id, agent_id=widget.agent_id, customer_id=customer_id, organization_id=org_id, channel='shopify' if shopify_config and shopify_config.enabled else 'web', conversation_key=conversation_token_service.session_key_for_scope(scope) ) session_id = new_session_id logger.debug(f"New session: {session_id}") # Join the room using session_id await sio.enter_room(sid, session_id, namespace='/widget') # Get agent to retrieve rate limiting settings agent_repo = AgentRepository(db) agent = agent_repo.get_agent(widget.agent_id) # Store session data including rate limiting settings enable_rate_limiting = agent.enable_rate_limiting if agent else False overall_limit_per_ip = agent.overall_limit_per_ip if agent else 100 requests_per_sec = agent.requests_per_sec if agent else 1.0 # Snapshot only the fields the rest of the app reads from session['ai_config'] # (encrypted_api_key/model_name/model_type) into a plain, DB-independent object. # This dict lives in the socket.io session store across the whole conversation, # long after this handler's db session closes - caching the live ORM instance # here would raise DetachedInstanceError on later access. ai_config_snapshot = SimpleNamespace( encrypted_api_key=ai_config.encrypted_api_key, model_name=ai_config.model_name, model_type=ai_config.model_type, settings=ai_config.settings, ) session_data = { 'widget_id': widget_id, 'org_id': org_id, 'agent_id': str(widget.agent_id), 'customer_id': customer_id, 'session_id': session_id, 'ai_config': ai_config_snapshot, 'conversation_token': conversation_token, # Which of the customer's conversations this socket may act on (see # conversation_token.conversation_scope). Read by _own_session. 'conversation_scope': scope, # Add rate limiting settings 'enable_rate_limiting': enable_rate_limiting, 'overall_limit_per_ip': overall_limit_per_ip, 'requests_per_sec': requests_per_sec, 'message_limit_reached': message_limit_reached, # Cached like use_workflow: read once per connection so the # human-only check costs no query per message. 'ai_replies_enabled': agent.ai_replies_enabled if agent else True, 'use_workflow': agent.use_workflow if agent else False, 'active_workflow_id': agent.active_workflow_id if agent else None, 'source': source, # Host page that embeds the widget (sent by the webclient). Recorded on # lead capture as lead_source.page_url so you can see WHERE leads convert. 'page_url': str((auth or {}).get('page_url') or '')[:500] or None, } # Log rate limiting settings if enable_rate_limiting: logger.debug(f"Rate limiting enabled for agent {agent.name} - Daily limit: {overall_limit_per_ip} requests, Rate: {requests_per_sec} req/sec") else: logger.debug(f"Rate limiting disabled for agent {agent.name}") logger.debug(f"Session data: {session_data['ai_config'].encrypted_api_key}") await sio.save_session(sid, session_data, namespace='/widget') logger.info(f"Widget client connected: {sid} joined room: {session_id}") # Send session_id back to client immediately so it can be used for end_chat. # `authenticated` lets the widget tell the host page whether a conversation it # just started belongs to the identified visitor or to an anonymous one. await sio.emit('session_initialized', { 'session_id': session_id, 'customer_id': customer_id, 'agent_id': str(widget.agent_id), 'authenticated': is_identified, 'created': session_created }, to=sid, namespace='/widget') return True except SocketRefused: raise except Exception as e: logger.error(f"Widget connection error for sid {sid}: {str(e)}") logger.error(traceback.format_exc()) await sio.emit('error', { 'error': 'Connection failed', 'type': 'connection_error' }, to=sid, namespace='/widget') return False finally: if db: db.close() @sio.on('chat', namespace='/widget') @socket_rate_limit(namespace='/widget') async def handle_widget_chat(sid, data): """Handle widget chat messages""" db = None try: # Identity was established (and the token verified) at connect time. session = await sio.get_session(sid, namespace='/widget') widget_id, org_id, customer_id = widget_socket_identity(session) if not widget_id or not org_id: logger.error(f"No authenticated widget session for sid {sid}") await sio.emit('error', {'error': 'Authentication failed', 'type': 'auth_error'}, room=sid, namespace='/widget') return # Process message and files original_message = data.get('message', '').strip() # Sanitize message to prevent XSS attacks message = sanitize_message(original_message) files = data.get('files', []) # List of file objects with base64 content # If message was stripped to empty but had content before, it was malicious if original_message and not message.strip() and not files: logger.warning(f"Blocked malicious message from customer {customer_id}") await sio.emit('error', { 'error': 'Your message contains unsafe content and cannot be sent.', 'type': 'validation_error' }, room=sid, namespace='/widget') return if not message.strip() and not files: return session_id = session['session_id'] # Upload files if provided uploaded_files = [] if files: db_temp = next(get_db()) try: agent_repo = AgentRepository(db_temp) agent = agent_repo.get_agent(session['agent_id']) if agent and not agent.allow_attachments: await sio.emit('error', { 'error': 'Attachments are not allowed for this agent', 'type': 'validation_error' }, room=sid, namespace='/widget') return # Get allowed attachment types for this agent allowed_types = FileUploadService.get_allowed_types_for_agent( agent.allowed_attachment_types if agent else None ) friendly_types = FileUploadService.get_friendly_allowed_types_message(allowed_types) # Check if chat is handed over to human agent # Check if there's any agent message in the conversation from app.models import ChatHistory agent_message_exists = db_temp.query(ChatHistory).filter( ChatHistory.session_id == session_id, ChatHistory.message_type == 'agent' ).first() if not agent_message_exists: await sio.emit('error', { 'error': 'Attachments are only available when the chat is handed over to a human agent', 'type': 'validation_error' }, room=sid, namespace='/widget') return # Upload each file with security validation for file_data in files: try: uploaded_file = await FileUploadService.upload_file( file_data=file_data, org_id=org_id, customer_id=customer_id, allowed_types=allowed_types ) uploaded_files.append(uploaded_file) except ValueError as val_err: # Validation error logger.error(f"File validation error: {str(val_err)}") await sio.emit('error', { 'error': str(val_err), 'type': 'validation_error', 'allowed_types': friendly_types }, room=sid, namespace='/widget') return except Exception as upload_err: logger.error(f"Error uploading file: {str(upload_err)}") await sio.emit('error', { 'error': f"Failed to upload file: {file_data.get('filename', 'unknown')}", 'type': 'upload_error' }, room=sid, namespace='/widget') return finally: db_temp.close() db = next(get_db()) session_repo = SessionToAgentRepository(db) own_session = _own_session(session_repo, session, active_only=False) active_session = own_session if own_session and own_session.status in ACTIVE_STATUSES else None # Check message limit from session if HAS_ENTERPRISE and session.get('message_limit_reached') and active_session and active_session.user_id is None: logger.error(f"Message limit reached") await sio.emit('error', { 'error': 'Unable to process your message. Please contact support.', 'type': 'message_limit_exceeded' }, to=sid, namespace='/widget') return if not active_session: # Reopen this socket's own closed session - unless the visitor has # since started a newer one, which is where they should be. Only # sessions in this token's scope count: anyone else's newer session # must not be able to lock the visitor out. latest_session = session_repo.get_latest_customer_session( customer_id=customer_id, scope=session['conversation_scope'] ) if own_session else None if latest_session and latest_session.status == SessionStatus.CLOSED: # Reopen the closed session if it matches the current session ID if str(latest_session.session_id) == session_id: success = session_repo.reopen_closed_session(session_id) if success: # Refresh the session data active_session = session_repo.get_session(session_id) logger.info(f"Reopened closed session {session_id} for customer {customer_id}") else: raise ValueError("Failed to reopen closed session") else: raise ValueError("Session mismatch") else: raise ValueError("No active session found") if str(active_session.session_id) != session_id: raise ValueError("Session mismatch") # The session opens when the widget connects, so the chat only really # starts on the customer's first message — notify there, not on connect, # or merely opening the chat window would ping the team. is_first_customer_message = not ChatRepository(db).has_customer_messages(session_id) if is_first_customer_message: await notify_new_chat(db, active_session) # A human-only agent answers nothing: no model, no workflow, and no # transfer either — the chat was never with the AI, so there is nothing # to hand over. Every automated branch below is gated on this, leaving # the message to fall through to the plain store-and-relay at the end, # exactly like a chat a human already holds. notify_new_chat above has # already told the team it is waiting. # Older sessions predate the cached flag, so default to answering. ai_replies_enabled = session.get('ai_replies_enabled', True) # Check if agent uses workflow and no human agent has taken over if ai_replies_enabled and active_session.workflow_id and active_session.user_id is None: await _emit_bot_typing(session_id) # Handle workflow chat using the dedicated service workflow_chat_service = WorkflowChatService(db) response = await workflow_chat_service.handle_workflow_chat( active_session=active_session, message=message, session_id=session_id, org_id=org_id, customer_id=customer_id, session=session, sio=sio, namespace='/widget', source=session.get('source') ) # If response is None, it means a form was displayed and no regular response should be sent if response is None: return # If this is a transfer response, send it and return if response.transfer_to_human and hasattr(response, '_is_transfer_response'): await sio.emit('chat_response', { 'message': response.message, 'session_id': session_id, 'transfer_to_human': response.transfer_to_human, 'transfer_reason': response.transfer_reason.value if response.transfer_reason else None, 'transfer_description': response.transfer_description, 'end_chat': response.end_chat, 'request_rating': response.request_rating }, room=session_id, namespace='/widget') return elif ai_replies_enabled and active_session.status == SessionStatus.OPEN and active_session.user_id is None: # open and user has not taken over await _emit_bot_typing(session_id) logger.debug(f"Initializing chat agent for model {session['ai_config'].model_type}") # Initialize chat agent with async factory method for MCP tools support chat_agent = await ChatAgent.create_async( api_key=decrypt_api_key(session['ai_config'].encrypted_api_key), model_name=session['ai_config'].model_name, model_type=session['ai_config'].model_type, base_url=(session['ai_config'].settings or {}).get('base_url'), org_id=org_id, agent_id=session['agent_id'], customer_id=customer_id, session_id=session_id, source=session.get('source') ) try: # Get response from ai agent (this already handles end chat internally) response = await chat_agent.get_response( message=message, session_id=chat_agent.agent.session_id, org_id=org_id, agent_id=session['agent_id'], customer_id=customer_id) finally: await chat_agent.safe_cleanup_mcp_tools() elif active_session.status == SessionStatus.TRANSFERRED and active_session.user_id is None: # transferred and user has not taken over await _emit_bot_typing(session_id) logger.debug(f"Transferring chat to human for session {session_id}") # Get response from agent transfer ai agent chat_repo = ChatRepository(db) user_msg = chat_repo.create_message({ "message": message, "message_type": "user", "session_id": session_id, "organization_id": org_id, "agent_id": session['agent_id'], "customer_id": customer_id, }) # Attach uploaded files to the user message if uploaded_files: from app.models import FileAttachment for file_info in uploaded_files: file_attachment = FileAttachment( file_url=file_info['file_url'], filename=file_info['filename'], content_type=file_info['content_type'], file_size=file_info['size'], chat_history_id=user_msg.id, organization_id=org_id, uploaded_by_customer_id=customer_id ) db.add(file_attachment) db.commit() await notify_conversation_changed(org_id, session_id) chat_history = [] chat_history = await chat_repo.get_session_history(session_id) jira_repo = JiraRepository(db) agent_data = jira_repo.get_agent_with_jira_config(session['agent_id']) if session['agent_id'] else None if agent_data is not None and getattr(agent_data, 'groups', None): availability_response = await get_agent_availability_response( agent=agent_data, customer_id=customer_id, chat_history=chat_history, db=db, api_key=decrypt_api_key(session['ai_config'].encrypted_api_key), model_name=session['ai_config'].model_name, model_type=session['ai_config'].model_type, base_url=(session['ai_config'].settings or {}).get('base_url'), session_id=session_id ) else: # No group queue configured. The availability agent apologises # that it is "unable to transfer the chat", which is untrue # here — the chat is queued and shows in the inbox as waiting. # Only reachable now that a chat can be queued without a group: # human-only agents, and Hand to my team. availability_response = {"message": QUEUED_FOR_HUMAN_NOTICE} # Create ChatResponse object response_content = ChatResponse( message=availability_response["message"], transfer_to_human=True, transfer_reason=None, transfer_description=None, end_chat=False, request_rating=False, create_ticket=False ) # Store AI response with transfer status chat_repo.create_message({ "message": response_content.message, "message_type": "bot", "session_id": session_id, "organization_id": org_id, "agent_id": session['agent_id'], "customer_id": customer_id, "attributes": { "transfer_to_human": response_content.transfer_to_human, "transfer_reason": response_content.transfer_reason.value if response_content.transfer_reason else None, "transfer_description": response_content.transfer_description, "end_chat": response_content.end_chat, "end_chat_reason": response_content.end_chat_reason.value if response_content.end_chat_reason else None, "end_chat_description": response_content.end_chat_description, "request_rating": response_content.request_rating, "shopify_output": response_content.shopify_output } }) await notify_conversation_changed(org_id, session_id) response = response_content elif active_session.workflow_id and active_session.user_id is not None: # Workflow session but human agent has taken over - handle like regular human takeover chat_repo = ChatRepository(db) user_msg = chat_repo.create_message({ "message": message, "message_type": "user", "session_id": session_id, "organization_id": org_id, "agent_id": session['agent_id'], "customer_id": customer_id, "user_id": active_session.user_id, }) # Attach uploaded files to the user message if uploaded_files: from app.models import FileAttachment for file_info in uploaded_files: file_attachment = FileAttachment( file_url=file_info['file_url'], filename=file_info['filename'], content_type=file_info['content_type'], file_size=file_info['size'], chat_history_id=user_msg.id, organization_id=org_id, uploaded_by_customer_id=customer_id ) db.add(file_attachment) db.commit() await notify_conversation_changed(org_id, session_id) # Get session data to find assigned user session_data = session_repo.get_session(session_id) user_id = str(session_data.user_id) if session_data and session_data.user_id else None timestamp = format_datetime(datetime.datetime.now()) attachments_data = await _client_attachments(uploaded_files) if user_id: # Also emit to user-specific room user_room = f"user_{user_id}" await sio.emit('chat_reply', { 'message': message, 'message_id': user_msg.id, 'type': 'user_message', 'transfer_to_human': False, 'session_id': session_id, 'created_at': timestamp, 'attachments': attachments_data if attachments_data else None }, room=user_room, namespace='/agent') # Emit to both session room and user's personal room await sio.emit('chat_reply', { 'message': message, 'message_id': user_msg.id, 'type': 'user_message', 'transfer_to_human': False, 'session_id': session_id, 'timestamp': timestamp, 'attachments': attachments_data if attachments_data else None }, room=session_id, namespace='/agent') return # don't do anything further - human agent handles the conversation else: chat_repo = ChatRepository(db) user_msg = chat_repo.create_message({ "message": message, "message_type": "user", "session_id": session_id, "organization_id": org_id, "agent_id": session['agent_id'], "customer_id": customer_id, "user_id": active_session.user_id, }) # Attach uploaded files to the user message if uploaded_files: from app.models import FileAttachment for file_info in uploaded_files: file_attachment = FileAttachment( file_url=file_info['file_url'], filename=file_info['filename'], content_type=file_info['content_type'], file_size=file_info['size'], chat_history_id=user_msg.id, organization_id=org_id, uploaded_by_customer_id=customer_id ) db.add(file_attachment) db.commit() await notify_conversation_changed(org_id, session_id) # Get session data to find assigned user session_data = session_repo.get_session(session_id) user_id = str(session_data.user_id) if session_data and session_data.user_id else None timestamp = format_datetime(datetime.datetime.now()) attachments_data = await _client_attachments(uploaded_files) if user_id: # Also emit to user-specific room user_room = f"user_{user_id}" await sio.emit('chat_reply', { 'message': message, 'message_id': user_msg.id, 'type': 'user_message', 'transfer_to_human': False, 'session_id': session_id, 'created_at': timestamp, 'attachments': attachments_data if attachments_data else None }, room=user_room, namespace='/agent') # Emit to both session room and user's personal room await sio.emit('chat_reply', { 'message': message, 'message_id': user_msg.id, 'type': 'user_message', 'transfer_to_human': False, 'session_id': session_id, 'timestamp': timestamp, 'attachments': attachments_data if attachments_data else None }, room=session_id, namespace='/agent') # Nothing is going to answer this on its own, so acknowledge once — # otherwise the visitor sends into what looks like a dead widget. # Only on the opening message: after that the thread is visibly a # conversation with people in it. if is_first_customer_message and not ai_replies_enabled: await notify_customer(db, active_session, HUMAN_ONLY_ACK, channels_only=False) return # don't do anything if the session is closed or the user has already taken over # Only emit response if we have a response object and message if 'response' in locals() and response.message: # Emit response to the specific room response_payload = { 'message': response.message, 'type': 'chat_response', 'session_id': session_id, 'transfer_to_human': response.transfer_to_human, 'end_chat': response.end_chat, 'end_chat_reason': response.end_chat_reason.value if response.end_chat_reason else None, 'end_chat_description': response.end_chat_description, 'request_rating': response.request_rating, # Initialize shopify_output to None. It will be populated if data exists. 'shopify_output': None, # Knowledge-base citations (rendering is gated client-side by the agent's # show_citations customization setting). 'sources': [s.model_dump() for s in response.sources] if getattr(response, 'sources', None) else None } # Check if the response object has the structured shopify_output field # and assign it directly if it's valid (should be a Pydantic model or dict) if hasattr(response, 'shopify_output') and response.shopify_output: # Assuming response.shopify_output is already the correct # ShopifyOutputData model instance from ChatResponse # We need to convert it to a dict for JSON serialization try: response_payload['shopify_output'] = response.shopify_output.model_dump(exclude_unset=True) except AttributeError: # Handle cases where it might be a plain dict already (less likely but safe) if isinstance(response.shopify_output, dict): response_payload['shopify_output'] = response.shopify_output else: logger.warning(f"Unexpected type for shopify_output: {type(response.shopify_output)}") await sio.emit('chat_response', response_payload, room=session_id, namespace='/widget') # On human handoff, optionally collect the visitor's contact details (email/name) # so the human agent can follow up. Fires for both a live transfer and the # no-agents/ticket case (request_contact). Only asks for enabled + missing fields. if response.transfer_to_human or getattr(response, 'request_contact', False): try: from app.services.contact_capture import build_handoff_contact_form agent_repo = AgentRepository(db) handoff_agent = agent_repo.get_agent(session['agent_id']) customer = CustomerRepository(db).get_by_id(customer_id) if handoff_agent and customer: contact_form = build_handoff_contact_form( customer, collect_email=getattr(handoff_agent, 'handoff_collect_email', True), collect_name=getattr(handoff_agent, 'handoff_collect_name', True), ) if contact_form: await sio.emit('display_form', { 'form_data': contact_form, 'session_id': session_id, }, room=session_id, namespace='/widget') except Exception as contact_err: logger.error(f"Failed to emit handoff contact form: {contact_err}") # Lead capture: the agent collected the details conversationally and set # request_lead_capture (like transfer_to_human). Persist the structured # lead_data + AI summary — no form. Skipped on transfer/handoff (the elif # above takes precedence), but NOT on end_chat: the model naturally captures # and signs off in the same turn, and this runs before the close block below. # record_lead_capture enforces valid-email + consent and captures once. elif getattr(response, 'request_lead_capture', False): lc_resp = record_lead_from_response( db, response, organization_id=org_id, agent_id=session['agent_id'], customer_id=customer_id, session_id=session_id, page_url=session.get('page_url'), scope=session.get('conversation_scope'), ) # If the capture merged this anonymous visitor into an existing # customer (email already known), repoint the live socket session # so the rest of this conversation writes to the merged customer. # Isolated so a session-store failure can't skip the end_chat # close block below. if lc_resp is not None and str(lc_resp.customer_id) != str(customer_id): try: session['customer_id'] = str(lc_resp.customer_id) await sio.save_session(sid, session, namespace='/widget') logger.info( f"Lead capture merged customer {customer_id} -> {lc_resp.customer_id}") except Exception as merge_err: logger.error(f"Failed to repoint socket session after merge: {merge_err}") # If end_chat is true, close the session if response.end_chat: try: session_repo = SessionToAgentRepository(db) # Close the session with end_chat reason and description session_repo.close_session( session_id=session_id, reason=response.end_chat_reason.value if response.end_chat_reason else None, description=response.end_chat_description ) logger.info(f"Session {session_id} closed due to end_chat with reason: {response.end_chat_reason}") except Exception as close_error: logger.error(f"Error closing session {session_id}: {str(close_error)}") except Exception as e: logger.error(f"Widget chat error for sid {sid}: {str(e)}") logger.error(traceback.format_exc()) await sio.emit('error', { 'error': 'Unable to process your request, please try again later.', 'type': 'chat_error' }, to=sid, namespace='/widget') finally: if db: db.close() @sio.on('get_chat_history', namespace='/widget') @socket_rate_limit(namespace='/widget') async def get_widget_chat_history(sid): db = None try: logger.info(f"Getting chat history for sid {sid}") # Get session data session = await sio.get_session(sid, namespace='/widget') widget_id, org_id, customer_id = widget_socket_identity(session) if not widget_id or not org_id: logger.error(f"No authenticated widget session for sid {sid}") await sio.emit('error', {'error': 'Authentication failed', 'type': 'auth_error'}, room=sid, namespace='/widget') return db = next(get_db()) # History of this socket's own conversation only session_repo = SessionToAgentRepository(db) active_session = _own_session(session_repo, session) if not active_session: logger.info("No active session found, returning empty history") await sio.emit('chat_history', { 'messages': [], 'type': 'chat_history' }, to=sid, namespace='/widget') return # Get chat history for active session chat_repo = ChatRepository(db) messages = await chat_repo.get_session_history( session_id=active_session.session_id ) # Convert datetime to ISO format string formatted_messages = [] for msg in messages: msg_dict = { 'message': msg.message, 'message_type': msg.message_type, 'timestamp': format_datetime(msg.created_at), 'attributes': msg.attributes, 'user_name': msg.user.full_name if msg.user else None, 'agent_name': msg.agent.display_name or msg.agent.name if msg.agent else None } # Add attachments with file info if they exist if msg.attachments: attachments = [] for attachment in msg.attachments: att_dict = { 'id': attachment.id, 'filename': attachment.filename, 'file_url': attachment.file_url, # signed by get_session_history 'content_type': attachment.content_type, 'file_size': attachment.file_size } attachments.append(att_dict) msg_dict['attachments'] = attachments formatted_messages.append(msg_dict) await sio.emit('chat_history', { 'messages': formatted_messages, 'type': 'chat_history' }, to=sid, namespace='/widget') except ValueError as e: logger.error(f"Widget authentication error for sid {sid}: {str(e)}") await sio.emit('error', { 'error': 'Authentication failed', 'type': 'auth_error' }, to=sid, namespace='/widget') except Exception as e: logger.error(f"Error getting chat history for sid {sid}: {str(e)}") logger.error(traceback.format_exc()) await sio.emit('error', { 'error': 'Failed to get chat history', 'type': 'chat_history_error' }, to=sid, namespace='/widget') finally: if db: db.close() @sio.on('refresh_token', namespace='/widget') async def handle_refresh_token(sid, data): """Adopt the rotated conversation token for an already-connected socket. The socket session holds the token the connection was authenticated with, and that is what a reconnect and any later reader see. Keeping it current means the stored token matches the one the page actually holds, rather than an expiring one nobody can use again. """ try: session = await sio.get_session(sid, namespace='/widget') if not session: return token = (data or {}).get('conversation_token') if isinstance(data, dict) else None state = conversation_token_service.inspect(token, session.get('widget_id')) if not state.is_live: logger.warning(f"Rejected token refresh for sid {sid}: token is not valid") return # A refresh may only extend the connection it already has - never move it to # another customer, which would hand this socket someone else's conversation. if str(state.payload.get('sub')) != str(session.get('customer_id')): logger.warning(f"Rejected token refresh for sid {sid}: customer mismatch") return # Nor to another scope: the socket's conversations were chosen by it. try: same_scope = conversation_token_service.conversation_scope(state.payload) == session.get('conversation_scope') except ValueError: same_scope = False if not same_scope: logger.warning(f"Rejected token refresh for sid {sid}: conversation scope mismatch") return session['conversation_token'] = token await sio.save_session(sid, session, namespace='/widget') logger.debug(f"Conversation token refreshed for sid {sid}") except Exception as e: logger.error(f"Error refreshing token for sid {sid}: {str(e)}") @sio.on('end_chat', namespace='/widget') async def handle_end_chat(sid, data): """Handle end chat event from widget client""" try: logger.info(f"End chat requested for sid {sid}") # Get session data session = await sio.get_session(sid, namespace='/widget') if not session: logger.error(f"No session found for sid {sid}") return # Identity was established (and the token verified) at connect time. Closing # the widget must not depend on the token still being live — re-checking it # here is what made "Authentication failed" appear on close (#315). widget_id, org_id, customer_id = widget_socket_identity(session) if not widget_id or not org_id: logger.error(f"No authenticated widget session for sid {sid}") await sio.emit('error', {'error': 'Authentication failed', 'type': 'auth_error'}, room=sid, namespace='/widget') return # Get session id and optional end reason/description session_id = session.get('session_id') reason = data.get('reason') if isinstance(data, dict) else None description = data.get('description') if isinstance(data, dict) else None if not session_id: logger.error(f"No session_id in session data for sid {sid}") return db = next(get_db()) try: # Close the session immediately session_repo = SessionToAgentRepository(db) closed = session_repo.close_session( session_id=session_id, reason=reason, description=description ) # close_session swallows its own errors and returns False (an invalid # end-chat reason does exactly that). Confirming regardless would tell the # client the chat ended while the session stayed open, and the next # connect would hand back the same conversation. if not closed: logger.error(f"Session {session_id} was not closed (reason={reason})") await sio.emit('error', { 'error': 'Failed to end chat session', 'type': 'end_chat_error' }, to=sid, namespace='/widget') return logger.info(f"Session {session_id} closed by client with reason: {reason}") # Confirm to client that chat has ended await sio.emit('chat_ended', { 'session_id': session_id, 'message': 'Chat session closed' }, room=sid, namespace='/widget') except Exception as close_error: logger.error(f"Error closing session {session_id}: {str(close_error)}") await sio.emit('error', { 'error': 'Failed to end chat session', 'type': 'end_chat_error' }, to=sid, namespace='/widget') finally: db.close() except Exception as e: logger.error(f"Error in end_chat handler for sid {sid}: {str(e)}") logger.error(traceback.format_exc()) await sio.emit('error', { 'error': 'Failed to process end chat request', 'type': 'end_chat_error' }, to=sid, namespace='/widget') # Add connection handler for agent namespace @sio.on('connect', namespace='/agent') async def agent_connect(sid, environ, auth): try: access_token, user_id, org_id = await authenticate_socket(sid, environ) if not access_token: raise ValueError("Authentication failed") logger.info(f"Authenticated connection for user { user_id} org {org_id}") # Store session data session_data = { 'user_id': user_id, 'organization_id': org_id } await sio.save_session(sid, session_data, namespace='/agent') return True except Exception as e: logger.error(f"Agent connection error for sid {sid}: {str(e)}") return False # Add new socket event handler for agent messages @sio.on('agent_message', namespace='/agent') async def handle_agent_message(sid, data): db = None try: session = await sio.get_session(sid, namespace='/agent') session_id = data['session_id'] logger.info(f"Session ID: {session_id}") if not session_id: raise ValueError("No active session") db = next(get_db()) session_repo = SessionToAgentRepository(db) chat_repo = ChatRepository(db) # Verify session and agent permissions session_data = session_repo.get_session(session_id) if not session_data or str(session_data.user_id) != session.get('user_id'): raise ValueError("Unauthorized") # Upload files if provided files = data.get('files', []) uploaded_files = [] if files: org_id = session.get('organization_id') user_id = session.get('user_id') # Get agent and allowed attachment types agent_repo = AgentRepository(db) agent = agent_repo.get_agent(session_data.agent_id) if session_data.agent_id else None allowed_types = FileUploadService.get_allowed_types_for_agent( agent.allowed_attachment_types if agent else None ) for file_data in files: try: uploaded_file = await FileUploadService.upload_file( file_data=file_data, org_id=org_id, user_id=user_id, allowed_types=allowed_types ) uploaded_files.append(uploaded_file) except ValueError as val_err: logger.error(f"File validation error: {str(val_err)}") await sio.emit('error', { 'error': str(val_err), 'type': 'validation_error' }, to=sid, namespace='/agent') return except Exception as upload_err: logger.error(f"Error uploading file: {str(upload_err)}") await sio.emit('error', { 'error': f"Failed to upload file: {file_data.get('filename', 'unknown')}", 'type': 'upload_error' }, to=sid, namespace='/agent') return # Store the agent's message original_agent_message = data.get('message', '') # Sanitize message to prevent XSS attacks agent_message = sanitize_message(original_agent_message) # If message was stripped to empty but had content before, it was malicious if original_agent_message and not agent_message.strip(): logger.warning(f"Blocked malicious message from agent {session.get('user_id')}") await sio.emit('error', { 'error': 'Your message contains unsafe content and cannot be sent.', 'type': 'validation_error' }, to=sid, namespace='/agent') return message_data = { "message": agent_message, "message_type": data.get('message_type', "agent"), "session_id": session_id, "organization_id": session.get('organization_id'), "agent_id": session_data.agent_id if session_data.agent_id else None, "customer_id": session_data.customer_id, "user_id": session_data.user_id, "attributes":{ "end_chat": data.get('end_chat', False), "request_rating": data.get('request_rating', False), "end_chat_reason": data.get('end_chat_reason', None), "end_chat_description": data.get('end_chat_description', None), "shopify_output": data.get('shopify_output', None) } } created_message = chat_repo.create_message(message_data) # Tell the sender which row their message became, before delivery, so # the inbox can match delivery receipts and errors to it exactly. client_id = data.get('client_id') if not (isinstance(client_id, str) and len(client_id) <= MAX_CLIENT_ID_LENGTH): client_id = None # only ever echoed back, but never echo junk await sio.emit('agent_message_stored', { 'session_id': session_id, 'client_id': client_id, 'message_id': created_message.id, }, to=sid, namespace='/agent') # Attach uploaded files to the message if uploaded_files: from app.models import FileAttachment for file_info in uploaded_files: file_attachment = FileAttachment( file_url=file_info['file_url'], filename=file_info['filename'], content_type=file_info['content_type'], file_size=file_info['size'], chat_history_id=created_message.id, organization_id=session.get('organization_id'), uploaded_by_user_id=session.get('user_id') ) db.add(file_attachment) db.commit() logger.info(f"Attached {len(uploaded_files)} files to agent message {created_message.id}") # Check if this is an end chat message if data.get('end_chat') is True: logger.info(f"Agent ended chat session {session_id}") # Update session status to closed session_repo.update_session_status(session_id, "CLOSED") await notify_conversation_changed(session.get('organization_id'), session_id) # Emit to widget clients response_payload = { 'message': data['message'], 'type': 'agent_message', 'message_type': data.get('message_type', 'agent'), 'end_chat': data.get('end_chat', False), 'request_rating': data.get('request_rating', False), 'end_chat_reason': data.get('end_chat_reason', None), 'end_chat_description': data.get('end_chat_description', None), 'shopify_output': data.get('shopify_output', False) } # Add individual product fields if shopify_output is true if response_payload['shopify_output']: response_payload.update({ 'product_id': data.get('product_id'), 'product_title': data.get('product_title'), 'product_description': data.get('product_description'), 'product_handle': data.get('product_handle'), 'product_inventory': data.get('product_inventory'), 'product_price': data.get('product_price'), 'product_currency': data.get('product_currency'), 'product_image': data.get('product_image'), }) # Add attachments to response if files were uploaded if uploaded_files: response_payload['attachments'] = await _client_attachments(uploaded_files) response_payload['message_id'] = created_message.id delivery = await deliver_to_customer(db, session_data, response_payload, message_id=created_message.id, source=MessageSource.HUMAN) if not delivery.ok: if delivery.reason == 'window_expired': error_message = ( "The customer's messaging window has expired — " "send an approved template message to re-open the conversation." if delivery.can_template else "The customer's messaging window has expired; this message could not be delivered." ) else: error_message = 'Message saved but could not be delivered to the customer.' chat_repo.mark_delivery_failed(created_message.id, delivery.reason) await sio.emit('error', { 'error': error_message, 'type': 'delivery_error', 'session_id': session_id, 'client_id': client_id, 'message_id': created_message.id, # Lets the inbox offer the template action only when sending one # would actually reopen the conversation. 'can_template': delivery.can_template, }, to=sid, namespace='/agent') except Exception as e: logger.error(f"Agent message error for sid {sid}: {str(e)}") logger.error(traceback.format_exc()) await sio.emit('error', { 'error': 'Failed to send message', 'type': 'message_error' }, to=sid, namespace='/agent') finally: if db: db.close() # Add handlers for room management @sio.on('join_room', namespace='/agent') async def handle_join_room(sid, data): db = None try: session = await sio.get_session(sid, namespace='/agent') session_id = data.get('session_id') if not session_id: raise ValueError("No session ID provided") # Handle user-specific rooms if session_id.startswith('user_'): user_id = session_id.split('_')[1] if str(session.get('user_id')) != user_id: raise ValueError("Unauthorized to join user room") await sio.enter_room(sid, session_id, namespace='/agent') logger.info(f"Agent {user_id} joined their user room") return # Handle per-org ticket rooms (live ticket_update events) if session_id.startswith('org_tickets_'): org_id = session_id.removeprefix('org_tickets_') if str(session.get('organization_id')) != org_id: raise ValueError("Unauthorized to join org ticket room") await sio.enter_room(sid, session_id, namespace='/agent') logger.info(f"Agent {session.get('user_id')} joined ticket room for org {org_id}") return # Per-org inbox room (live delivery receipts on any of the org's # chats) — for the people who can see chats nobody owns if is_org_inbox_room(session_id): db = next(get_db()) if not may_join_org_inbox(db, session, session_id): raise ValueError("Unauthorized to join org inbox room") await sio.enter_room(sid, session_id, namespace='/agent') return # These rooms carry invalidations only, never message content. A # watcher must satisfy the same scope as the chat-detail endpoint. if session_id.startswith(ORG_CHAT_UPDATES_PREFIX): db = next(get_db()) if not may_join_chat_updates(db, session, session_id): raise ValueError("Unauthorized to join chat updates room") await sio.enter_room(sid, session_id, namespace='/agent') return if session_id.startswith(CHAT_WATCH_PREFIX): db = next(get_db()) if not await may_watch_chat(db, session, session_id): raise ValueError("Unauthorized to watch chat") await sio.enter_room(sid, session_id, namespace='/agent') return # Verify this agent has permission to join this room db = next(get_db()) session_repo = SessionToAgentRepository(db) session_data = session_repo.get_session(session_id) if not session_data: raise ValueError("Invalid session") if str(session_data.user_id) != str(session.get('user_id')): raise ValueError("Unauthorized to join this room") # Join the room await sio.enter_room(sid, session_id, namespace='/agent') logger.info(f"Agent {session.get('user_id')} joined room: {session_id}") # Notify room of join await sio.emit('room_event', { 'type': 'join', 'user_id': str(session.get('user_id')), }, room=session_id, namespace='/agent') # Merge into the existing socket session — replacing it would drop # keys set at connect (organization_id, used by the org ticket room # authorization) and silently break later joins. session_data = {**session, 'session_id': session_id} await sio.save_session(sid, session_data, namespace='/agent') except Exception as e: logger.error(f"Error joining room for sid {sid}: {str(e)}") await sio.emit('error', { 'error': 'Failed to join room', 'type': 'room_error' }, to=sid, namespace='/agent') finally: if db: db.close() @sio.on('leave_room', namespace='/agent') async def handle_leave_room(sid, data): try: session = await sio.get_session(sid, namespace='/agent') if not session: raise ValueError("No active session") session_id = data.get('session_id') if not session_id: raise ValueError("No session ID provided") # Leave the room await sio.leave_room(sid, session_id, namespace='/agent') logger.info(f"Agent {session.get('user_id')} left room: {session_id}") # Notify a conversation room of the leave — not the org inbox room, # where the whole team would hear every chat switch if not (is_org_inbox_room(session_id) or session_id.startswith( (ORG_CHAT_UPDATES_PREFIX, CHAT_WATCH_PREFIX) )): await sio.emit('room_event', { 'type': 'leave', 'user_id': str(session.get('user_id')), }, room=session_id, namespace='/agent') except Exception as e: logger.error(f"Error leaving room for sid {sid}: {str(e)}") await sio.emit('error', { 'error': 'Failed to leave room', 'type': 'room_error' }, to=sid, namespace='/agent') @sio.on('taken_over', namespace='/agent') async def handle_taken_over(sid, data): logger.info(f"Agent {data['user_name']} has taken over chat {data['session_id']}") await sio.emit('handle_taken_over', data, room=data['session_id'] , namespace='/widget') @sio.on('submit_rating', namespace='/widget') @socket_rate_limit(namespace='/widget') async def handle_rating_submission(sid, data): """Handle rating submission from widget""" db = None try: # Identity was established (and the token verified) at connect time. session = await sio.get_session(sid, namespace='/widget') widget_id, org_id, customer_id = widget_socket_identity(session) if not widget_id or not org_id: logger.error(f"No authenticated widget session for sid {sid}") await sio.emit('error', {'error': 'Authentication failed', 'type': 'auth_error'}, room=sid, namespace='/widget') return session_id = session['session_id'] # Validate rating data rating = data.get('rating') feedback = data.get('feedback') if not rating or not isinstance(rating, int) or rating < 1 or rating > 5: raise ValueError("Invalid rating value") if feedback is not None: if not isinstance(feedback, str): raise ValueError("Invalid feedback value") # Same treatment as an inbound chat message: the text is untrusted # and ends up in the agent UI and the ticket activity feed. feedback = sanitize_message(feedback.strip()[:MAX_RATING_FEEDBACK_LENGTH]) or None db = next(get_db()) # Get session details session_repo = SessionToAgentRepository(db) session_data = session_repo.get_session(session_id) if not session_data: raise ValueError("Session not found") # One rating per session — re-rating replaces the previous score. rating_repo = RatingRepository(db) rating_obj = rating_repo.upsert_rating( session_id=session_id, customer_id=customer_id, user_id=session_data.user_id, agent_id=session_data.agent_id, organization_id=org_id, rating=rating, feedback=feedback ) # Close the CSAT loop if this conversation belongs to a ticket. record_conversation_rating(db, rating_obj) # Emit success response await sio.emit('rating_submitted', { 'success': True, 'message': 'Rating submitted successfully' }, room=sid, namespace='/widget') # Also emit to agent room if there's an assigned agent if session_data.user_id: user_room = f"user_{session_data.user_id}" await sio.emit('rating_received', { 'session_id': session_id, 'rating': rating, 'feedback': feedback }, room=user_room, namespace='/agent') except ValueError as e: logger.error(f"Rating submission error for sid {sid}: {str(e)}") await sio.emit('error', { 'error': str(e), 'type': 'rating_error' }, to=sid, namespace='/widget') except Exception as e: logger.error(f"Rating submission error for sid {sid}: {str(e)}") logger.error(traceback.format_exc()) await sio.emit('error', { 'error': 'Failed to submit rating', 'type': 'rating_error' }, to=sid, namespace='/widget') finally: if db: db.close() @sio.on('get_workflow_state', namespace='/widget') @socket_rate_limit(namespace='/widget') async def handle_get_workflow_state(sid): """Get current workflow state and execute next node if no chat history""" db = None try: logger.info(f"Getting workflow state for sid {sid}") # Identity was established (and the token verified) at connect time. session = await sio.get_session(sid, namespace='/widget') widget_id, org_id, customer_id = widget_socket_identity(session) if not widget_id or not org_id: logger.error(f"No authenticated widget session for sid {sid}") await sio.emit('error', {'error': 'Authentication failed', 'type': 'auth_error'}, room=sid, namespace='/widget') return session_id = session['session_id'] db = next(get_db()) session_repo = SessionToAgentRepository(db) chat_repo = ChatRepository(db) active_session = _own_session(session_repo, session) if not active_session: raise ValueError("No active session found") # Check if there's any chat history chat_history = await chat_repo.get_session_history(session_id) has_history = len(chat_history) > 0 # If agent uses workflow, handle workflow state if active_session.workflow_id: current_node_id = active_session.current_node_id # If no current node and no history - start from beginning by executing workflow if not current_node_id and not has_history: logger.info(f"Starting workflow from beginning for session {session_id}") workflow_service = WorkflowExecutionService(db) # Execute workflow to get the starting node workflow_result = await workflow_service.execute_workflow( session_id=session_id, user_message=None, # No user message for initial execution workflow_id=active_session.workflow_id, current_node_id=None, # Start from beginning workflow_state=active_session.workflow_state or {}, api_key=decrypt_api_key(session['ai_config'].encrypted_api_key), model_name=session['ai_config'].model_name, model_type=session['ai_config'].model_type, base_url=(session['ai_config'].settings or {}).get('base_url'), org_id=org_id, agent_id=session['agent_id'], customer_id=customer_id, is_initial_execution=True, source=session.get('source') ) if workflow_result.success: logger.debug(f"Initial workflow result: {workflow_result}") # Handle intermediate messages from MESSAGE nodes during automatic execution if workflow_result.intermediate_messages: logger.info(f"Found {len(workflow_result.intermediate_messages)} intermediate messages from MESSAGE nodes in initial execution") for intermediate_message in workflow_result.intermediate_messages: logger.debug(f"Emitting intermediate message: {intermediate_message}") # Store intermediate message in chat history chat_repo.create_message({ "message": intermediate_message, "message_type": "bot", "session_id": session_id, "organization_id": org_id, "agent_id": session['agent_id'], "customer_id": customer_id, "attributes": { "workflow_execution": True, "workflow_id": str(active_session.workflow_id), "intermediate_message": True, "message_node": True, "initial_execution": True } }) await notify_conversation_changed(org_id, session_id) # Emit intermediate message to client immediately await sio.emit('chat_response', { 'message': intermediate_message, 'type': 'chat_response', 'transfer_to_human': False, 'end_chat': False, 'request_rating': False, 'shopify_output': None }, room=session_id, namespace='/widget') # Handle different node types if workflow_result.form_data: await sio.emit('workflow_state', { 'type': 'form', 'form_data': workflow_result.form_data, 'session_id': session_id, 'has_history': has_history, 'button_text': 'Start Chat' }, room=sid, namespace='/widget') elif workflow_result.landing_page_data: await sio.emit('workflow_state', { 'type': 'landing_page', 'landing_page_data': workflow_result.landing_page_data, 'session_id': session_id, 'has_history': has_history, 'button_text': 'Start Chat' }, room=sid, namespace='/widget') elif workflow_result.message: # Store initial message and emit chat_repo.create_message({ "message": workflow_result.message, "message_type": "bot", "session_id": session_id, "organization_id": org_id, "agent_id": session['agent_id'], "customer_id": customer_id, "attributes": { "workflow_execution": True, "workflow_id": str(active_session.workflow_id), "initial_message": True } }) await notify_conversation_changed(org_id, session_id) # The widget does not render a workflow_state message, so # send the text as a chat_response like the other handlers. await sio.emit('chat_response', { 'message': workflow_result.message, 'type': 'chat_response', 'transfer_to_human': workflow_result.transfer_to_human, 'end_chat': workflow_result.end_chat, 'request_rating': workflow_result.request_rating, 'shopify_output': None }, room=session_id, namespace='/widget') await sio.emit('workflow_state', { 'type': 'message', 'message': workflow_result.message, 'session_id': session_id, 'has_history': has_history, 'button_text': 'Continue Conversation' }, room=sid, namespace='/widget') else: await sio.emit('workflow_state', { 'type': 'ready', 'session_id': session_id, 'has_history': has_history, 'button_text': 'Start Chat' }, room=sid, namespace='/widget') else: await sio.emit('workflow_state', { 'type': 'error', 'error': workflow_result.error, 'session_id': session_id, 'has_history': has_history, 'button_text': 'Start Chat' }, room=sid, namespace='/widget') else: # We have a current node - fetch node details and emit them directly logger.info(f"Fetching details for current node {current_node_id} in session {session_id}") try: from app.repositories.workflow import WorkflowRepository workflow_repo = WorkflowRepository(db) workflow = workflow_repo.get_workflow_with_nodes_and_connections(active_session.workflow_id) if workflow: current_node = None for node in workflow.nodes: if node.id == current_node_id: current_node = node break if current_node: logger.debug(f"Found current node: {current_node.node_type}") if current_node.node_type.value == 'form': # Emit form node details config = current_node.config or {} form_data = { "title": config.get("form_title", ""), "description": config.get("form_description", ""), "submit_button_text": config.get("submit_button_text", "Submit"), "fields": config.get("form_fields", []), "form_full_screen": config.get("form_full_screen", False) } await sio.emit('workflow_state', { 'type': 'form', 'form_data': form_data, 'session_id': session_id, 'has_history': has_history, 'button_text': 'Continue Conversation' }, room=sid, namespace='/widget') elif current_node.node_type.value == 'landing_page': # Emit landing page node details config = current_node.config or {} landing_page_data = { "heading": config.get("landing_page_heading", "Welcome"), "content": config.get("landing_page_content", "Thank you for visiting!") } await sio.emit('workflow_state', { 'type': 'landing_page', 'landing_page_data': landing_page_data, 'session_id': session_id, 'has_history': has_history, 'button_text': 'Continue Conversation' }, room=sid, namespace='/widget') else: # For other node types, just emit ready state await sio.emit('workflow_state', { 'type': 'ready', 'session_id': session_id, 'has_history': has_history, 'button_text': 'Continue Conversation' }, room=sid, namespace='/widget') else: logger.warning(f"Current node {current_node_id} not found in workflow") await sio.emit('workflow_state', { 'type': 'ready', 'session_id': session_id, 'has_history': has_history, 'button_text': 'Continue Conversation' }, room=sid, namespace='/widget') else: logger.error(f"Workflow {active_session.workflow_id} not found") await sio.emit('workflow_state', { 'type': 'error', 'error': 'Workflow not found', 'session_id': session_id, 'has_history': has_history, 'button_text': 'Continue Conversation' }, room=sid, namespace='/widget') except Exception as e: logger.error(f"Error fetching node details: {str(e)}") await sio.emit('workflow_state', { 'type': 'error', 'error': 'Failed to get workflow state', 'session_id': session_id, 'has_history': has_history, 'button_text': 'Continue Conversation' }, room=sid, namespace='/widget') else: # No workflow or has history - just return current state await sio.emit('workflow_state', { 'type': 'ready', 'session_id': session_id, 'has_history': has_history, 'button_text': 'Start Chat' if not has_history else 'Continue Conversation' }, room=sid, namespace='/widget') except Exception as e: logger.error(f"Error getting workflow state for sid {sid}: {str(e)}") logger.error(traceback.format_exc()) await sio.emit('error', { 'error': 'Failed to get workflow state', 'type': 'workflow_error' }, to=sid, namespace='/widget') finally: if db: db.close() @sio.on('proceed_workflow', namespace='/widget') @socket_rate_limit(namespace='/widget') async def handle_proceed_workflow(sid, data): """Proceed to next workflow node after landing page interaction""" db = None try: logger.info(f"Proceeding workflow for sid {sid}") # Identity was established (and the token verified) at connect time. session = await sio.get_session(sid, namespace='/widget') widget_id, org_id, customer_id = widget_socket_identity(session) if not widget_id or not org_id: logger.error(f"No authenticated widget session for sid {sid}") await sio.emit('error', {'error': 'Authentication failed', 'type': 'auth_error'}, room=sid, namespace='/widget') return session_id = session['session_id'] db = next(get_db()) session_repo = SessionToAgentRepository(db) chat_repo = ChatRepository(db) active_session = _own_session(session_repo, session) if not active_session or not active_session.workflow_id: raise ValueError("No active workflow session found") # Simply update to next node and execute it workflow_service = WorkflowExecutionService(db) # Get current node and find next node from app.repositories.workflow import WorkflowRepository workflow_repo = WorkflowRepository(db) workflow = workflow_repo.get_workflow_with_nodes_and_connections(active_session.workflow_id) if not workflow: raise ValueError("Workflow not found") # Find current node current_node = None for node in workflow.nodes: if node.id == active_session.current_node_id: current_node = node break if not current_node: raise ValueError("Current node not found") # Find next node next_node_id = None for connection in current_node.outgoing_connections: next_node_id = connection.target_node_id break if not next_node_id: await sio.emit('workflow_proceeded', {'success': True, 'message': 'End of workflow'}, room=sid, namespace='/widget') return logger.debug(f"Next node ID: {next_node_id}") logger.debug(f"Active session: {active_session}") logger.debug(f"Workflow state: {active_session.workflow_state}") # Update session with next node session_repo.update_workflow_state(session_id, next_node_id, active_session.workflow_state or {}) # Execute the next workflow node workflow_result = await workflow_service.execute_workflow( session_id=session_id, user_message=None, workflow_id=active_session.workflow_id, current_node_id=next_node_id, workflow_state=active_session.workflow_state or {}, org_id=org_id, agent_id=session['agent_id'], customer_id=customer_id, api_key=decrypt_api_key(session['ai_config'].encrypted_api_key), model_name=session['ai_config'].model_name, model_type=session['ai_config'].model_type, base_url=(session['ai_config'].settings or {}).get('base_url'), source=session.get('source') ) if workflow_result.success: logger.debug(f"Workflow result: {workflow_result}") # Handle intermediate messages from MESSAGE nodes during automatic execution if workflow_result.intermediate_messages: logger.info(f"Found {len(workflow_result.intermediate_messages)} intermediate messages from MESSAGE nodes") for intermediate_message in workflow_result.intermediate_messages: logger.debug(f"Emitting intermediate message: {intermediate_message}") # Store intermediate message in chat history chat_repo.create_message({ "message": intermediate_message, "message_type": "bot", "session_id": session_id, "organization_id": org_id, "agent_id": session['agent_id'], "customer_id": customer_id, "attributes": { "workflow_execution": True, "workflow_id": str(active_session.workflow_id), "intermediate_message": True, "message_node": True } }) await notify_conversation_changed(org_id, session_id) # Emit intermediate message to client immediately await sio.emit('chat_response', { 'message': intermediate_message, 'type': 'chat_response', 'transfer_to_human': False, 'end_chat': False, 'request_rating': False, 'shopify_output': None }, room=session_id, namespace='/widget') # Handle the next node type if workflow_result.form_data: # It's a form node - emit form display await sio.emit('display_form', { 'form_data': workflow_result.form_data, 'session_id': session_id }, room=session_id, namespace='/widget') elif workflow_result.message: # Regular message node - store and emit chat_repo.create_message({ "message": workflow_result.message, "message_type": "bot", "session_id": session_id, "organization_id": org_id, "agent_id": session['agent_id'], "customer_id": customer_id, "attributes": { "workflow_execution": True, "workflow_id": str(active_session.workflow_id), "current_node_id": str(workflow_result.next_node_id) if workflow_result.next_node_id else None, "transfer_to_human": workflow_result.transfer_to_human, "end_chat": workflow_result.end_chat, "request_rating": workflow_result.request_rating, } }) await notify_conversation_changed(org_id, session_id) # Emit chat response await sio.emit('chat_response', { 'message': workflow_result.message, 'type': 'chat_response', 'transfer_to_human': workflow_result.transfer_to_human, 'end_chat': workflow_result.end_chat, 'request_rating': workflow_result.request_rating, 'shopify_output': None }, room=session_id, namespace='/widget') # Emit success await sio.emit('workflow_proceeded', { 'success': True }, room=sid, namespace='/widget') else: # Handle error await sio.emit('error', { 'error': workflow_result.error or 'Failed to proceed workflow', 'type': 'workflow_error' }, to=sid, namespace='/widget') except Exception as e: logger.error(f"Error proceeding workflow for sid {sid}: {str(e)}") logger.error(traceback.format_exc()) await sio.emit('error', { 'error': 'Failed to proceed workflow', 'type': 'workflow_error' }, to=sid, namespace='/widget') finally: if db: db.close() async def _confirm_contact(session_id: str, message: str) -> None: """Tell the visitor their contact details were received, as a bot message.""" await sio.emit('chat_response', { 'message': message, 'type': 'chat_response', 'session_id': session_id, 'transfer_to_human': False, 'end_chat': False, }, room=session_id, namespace='/widget') @sio.on('submit_contact_info', namespace='/widget') @socket_rate_limit(namespace='/widget') async def handle_contact_info(sid, data): """Handle the inline contact form shown at human handoff (email + optional name). Updates the customer record so the human agent can follow up. Non-blocking: the handoff already happened, so failures here never undo the transfer. """ db = None try: session = await sio.get_session(sid, namespace='/widget') widget_id, org_id, customer_id = widget_socket_identity(session) if not widget_id or not org_id: logger.error(f"No authenticated widget session for sid {sid}") await sio.emit('error', {'error': 'Authentication failed', 'type': 'auth_error'}, room=sid, namespace='/widget') return session_id = session['session_id'] form_data = data.get('form_data', {}) or {} email = (form_data.get('email') or '').strip() name = (form_data.get('name') or '').strip() # Validate email server-side too (the field is required client-side when shown) if email and not validate_email(email): await sio.emit('error', {'error': 'Please enter a valid email address', 'type': 'validation_error'}, room=session_id, namespace='/widget') return if not email and not name: return # nothing to update db = next(get_db()) customer_repo = CustomerRepository(db) # A visitor bound to a known customer by an email they only typed must # not rename that person, nor leave an address on their record for the # team to follow up at. if not can_act_as(db, customer_repo.get_by_id(customer_id), session.get('conversation_scope')): logger.info(f"Contact details from sid {sid} not stored: the visitor does not own customer {customer_id}") # Silence would read as the form having failed. The team can still # follow up here, in the conversation they see in the inbox. await _confirm_contact(session_id, "Thanks — a teammate will follow up here.") return result = customer_repo.update_contact(customer_id, email=email or None, full_name=name or None) # The customer row can refuse the address — another customer in the org # already owns it (a visitor returning anonymously, or a shared team # inbox), or it is a channel identity key. Keep it on meta_data so the # agent can still follow up; otherwise the visitor asked to be contacted # and nothing anywhere records how. email_retained = retain_unstored_email(customer_repo, customer_id, email, result) # Confirm back to the visitor as a bot message. Retaining counts: from # where they sit they gave us a good address either way, and silence # after submitting the form reads as the form having failed. if result.get('email_updated') or result.get('name_updated') or email_retained: confirm = "Thanks — a teammate will follow up" updated_email = result.get('email') or '' if result.get('email_updated') and updated_email and not CustomerRepository.is_placeholder_email(updated_email): confirm += f" at {updated_email}" elif email_retained: confirm += f" at {email}" confirm += "." await _confirm_contact(session_id, confirm) except Exception as e: logger.error(f"Error handling contact info for sid {sid}: {str(e)}") finally: if db: db.close() @sio.on('submit_form', namespace='/widget') @socket_rate_limit(namespace='/widget') async def handle_form_submission(sid, data): """Handle form submission from widget""" db = None try: logger.info(f"Submitting form for sid {sid}") # Identity was established (and the token verified) at connect time. session = await sio.get_session(sid, namespace='/widget') widget_id, org_id, customer_id = widget_socket_identity(session) if not widget_id or not org_id: logger.error(f"No authenticated widget session for sid {sid}") await sio.emit('error', {'error': 'Authentication failed', 'type': 'auth_error'}, room=sid, namespace='/widget') return session_id = session['session_id'] # Validate form data form_data = data.get('form_data', {}) if not form_data: logger.warning(f"Form submission rejected for sid {sid}: no form data") raise ValueError("No form data provided") db = next(get_db()) session_repo = SessionToAgentRepository(db) active_session = _own_session(session_repo, session) if not active_session or not active_session.workflow_id: logger.warning(f"Form submission rejected for sid {sid}: no active workflow session") raise ValueError("No active workflow session found") # Get the current form configuration for validation from app.repositories.workflow import WorkflowRepository workflow_repo = WorkflowRepository(db) workflow = workflow_repo.get_workflow_with_nodes_and_connections(active_session.workflow_id) if not workflow: logger.warning(f"Form submission rejected for sid {sid}: workflow not found") raise ValueError("Workflow not found") # Find the current form node to get field configurations current_node = None for node in workflow.nodes: if node.id == active_session.current_node_id: current_node = node break if current_node and current_node.node_type.value == 'form': # Get form fields configuration from config JSON config = current_node.config or {} form_fields = config.get("form_fields", []) # Validate form data against field configurations validation_errors = validate_form_data(form_fields, form_data) if validation_errors: error_message = "; ".join(validation_errors) logger.error(f"Form validation failed for sid {sid}: {error_message}") await sio.emit('error', { 'error': error_message, 'type': 'validation_error' }, to=sid, namespace='/widget') return # Submit form through workflow service workflow_service = WorkflowExecutionService(db) logger.info(f"Submitting form for sid {sid}") workflow_result = await workflow_service.submit_form( session_id=session_id, form_data=form_data, workflow_id=active_session.workflow_id, org_id=org_id, agent_id=session['agent_id'], customer_id=customer_id, api_key=decrypt_api_key(session['ai_config'].encrypted_api_key), model_name=session['ai_config'].model_name, model_type=session['ai_config'].model_type, base_url=(session['ai_config'].settings or {}).get('base_url'), source=session.get('source') ) if workflow_result.success: # Check if form contains email and update customer if they have @noemail.com email if 'email' in form_data and form_data['email']: submitted_email = form_data['email'].strip() if submitted_email: # Only process if email is not empty customer_repo = CustomerRepository(db) customer = customer_repo.get_by_id(customer_id) if customer and CustomerRepository.is_placeholder_email(customer.email) \ and can_act_as(db, customer, session.get('conversation_scope')): # Customer has a generated placeholder email, update it with the real one try: # Check if the new email already exists for another customer in the same organization existing_customer = customer_repo.get_customer_by_email(submitted_email, org_id) if not existing_customer: # Update customer email customer.email = submitted_email db.commit() logger.info(f"Updated email for customer {customer_id} in session {session_id}") else: logger.warning(f"Email already belongs to customer {existing_customer.id}; skipping update for customer {customer_id}") except Exception as e: logger.error(f"Failed to update email for customer {customer_id}: {type(e).__name__}") db.rollback() # Store form submission in chat history chat_repo = ChatRepository(db) # Handle intermediate messages from MESSAGE nodes during automatic execution if workflow_result.intermediate_messages: logger.info(f"Found {len(workflow_result.intermediate_messages)} intermediate messages from MESSAGE nodes") for intermediate_message in workflow_result.intermediate_messages: # Store intermediate message in chat history chat_repo.create_message({ "message": intermediate_message, "message_type": "bot", "session_id": session_id, "organization_id": org_id, "agent_id": session['agent_id'], "customer_id": customer_id, "attributes": { "workflow_execution": True, "workflow_id": str(active_session.workflow_id), "intermediate_message": True, "message_node": True } }) await notify_conversation_changed(org_id, session_id) # Emit intermediate message to client immediately await sio.emit('chat_response', { 'message': intermediate_message, 'type': 'chat_response', 'transfer_to_human': False, 'end_chat': False, 'request_rating': False, 'shopify_output': None }, room=session_id, namespace='/widget') # Check if there's a response message to send if workflow_result.message: # Store workflow response chat_repo.create_message({ "message": workflow_result.message, "message_type": "bot", "session_id": session_id, "organization_id": org_id, "agent_id": session['agent_id'], "customer_id": customer_id, "attributes": { "workflow_execution": True, "workflow_id": str(active_session.workflow_id), "current_node_id": str(workflow_result.next_node_id) if workflow_result.next_node_id else None, "transfer_to_human": workflow_result.transfer_to_human, "end_chat": workflow_result.end_chat, "request_rating": workflow_result.request_rating, } }) await notify_conversation_changed(org_id, session_id) # Emit chat response await sio.emit('chat_response', { 'message': workflow_result.message, 'type': 'chat_response', 'transfer_to_human': workflow_result.transfer_to_human, 'end_chat': workflow_result.end_chat, 'request_rating': workflow_result.request_rating, 'shopify_output': None }, room=session_id, namespace='/widget') # Emit form submission success await sio.emit('form_submitted', { 'success': True, 'message': 'Form submitted successfully' }, room=sid, namespace='/widget') else: # Handle form submission error await sio.emit('error', { 'error': workflow_result.error or 'Form submission failed', 'type': 'form_error' }, to=sid, namespace='/widget') except ValueError as e: logger.error(f"Form submission error for sid {sid}: {type(e).__name__}") await sio.emit('error', { 'error': str(e), 'type': 'form_error' }, to=sid, namespace='/widget') except Exception as e: logger.error(f"Form submission error for sid {sid}: {type(e).__name__}") await sio.emit('error', { 'error': 'Failed to submit form', 'type': 'form_error' }, to=sid, namespace='/widget') finally: if db: db.close()