703 lines
31 KiB
Python
703 lines
31 KiB
Python
"""
|
|
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 fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response, Header, Query, status
|
|
from fastapi.responses import HTMLResponse
|
|
from sqlalchemy.orm import Session
|
|
from typing import List, Optional
|
|
from uuid import UUID
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
import json
|
|
from urllib.parse import urlparse
|
|
|
|
from app.models.agent import Agent
|
|
from app.models.customer import Customer
|
|
from app.models.widget import Widget
|
|
|
|
from app.core.auth import get_current_user
|
|
from app.database import get_db
|
|
from app.repositories.widget import WidgetRepository
|
|
from app.repositories.agent import AgentRepository
|
|
from app.core.security import create_conversation_token, verify_conversation_token
|
|
from app.services import conversation_token
|
|
from app.utils.client_ip import forwarded_client_ip
|
|
from app.repositories.customer import CustomerRepository
|
|
from app.models.schemas.widget import WidgetCreate, WidgetResponse
|
|
from app.core.logger import get_logger
|
|
from app.repositories.session_to_agent import SessionToAgentRepository, session_in_scope
|
|
from app.services.contact_capture import CONTACT_EMAIL_META_KEY
|
|
from app.services.widget_identity import may_stand_for
|
|
from app.models.session_to_agent import SessionStatus
|
|
from app.core.config import settings
|
|
from app.core.s3 import get_s3_signed_url
|
|
from app.utils.business_hours import is_within_business_hours
|
|
|
|
router = APIRouter()
|
|
logger = get_logger(__name__)
|
|
|
|
# The enterprise module can record where each widget is live; without it,
|
|
# nothing is recorded.
|
|
try:
|
|
from app.enterprise.services import widget_install
|
|
except ImportError:
|
|
widget_install = None
|
|
|
|
|
|
def _identity_expired() -> HTTPException:
|
|
"""401 telling the widget its visitor's identity lapsed, not that it never had one.
|
|
|
|
The embed loader answers this by asking the host page for a fresh token
|
|
(`tokenProvider`) and retrying, so an identified visitor is never silently
|
|
re-created as an anonymous customer.
|
|
"""
|
|
return HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail={
|
|
"code": conversation_token.IDENTITY_EXPIRED_CODE,
|
|
"message": "Conversation token expired. Issue a new one with /generate-token.",
|
|
},
|
|
)
|
|
|
|
|
|
_ASSETS_DIR = Path(settings.WIDGET_ASSETS_DIR)
|
|
|
|
|
|
def _asset_version() -> str:
|
|
"""Cache-buster for the widget bundle URLs.
|
|
|
|
/assets/widget.js is served without a Cache-Control header and its URL never
|
|
changes, so browsers fall back to heuristic caching and can keep serving an old
|
|
bundle for hours after a release — the widget silently runs last week's code.
|
|
Stamping the build's mtime+size onto the URL makes a new build a new URL.
|
|
Recomputed per request (a stat is microseconds) so a rebuild takes effect
|
|
without restarting the backend.
|
|
"""
|
|
try:
|
|
stat = (_ASSETS_DIR / "widget.js").stat()
|
|
return f"{int(stat.st_mtime)}-{stat.st_size}"
|
|
except OSError:
|
|
# Assets served from somewhere else (CDN, dev server) — nothing to stamp.
|
|
return ""
|
|
|
|
|
|
def _widget_html_response(html: str) -> HTMLResponse:
|
|
"""The widget HTML embeds a conversation token and the current customization,
|
|
and points at a versioned bundle URL. Caching it would hand a visitor someone
|
|
else's token from a shared cache, and would keep pinning them to a stale widget
|
|
build after a release."""
|
|
return HTMLResponse(html, headers={"Cache-Control": "no-store"})
|
|
|
|
|
|
# BACKEND_URL defaults to http://localhost:8000 and neither compose file sets
|
|
# it, so a self-hosted install that never overrode it used to serve every visitor
|
|
# a widget whose socket dialled the visitor's own machine (#315). Loopback can
|
|
# never be right for a browser on someone else's computer, so we fall back to the
|
|
# origin the widget HTML was actually fetched over.
|
|
_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"})
|
|
|
|
_warned_about_loopback_backend_url = False
|
|
|
|
|
|
def _is_loopback(url: str) -> bool:
|
|
host = urlparse(url).hostname
|
|
return host is not None and host.lower() in _LOOPBACK_HOSTS
|
|
|
|
|
|
def _public_origin(request: Optional[Request]) -> Optional[str]:
|
|
"""Scheme and host this request reached us on, as the browser sees them.
|
|
|
|
Reads the forwarding headers our nginx sets rather than ``request.url``, which
|
|
only reflects them when the ASGI server is started with proxy headers enabled.
|
|
"""
|
|
if request is None:
|
|
return None
|
|
|
|
# Several proxies in a chain append to these, and the browser's own value is
|
|
# the first entry.
|
|
forwarded_host = request.headers.get("x-forwarded-host")
|
|
host = (forwarded_host or request.headers.get("host") or "").split(",")[0].strip()
|
|
if not host:
|
|
return None
|
|
|
|
forwarded_proto = request.headers.get("x-forwarded-proto")
|
|
if forwarded_proto:
|
|
scheme = forwarded_proto.split(",")[0].strip()
|
|
elif forwarded_host:
|
|
# A proxy that rewrites the host but not the protocol (Traefik and some
|
|
# Caddy setups, unlike our nginx) leaves request.url.scheme reading "http"
|
|
# for the proxy's own hop. Trusting that would hand an https page a ws://
|
|
# socket the browser refuses as mixed content, so assume the hop was TLS.
|
|
scheme = "https"
|
|
else:
|
|
scheme = request.url.scheme or "https"
|
|
|
|
return f"{scheme}://{host}"
|
|
|
|
|
|
def _widget_runtime_config(request: Optional[Request] = None) -> dict:
|
|
"""Runtime API/WS URLs to hand the widget iframe as ``window.APP_CONFIG``.
|
|
|
|
Derived from ``BACKEND_URL`` so a self-hosted widget talks to the configured
|
|
backend instead of the vendor cloud. On the hosted deployment BACKEND_URL is
|
|
``https://api.replypier.site``, which yields exactly the widget's baked-in
|
|
defaults, so injecting this is a no-op there.
|
|
"""
|
|
global _warned_about_loopback_backend_url
|
|
|
|
api_base = settings.BACKEND_URL.rstrip("/")
|
|
if _is_loopback(api_base):
|
|
derived = _public_origin(request)
|
|
if derived and not _is_loopback(derived):
|
|
if not _warned_about_loopback_backend_url:
|
|
_warned_about_loopback_backend_url = True
|
|
logger.warning(
|
|
"BACKEND_URL is %s, which no visitor's browser can reach. "
|
|
"Serving widgets against %s instead, taken from the request. "
|
|
"Set BACKEND_URL to this install's public API URL.",
|
|
api_base, derived,
|
|
)
|
|
api_base = derived
|
|
|
|
if api_base.startswith("https://"):
|
|
ws_url = "wss://" + api_base[len("https://"):]
|
|
elif api_base.startswith("http://"):
|
|
ws_url = "ws://" + api_base[len("http://"):]
|
|
else:
|
|
ws_url = api_base
|
|
return {"API_URL": f"{api_base}/api/v1", "WS_URL": ws_url}
|
|
|
|
@router.post("", response_model=WidgetResponse)
|
|
def create_new_widget(
|
|
widget: WidgetCreate,
|
|
db: Session = Depends(get_db),
|
|
current_user=Depends(get_current_user)
|
|
):
|
|
"""Create a new widget for the organization"""
|
|
widget_repo = WidgetRepository(db)
|
|
return widget_repo.create_widget(widget, current_user.organization_id)
|
|
|
|
|
|
def _note_install(db: Session, widget: Widget, request: Request, background_tasks: BackgroundTasks) -> None:
|
|
"""Note where the widget is live (or blocked), after the response is
|
|
sent. Never lets a tracking error break the widget itself."""
|
|
if widget_install is None:
|
|
return
|
|
try:
|
|
sighting = widget_install.claim_write(db, widget, request.headers.get("origin"), forwarded_client_ip(request))
|
|
if sighting:
|
|
background_tasks.add_task(widget_install.record, widget.id, sighting)
|
|
except Exception:
|
|
logger.exception(f"Install tracking failed for widget {widget.id}")
|
|
|
|
|
|
@router.get("/{widget_id}/data", response_class=HTMLResponse)
|
|
async def get_widget_ui(
|
|
widget_id: str,
|
|
request: Request,
|
|
response: Response,
|
|
background_tasks: BackgroundTasks,
|
|
authorization: Optional[str] = Header(None),
|
|
source: Optional[str] = Query(None),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get widget UI and handle customer authentication"""
|
|
|
|
widget = db.query(Widget).filter(Widget.id == widget_id).first()
|
|
if not widget:
|
|
raise HTTPException(status_code=404, detail="Widget not found")
|
|
|
|
_note_install(db, widget, request, background_tasks)
|
|
|
|
# Get agent data
|
|
agent_repo = AgentRepository(db)
|
|
agent = agent_repo.get_by_id(widget.agent_id)
|
|
if not agent:
|
|
raise HTTPException(status_code=404, detail="Agent not found")
|
|
|
|
# What the header may honestly claim. Only a human-handled agent needs the
|
|
# business-hours check: while the AI answers, it answers at 3am too.
|
|
presence = {
|
|
'mode': 'ai' if agent.ai_replies_enabled else 'human',
|
|
'available': True if agent.ai_replies_enabled
|
|
else is_within_business_hours(agent.organization),
|
|
}
|
|
|
|
require_token_auth = getattr(agent, 'require_token_auth', False)
|
|
customer_id = None
|
|
token = None
|
|
token_payload = None
|
|
|
|
# Try to validate existing token if provided
|
|
if authorization and authorization.startswith('Bearer '):
|
|
token = authorization.split(' ')[1]
|
|
state = conversation_token.inspect(token, widget_id)
|
|
if state.is_live:
|
|
token_payload = state.payload
|
|
customer_id = state.payload.get("sub")
|
|
elif state.identity_lost:
|
|
# A lapsed token that named a customer means a signed-in visitor whose
|
|
# identity just ran out. Say so instead of rendering the frame as a
|
|
# brand-new anonymous visitor - the loader re-identifies and retries.
|
|
raise _identity_expired()
|
|
else:
|
|
token = None # Invalid token
|
|
|
|
# SECURITY: If token auth is required, valid token MUST be provided
|
|
if require_token_auth:
|
|
if not token or customer_id is None:
|
|
logger.warning(f"Widget UI request denied: require_token_auth=true but no valid token for widget_id={widget_id}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Authentication required. Token must be obtained from /api/v1/generate-token endpoint with valid API key."
|
|
)
|
|
# Has valid token - return widget with existing token
|
|
return _widget_html_response(await get_widget_html(
|
|
widget_id=widget_id,
|
|
agent_name=agent.display_name or agent.name,
|
|
agent_customization=agent.customization,
|
|
customer_id=customer_id,
|
|
initial_token=token,
|
|
agent_workflow=bool(agent.use_workflow and agent.active_workflow_id),
|
|
allow_attachments=agent.allow_attachments,
|
|
ai_replies_enabled=agent.ai_replies_enabled,
|
|
presence=presence,
|
|
request=request
|
|
))
|
|
|
|
# Token auth NOT required - allow anonymous access
|
|
# If we have a valid token, use it; otherwise create a new one
|
|
if token and customer_id:
|
|
return _widget_html_response(await get_widget_html(
|
|
widget_id=widget_id,
|
|
agent_name=agent.display_name or agent.name,
|
|
agent_customization=agent.customization,
|
|
customer_id=customer_id,
|
|
initial_token=token,
|
|
agent_workflow=bool(agent.use_workflow and agent.active_workflow_id),
|
|
allow_attachments=agent.allow_attachments,
|
|
ai_replies_enabled=agent.ai_replies_enabled,
|
|
presence=presence,
|
|
request=request
|
|
))
|
|
|
|
# No valid token - create new one for anonymous access. A live token that
|
|
# names no customer yet keeps its conversation key.
|
|
token = _mint_widget_token(widget_id, _conversation_key(token_payload), source=source)
|
|
|
|
return _widget_html_response(await get_widget_html(
|
|
widget_id=widget_id,
|
|
agent_name=agent.display_name or agent.name,
|
|
agent_customization=agent.customization,
|
|
customer_id=customer_id,
|
|
initial_token=token,
|
|
agent_workflow=bool(agent.use_workflow and agent.active_workflow_id),
|
|
allow_attachments=agent.allow_attachments,
|
|
ai_replies_enabled=agent.ai_replies_enabled,
|
|
presence=presence,
|
|
request=request
|
|
))
|
|
|
|
async def get_widget_html(widget_id: str, agent_name: str, agent_customization: dict, customer_id: Optional[str] = None, initial_token: Optional[str] = None, agent_workflow: bool = False, allow_attachments: bool = False, ai_replies_enabled: bool = True,
|
|
presence: Optional[dict] = None, request: Optional[Request] = None) -> str:
|
|
"""Generate widget HTML with embedded data"""
|
|
import html
|
|
widget_url = settings.VITE_WIDGET_URL
|
|
version = _asset_version()
|
|
asset_query = f"?v={version}" if version else ""
|
|
|
|
# Convert AgentCustomization to dict if it's a model instance
|
|
customization_dict = {}
|
|
if agent_customization:
|
|
# Get signed URL for photo if using S3
|
|
photo_url = agent_customization.photo_url
|
|
if settings.S3_FILE_STORAGE and photo_url:
|
|
photo_url = await get_s3_signed_url(photo_url)
|
|
|
|
customization_dict = {
|
|
"chat_background_color": agent_customization.chat_background_color,
|
|
"chat_text_color": agent_customization.chat_text_color,
|
|
"chat_bubble_color": agent_customization.chat_bubble_color,
|
|
"accent_color": agent_customization.accent_color,
|
|
"font_family": agent_customization.font_family,
|
|
"photo_url": photo_url,
|
|
"chat_style": agent_customization.chat_style.value if agent_customization.chat_style else "CHATBOT",
|
|
"widget_position": agent_customization.widget_position.value if agent_customization.widget_position else "FLOATING",
|
|
"welcome_title": agent_customization.welcome_title,
|
|
"welcome_subtitle": agent_customization.welcome_subtitle,
|
|
"welcome_message": agent_customization.welcome_message,
|
|
"chat_initiation_messages": agent_customization.chat_initiation_messages or [],
|
|
"quick_actions": agent_customization.quick_actions or [],
|
|
"show_citations": agent_customization.show_citations,
|
|
"collect_email": agent_customization.collect_email,
|
|
# A human-only agent never produces an AI reply, so disclosing one
|
|
# would be untrue — same reason the widget hides it after takeover.
|
|
"show_ai_disclaimer": agent_customization.show_ai_disclaimer and ai_replies_enabled,
|
|
"allow_new_chat": agent_customization.allow_new_chat,
|
|
"customization_metadata": agent_customization.customization_metadata or {}
|
|
}
|
|
|
|
|
|
return f"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Chat Widget</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
<script>
|
|
// Runtime backend config so the widget connects to THIS install's
|
|
// backend instead of falling back to the baked cloud default.
|
|
// Declared before the widget module loads.
|
|
window.APP_CONFIG = {json.dumps(_widget_runtime_config(request))};
|
|
</script>
|
|
<script type="module" crossorigin src="{widget_url}/assets/widget.js{asset_query}"></script>
|
|
<link rel="stylesheet" crossorigin href="{widget_url}/assets/widget.css{asset_query}">
|
|
<script>
|
|
window.__INITIAL_DATA__ = {{
|
|
widgetId: "{html.escape(widget_id)}",
|
|
agentName: "{html.escape(agent_name)}",
|
|
customization: {json.dumps(customization_dict)},
|
|
customerId: "{html.escape(customer_id or '')}",
|
|
initialToken: "{html.escape(initial_token or '')}",
|
|
customer: {{}},
|
|
workflow: {str(agent_workflow).lower()},
|
|
allowAttachments: {str(allow_attachments).lower()},
|
|
presence: {json.dumps(presence or {'mode': 'ai', 'available': True})}
|
|
}};
|
|
</script>
|
|
</head>
|
|
<body>
|
|
<div id="app"></div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
async def get_human_agent_session_info(db: Session, customer_id: str, scope: str) -> dict:
|
|
"""Human agent assigned to the customer's open conversation this token may reach"""
|
|
human_agent_info = {}
|
|
session_repo = SessionToAgentRepository(db)
|
|
sessions = session_repo.get_customer_sessions(customer_id, SessionStatus.OPEN, scope=scope)
|
|
|
|
if sessions and len(sessions) > 0:
|
|
session_model,user_full_name, user_profile_pic = sessions[0]
|
|
|
|
if user_full_name: # If there's a human agent assigned
|
|
# HumanAgentResponse signs human_agent_profile_pic on serialization.
|
|
|
|
# Get human agent info from session
|
|
human_agent_info = {
|
|
"human_agent_name": user_full_name,
|
|
"human_agent_profile_pic": user_profile_pic,
|
|
}
|
|
|
|
return human_agent_info
|
|
|
|
|
|
def _conversation_key(token_payload: Optional[dict]) -> str:
|
|
"""Keep the browser's conversation key across re-mints, or start one.
|
|
|
|
Never an account key: only our backend mints those, for one signed-in
|
|
dashboard account, and every token the widget issues is unverified.
|
|
"""
|
|
key = (token_payload or {}).get(conversation_token.CONVERSATION_KEY_CLAIM)
|
|
if key and not conversation_token.is_account_key(key):
|
|
return key
|
|
return conversation_token.new_conversation_key()
|
|
|
|
|
|
def _mint_widget_token(widget_id: str, conversation_key: str, customer_id: Optional[str] = None,
|
|
source: Optional[str] = None, token_payload: Optional[dict] = None) -> str:
|
|
"""A conversation token the widget issues itself - unverified, so always scoped.
|
|
|
|
Prefers a fresh `source` (first-visit Explore flow) over one the visitor's
|
|
earlier token carried.
|
|
"""
|
|
extra_data = {conversation_token.CONVERSATION_KEY_CLAIM: conversation_key}
|
|
source = source or (token_payload or {}).get("source")
|
|
if widget_id == settings.EXPLORE_WIDGET_ID and source:
|
|
extra_data["source"] = source
|
|
return create_conversation_token(widget_id=widget_id, customer_id=customer_id, **extra_data)
|
|
|
|
|
|
def _anonymous_customer(customer_repo: CustomerRepository, widget: Widget, conversation_key: str,
|
|
claimed_email: Optional[str] = None) -> Customer:
|
|
"""A customer of the visitor's own. An address they gave that belongs to
|
|
someone they can't stand for is kept where the inbox shows it."""
|
|
meta_data = {CONTACT_EMAIL_META_KEY: claimed_email} if claimed_email else None
|
|
return customer_repo.create_customer(
|
|
CustomerRepository.anonymous_email(), widget.organization_id,
|
|
meta_data=meta_data, conversation_key=conversation_key)
|
|
|
|
|
|
def _widget_data_response(widget: Widget, agent: Agent, customer_id: Optional[str], human_agent_info: dict,
|
|
token: Optional[str] = None) -> dict:
|
|
"""GET /widgets/{id}'s answer; `token` only when the visitor needs a new one."""
|
|
# photo_url is signed by AgentCustomizationResponse on serialization.
|
|
data = {
|
|
"id": widget.id,
|
|
"organization_id": widget.organization_id,
|
|
"customer_id": customer_id,
|
|
"human_agent": human_agent_info,
|
|
"agent": {
|
|
"id": agent.id,
|
|
"name": agent.name,
|
|
"display_name": agent.display_name,
|
|
"customization": agent.customization,
|
|
"workflow": bool(agent.use_workflow and agent.active_workflow_id),
|
|
"allow_attachments": agent.allow_attachments
|
|
}
|
|
}
|
|
if token:
|
|
data["token"] = token
|
|
return data
|
|
|
|
|
|
@router.get("/{widget_id}", response_model=WidgetResponse)
|
|
async def get_widget_data(
|
|
widget_id: str,
|
|
response: Response,
|
|
email: Optional[str] = None,
|
|
source: Optional[str] = Query(None),
|
|
authorization: Optional[str] = Header(None),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get widget data including agent customization"""
|
|
logger.info(f"Getting widget data for widget_id {widget_id}, email {email}, has_token {bool(authorization)}")
|
|
# Binds this visitor to a customer: a stored copy would skip that and leave
|
|
# the widget holding the page's anonymous token.
|
|
response.headers["Cache-Control"] = "no-store"
|
|
|
|
widget = db.query(Widget).filter(Widget.id == widget_id).first()
|
|
if not widget:
|
|
raise HTTPException(status_code=404, detail="Widget not found")
|
|
|
|
agent_repo = AgentRepository(db)
|
|
agent = agent_repo.get_by_id(widget.agent_id)
|
|
if not agent:
|
|
raise HTTPException(status_code=404, detail="Agent not found")
|
|
|
|
require_token_auth = getattr(agent, 'require_token_auth', False)
|
|
customer_id = None
|
|
token = None
|
|
token_payload = None
|
|
scope = None
|
|
|
|
# Try to validate existing token if provided
|
|
if authorization and authorization.startswith('Bearer '):
|
|
token = authorization.split(' ')[1]
|
|
state = conversation_token.inspect(token, widget_id)
|
|
if state.is_live:
|
|
try:
|
|
scope = conversation_token.conversation_scope(state.payload)
|
|
token_payload = state.payload
|
|
customer_id = state.payload.get("sub")
|
|
except ValueError:
|
|
token = None
|
|
elif state.identity_lost:
|
|
# Identity ran out rather than never existing: minting a fresh anonymous
|
|
# customer here is what used to turn a signed-in visitor into "Anonymous"
|
|
# in the inbox the moment they started a new chat.
|
|
raise _identity_expired()
|
|
else:
|
|
token = None
|
|
|
|
# SECURITY: If token auth required, must have valid token with customer_id
|
|
if require_token_auth:
|
|
if not token or customer_id is None:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized - Token required")
|
|
|
|
# Check if agent has workflow enabled
|
|
agent_has_workflow = bool(agent.use_workflow and agent.active_workflow_id)
|
|
|
|
# Check if agent has ASK_ANYTHING chat style
|
|
is_ask_anything_style = (agent.customization and
|
|
agent.customization.chat_style and
|
|
agent.customization.chat_style.value == "ASK_ANYTHING")
|
|
|
|
# Email is optional unless the agent explicitly opts into collecting it before chat.
|
|
# When not collecting email we bypass the gate (anonymous customer + token), like
|
|
# ASK_ANYTHING / workflow agents already do. NOTE: this runs AFTER the require_token_auth
|
|
# 401 check above, so token-protected widgets are unaffected.
|
|
email_bypass = not bool(agent.customization and getattr(agent.customization, 'collect_email', False))
|
|
|
|
customer_repo = CustomerRepository(db)
|
|
conversation_key = _conversation_key(token_payload)
|
|
# Judged on the customer the socket will act as - the merge survivor - or
|
|
# the widget would be told yes here and refused on every connect.
|
|
customer = customer_repo.resolve_merged(customer_id) if customer_id not in (None, "None") else None
|
|
|
|
if customer is not None:
|
|
# Once the embedding app signs this person in, a token the widget issued
|
|
# (from an email someone typed) may no longer name them - whoever claimed
|
|
# the address first would otherwise keep a token for the signed-in user.
|
|
# Hand that browser a customer of its own and keep the address it gave.
|
|
if not may_stand_for(customer, scope):
|
|
logger.info(f"Widget {widget_id}: unverified token names a signed-in customer; starting an anonymous one")
|
|
anonymous = _anonymous_customer(customer_repo, widget, conversation_key, claimed_email=customer.email)
|
|
new_token = _mint_widget_token(widget_id, conversation_key, customer_id=str(anonymous.id),
|
|
source=source, token_payload=token_payload)
|
|
return _widget_data_response(widget, agent, str(anonymous.id), {}, new_token)
|
|
|
|
logger.debug(f"Using existing token for customer_id: {customer_id}")
|
|
human_agent_info = await get_human_agent_session_info(db, str(customer.id), scope)
|
|
return _widget_data_response(widget, agent, customer_id, human_agent_info)
|
|
|
|
# A token-protected widget only ever runs on tokens its site issued: one whose
|
|
# customer is gone is no reason to issue a widget token of our own.
|
|
if require_token_auth:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized - Token required")
|
|
|
|
# No customer yet - or the token's one no longer exists, which would otherwise
|
|
# refuse every connect while this answer kept the token. For workflow agents,
|
|
# ASK_ANYTHING style, or email-optional agents, create one with a blank email.
|
|
# Only email-collecting regular agents require email up front.
|
|
if not (email or agent_has_workflow or is_ask_anything_style or email_bypass):
|
|
# Regular agent without email/customer_id - return 401
|
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
|
|
# The email is only what the visitor typed. It may still name the existing
|
|
# customer - one person in the inbox - because the token minted below is
|
|
# scoped to this browser's conversations, not to theirs. Never a person the
|
|
# embedding app signed in, though: only its own token may stand for them.
|
|
existing = customer_repo.get_customer_by_email(email, widget.organization_id) if email else None
|
|
if existing is not None and not existing.is_authenticated:
|
|
customer = existing
|
|
elif existing is not None:
|
|
customer = _anonymous_customer(customer_repo, widget, conversation_key, claimed_email=email)
|
|
elif email:
|
|
customer = customer_repo.create_customer(email, widget.organization_id, conversation_key=conversation_key)
|
|
else:
|
|
customer = _anonymous_customer(customer_repo, widget, conversation_key)
|
|
|
|
human_agent_info = await get_human_agent_session_info(db, str(customer.id), conversation_key)
|
|
new_token = _mint_widget_token(widget_id, conversation_key, customer_id=str(customer.id),
|
|
source=source, token_payload=token_payload)
|
|
return _widget_data_response(widget, agent, str(customer.id), human_agent_info, new_token)
|
|
|
|
|
|
@router.post("/{widget_id}/end-chat")
|
|
async def end_chat_acknowledgment(
|
|
widget_id: str,
|
|
session_id: str,
|
|
reason: Optional[str] = None,
|
|
description: Optional[str] = None,
|
|
authorization: Optional[str] = Header(None),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Acknowledge end_chat event from widget and close session on backend"""
|
|
try:
|
|
logger.info(f"End chat request received: widget_id={widget_id}, session_id={session_id}, reason={reason}, has_token={bool(authorization)}")
|
|
|
|
# A conversation token is REQUIRED. This used to validate the token only
|
|
# when one was sent, so a request with no Authorization header fell
|
|
# straight through and closed any session by id, unauthenticated.
|
|
if not authorization or not authorization.startswith('Bearer '):
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
|
|
|
try:
|
|
token_data = verify_conversation_token(authorization.split(' ')[1])
|
|
except Exception:
|
|
token_data = None
|
|
if not token_data:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
|
|
|
customer_id = token_data.get("sub")
|
|
if token_data.get("widget_id") != widget_id:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Widget mismatch")
|
|
try:
|
|
scope = conversation_token.conversation_scope(token_data)
|
|
except ValueError:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
|
|
|
# The token proves who the caller is, not that this session is theirs —
|
|
# without this any visitor could close another customer's conversation,
|
|
# or another browser's conversation of a customer it only claimed to be.
|
|
session_repo = SessionToAgentRepository(db)
|
|
session = session_repo.get_session(session_id)
|
|
if not session or str(session.customer_id) != str(customer_id) or not session_in_scope(session, scope):
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
|
|
|
success = session_repo.close_session(
|
|
session_id=session_id,
|
|
reason=reason,
|
|
description=description
|
|
)
|
|
|
|
if success:
|
|
logger.info(f"End chat acknowledged: widget_id={widget_id}, session_id={session_id}, reason={reason}")
|
|
return {
|
|
"success": True,
|
|
"message": "Chat session closed",
|
|
"session_id": session_id,
|
|
"closed_at": datetime.utcnow().isoformat()
|
|
}
|
|
else:
|
|
logger.warning(f"Failed to close session: widget_id={widget_id}, session_id={session_id}")
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error in end_chat_acknowledgment: {str(e)}")
|
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to process end chat")
|
|
|
|
|
|
@router.get("", response_model=List[WidgetResponse])
|
|
def list_widgets(
|
|
db: Session = Depends(get_db),
|
|
current_user=Depends(get_current_user)
|
|
):
|
|
"""List all widgets for the organization"""
|
|
widget_repo = WidgetRepository(db)
|
|
return widget_repo.get_widgets(current_user.organization_id)
|
|
|
|
|
|
@router.get("/agent/{agent_id}", response_model=List[WidgetResponse])
|
|
def get_widgets_by_agent(
|
|
agent_id: UUID,
|
|
db: Session = Depends(get_db),
|
|
current_user=Depends(get_current_user)
|
|
):
|
|
"""Get all widgets for a specific agent"""
|
|
widget_repo = WidgetRepository(db)
|
|
widgets = widget_repo.get_widgets_by_agent(agent_id)
|
|
|
|
# Verify the agent belongs to the user's organization
|
|
agent_repo = AgentRepository(db)
|
|
agent = agent_repo.get_by_id(agent_id)
|
|
if not agent or str(agent.organization_id) != str(current_user.organization_id):
|
|
raise HTTPException(status_code=404, detail="Agent not found")
|
|
|
|
return widgets
|
|
|
|
|
|
@router.delete("/{widget_id}")
|
|
def remove_widget(
|
|
widget_id: str,
|
|
db: Session = Depends(get_db),
|
|
current_user=Depends(get_current_user)
|
|
):
|
|
"""Delete a widget"""
|
|
widget_repo = WidgetRepository(db)
|
|
widget = widget_repo.get_widget(widget_id)
|
|
if not widget or widget.organization_id != current_user.organization_id:
|
|
raise HTTPException(status_code=404, detail="Widget not found")
|
|
widget_repo.delete_widget(widget_id)
|
|
return {"message": "Widget deleted"} |