""" 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. """ from pydantic import BaseModel, Field, field_serializer, field_validator from typing import Optional, Dict, List from typing_extensions import Annotated from uuid import UUID import enum from app.core.s3 import sign_s3_url, strip_s3_signature # Cap launcher/welcome copy so it can't balloon the widget (the launcher nudge is # also clamped to 4 lines client-side). Limits match the frontend input maxlengths # so the UI never lets a user type something the API would reject. InitiationMessage = Annotated[str, Field(max_length=100)] class ChatStyle(str, enum.Enum): CHATBOT = "CHATBOT" ASK_ANYTHING = "ASK_ANYTHING" # Premium design presets GLASS = "GLASS" TERMINAL = "TERMINAL" PLAYFUL = "PLAYFUL" CALM_MINT = "CALM_MINT" AURORA = "AURORA" SUNRISE = "SUNRISE" class WidgetPosition(str, enum.Enum): FLOATING = "FLOATING" FIXED = "FIXED" class WidgetDisplayMode(str, enum.Enum): FLOATING = "floating" SIDEBAR_LEFT = "sidebar-left" SIDEBAR_RIGHT = "sidebar-right" SEARCH_BAR = "search-bar" class WidgetDisplaySide(str, enum.Enum): LEFT = "left" RIGHT = "right" class WidgetDisplayConfig(BaseModel): """Dashboard "Widget placement" defaults, stored in customization_metadata. The embed loader (replypier.js) merges these under any options the installing developer set on the page. Bounds keep saved values renderable on any reasonable screen; the loader clamps again client-side. """ mode: Optional[WidgetDisplayMode] = None side: Optional[WidgetDisplaySide] = None launcher: Optional[bool] = None width: Optional[int] = Field(default=None, ge=280, le=800) height: Optional[int] = Field(default=None, ge=400, le=900) sidebar_width: Optional[int] = Field(default=None, ge=320, le=640) search_placeholder: Optional[str] = Field(default=None, max_length=80) offset_bottom: Optional[int] = Field(default=None, ge=0, le=200) offset_side: Optional[int] = Field(default=None, ge=0, le=200) z_index: Optional[int] = Field(default=None, ge=1, le=2147483647) # Predefined chat initiation messages DEFAULT_CHAT_INITIATIONS = [ "👋 Hi! Need help? Ask me anything!", "💬 Have a question? I'm here to help!", "🤝 Welcome! How can I assist you today?", "✨ Got questions? Let's chat!", "👨‍💼 Need support? Click to chat with us!" ] # Predefined quick-action buttons (clicking sends the label as a message) DEFAULT_QUICK_ACTIONS = [ "Track my order", "Start a return", "Talk to a human" ] class CustomizationBase(BaseModel): photo_url: Optional[str] = None @field_validator('photo_url') @classmethod def _strip_photo_url_signature(cls, v: Optional[str]) -> Optional[str]: """Keep the bare S3 URL as the stored value. CustomizationResponse serializes photo_url signed, and the agent customization form POSTs the value it was given straight back, so without this the expiring signature would be written to the column. """ return strip_s3_signature(v) chat_background_color: Optional[str] = "#F8F9FA" chat_bubble_color: Optional[str] = "#E9ECEF" chat_text_color: Optional[str] = "#212529" icon_url: Optional[str] = None icon_color: Optional[str] = "#6C757D" accent_color: Optional[str] = "#f34611" font_family: Optional[str] = "Inter, system-ui, sans-serif" custom_css: Optional[str] = None customization_metadata: Optional[Dict] = {} chat_style: Optional[ChatStyle] = ChatStyle.CHATBOT widget_position: Optional[WidgetPosition] = WidgetPosition.FLOATING # NOTE: length caps live on CustomizationCreate (input), NOT here — the response # model also inherits this base, and existing rows may exceed a newly-lowered cap; # enforcing on the response would 500 the whole agent list. welcome_title: Optional[str] = None welcome_subtitle: Optional[str] = None welcome_message: Optional[str] = None chat_initiation_messages: Optional[List[str]] = None quick_actions: Optional[List[str]] = None show_citations: Optional[bool] = False collect_email: Optional[bool] = False show_ai_disclaimer: Optional[bool] = True allow_new_chat: Optional[bool] = False class CustomizationCreate(CustomizationBase): # Enforce length caps on write only, so the UI can't submit values the widget # can't display. Reads (CustomizationResponse) intentionally stay unconstrained. welcome_title: Optional[str] = Field(default=None, max_length=100) welcome_subtitle: Optional[str] = Field(default=None, max_length=250) welcome_message: Optional[str] = Field(default=None, max_length=500) chat_initiation_messages: Optional[List[InitiationMessage]] = None @field_validator('customization_metadata') @classmethod def _validate_widget_display(cls, v: Optional[Dict]) -> Optional[Dict]: """Validate and normalize the structured widget_display key on write. The rest of customization_metadata stays free-form (avatar_style etc.); widget_display is consumed by the embed loader on customer pages, so bad values must be rejected here. Unknown keys inside it are dropped and unset keys aren't stored. An explicit null clears the settings. """ if not v or v.get('widget_display') is None: return v validated = WidgetDisplayConfig.model_validate(v['widget_display']) # mode='json' turns enums into plain strings — the dict goes straight into # a JSON column, so it must hold JSON primitives, not enum members. v['widget_display'] = validated.model_dump(mode='json', exclude_none=True) return v class CustomizationResponse(CustomizationBase): id: int agent_id: UUID @field_serializer('photo_url') def _sign_photo_url(self, v: Optional[str]) -> Optional[str]: """Sign on the way out, every response. Signing is a local HMAC (~0.05ms), so there is nothing to gain by caching the result in the database.""" return sign_s3_url(v) if v else v class Config: from_attributes = True