""" Copyright 2024-2026 ChatterMate 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. """ # Set environment variable to prevent tokenizer deadlock warnings import os os.environ.setdefault('TOKENIZERS_PARALLELISM', 'false') # Add users import from fastapi.staticfiles import StaticFiles import socketio from app.api import chat, organizations, users, ai_setup, knowledge, agent, notification, widget, widget_apps, user_groups, roles, analytics, jira, shopify, workflow, workflow_node, mcp_tool, file_upload, token, lead_capture, people, tickets from app.api import system_updates from app.api import help_center as help_center_api from app.api import help_center_images from app.api import channels as channels_api from app.api import webhooks as channel_webhooks # Import widget_chat to register socket.io event handlers for /widget namespace from app.api import widget_chat # noqa: F401 - imported for side effects (socket.io handlers registration) from fastapi import FastAPI, BackgroundTasks from app.core.config import settings, verify_secret_configuration from app.core.encryption import verify_encryption_key from app.services.file_storage import PUBLIC_UPLOAD_FOLDERS from app.services.firebase import initialize_firebase from app.database import engine, Base import asyncio from app.core.logger import get_logger from contextlib import asynccontextmanager import os from app.channels.app_hooks import load_extensions from app.core.socketio import socket_app, configure_socketio, sio from app.core.cors import ( FirstPartyCredentialsCORSMiddleware, first_party_origins, get_cors_origin_regex, get_cors_origins, with_configured_origins, ) from app.core.application import app, initialize_cors_listener from app.core.crawler_policy import install_crawler_policy # Import models to ensure they're registered with SQLAlchemy from app.models import Organization, User, Customer try: from app.enterprise.models import OTP except ImportError: print("Enterprise models not available") from app.api import session_to_agent from app.workers.chat_auto_closer import run_auto_closer_loop logger = get_logger(__name__) @asynccontextmanager async def lifespan(app: FastAPI): # Startup # First: a bad ENCRYPTION_KEY must stop the boot, not surface as an unreadable # conversation once the app is already serving traffic. verify_encryption_key() verify_secret_configuration() initialize_firebase() await startup_event(app) yield # Shutdown pass # Move the CORS setup before app instantiation cors_origins = with_configured_origins(get_cors_origins()) logger.debug(f"CORS origins: {cors_origins}") # Update the FastAPI app to use the lifespan function app.router.lifespan_context = lifespan # Add CORS middleware to FastAPI app. The regex covers every help-center # subdomain (*.chattermate.help) so a newly-created slug is allowed without a # cache refresh; explicit origins still cover org domains + custom domains. # Only first-party origins (the dashboard) may send credentials. app.add_middleware( FirstPartyCredentialsCORSMiddleware, credentialed_origins=first_party_origins(), allow_origins=cors_origins, allow_origin_regex=get_cors_origin_regex(), allow_methods=["*"], allow_headers=["*"], ) async def startup_event(api: FastAPI): """Configure Socket.IO on startup. Called from lifespan() with the FastAPI instance; the on_event registration it used to carry never ran once a custom lifespan was set. The module-level `app` is rebound to the Socket.IO ASGI wrapper below, so the instance has to be passed in.""" configure_socketio(cors_origins) # Channel extensions (e.g. an external-app integration) hook into message # handling; loaded explicitly so other entry points never pull them in. load_extensions() # Start CORS listener for multi-worker synchronization initialize_cors_listener() # Start chat auto-closer background task, AI chat will auto close after 1 day. # Held on app.state so the task cannot be garbage-collected mid-run. api.state.auto_closer_task = asyncio.create_task(run_auto_closer_loop()) # Include routers app.include_router(system_updates.router, prefix=f"{settings.API_V1_STR}/system-updates", tags=["system-updates"]) app.include_router( chat.router, prefix=f"{settings.API_V1_STR}/chats", tags=["chats"] ) app.include_router( channels_api.router, prefix=f"{settings.API_V1_STR}/channels", tags=["channels"] ) app.include_router( tickets.router, prefix=f"{settings.API_V1_STR}/tickets", tags=["tickets"] ) from app.api import crm as crm_api app.include_router( crm_api.router, prefix=f"{settings.API_V1_STR}/crm", tags=["crm"] ) from app.api import ticket_db_connectors, ticket_webhooks app.include_router( ticket_db_connectors.router, prefix=f"{settings.API_V1_STR}/ticket-db-connectors", tags=["tickets"] ) app.include_router( ticket_webhooks.router, prefix=f"{settings.API_V1_STR}/tickets/webhooks", tags=["tickets"] ) app.include_router( channel_webhooks.router, prefix=f"{settings.API_V1_STR}/webhooks", tags=["webhooks"] ) # Try to import enterprise module if available try: from app.enterprise import router as enterprise_router app.include_router(enterprise_router, prefix=f"{settings.API_V1_STR}/enterprise", tags=["enterprise"]) except ImportError as e: if os.path.exists(os.path.join(os.path.dirname(__file__), "enterprise", "__init__.py")): # The module is there but won't load (e.g. it needs newer core code): # signup and billing would vanish silently, so say so loudly. logger.error(f"Enterprise module present but failed to import: {e}") else: logger.info("Enterprise module not available - running in community edition mode") app.include_router( organizations.router, prefix=f"{settings.API_V1_STR}/organizations", tags=["organizations"] ) app.include_router( users.router, prefix=f"{settings.API_V1_STR}/users", tags=["users"] ) app.include_router( help_center_api.router, prefix=f"{settings.API_V1_STR}/help-center", tags=["help-center"] ) # Public: article images baked into FAQ Markdown. Same prefix as the admin # router above but unauthenticated — article pages are public. Also registered # on public_app for host mode. app.include_router( help_center_images.router, prefix=f"{settings.API_V1_STR}/help-center", tags=["help-center"] ) app.include_router( knowledge.router, prefix=f"{settings.API_V1_STR}/knowledge", tags=["knowledge"] ) app.include_router( ai_setup.router, prefix=f"{settings.API_V1_STR}/ai", tags=["ai"] ) app.include_router( agent.router, prefix=f"{settings.API_V1_STR}/agent", tags=["agent"] ) app.include_router( lead_capture.router, prefix=f"{settings.API_V1_STR}/agent", tags=["lead-capture"] ) app.include_router( people.router, prefix=f"{settings.API_V1_STR}/people", tags=["people"] ) app.include_router( mcp_tool.router, prefix=f"{settings.API_V1_STR}/mcp-tools", tags=["mcp-tools"] ) # Proxy router moved to enterprise module app.include_router( notification.router, prefix=f"{settings.API_V1_STR}/notifications", tags=["notification"] ) app.include_router( widget.router, prefix=f"{settings.API_V1_STR}/widgets", tags=["widget"] ) app.include_router( user_groups.router, prefix=f"{settings.API_V1_STR}/groups", tags=["groups"] ) app.include_router( roles.router, prefix=f"{settings.API_V1_STR}/roles", tags=["roles"] ) app.include_router( session_to_agent.router, prefix=f"{settings.API_V1_STR}/sessions", tags=["session_to_agent"] ) app.include_router( analytics.router, prefix=f"{settings.API_V1_STR}/analytics", tags=["analytics"] ) app.include_router( jira.router, prefix=f"{settings.API_V1_STR}/jira", tags=["jira"] ) app.include_router( token.router, prefix=f"{settings.API_V1_STR}", tags=["token"] ) app.include_router( widget_apps.router, prefix=f"{settings.API_V1_STR}/widget-apps", tags=["widget-apps"] ) app.include_router( shopify.router, prefix=f"{settings.API_V1_STR}/shopify", tags=["shopify"] ) app.include_router( workflow.router, prefix=f"{settings.API_V1_STR}/workflow", tags=["workflow"] ) app.include_router( workflow_node.router, prefix=f"{settings.API_V1_STR}/workflow", tags=["workflow_node"] ) app.include_router( file_upload.router, prefix=f"{settings.API_V1_STR}/files", tags=["files"] ) @app.get("/") async def root(): return { "name": settings.PROJECT_NAME, "version": settings.VERSION, "description": "Welcome to ChatterMate API" } @app.api_route("/health", methods=["GET"], operation_id="get_health_check") async def get_health_check(): return { "status": "healthy", "version": settings.VERSION } @app.api_route("/health", methods=["HEAD"], operation_id="head_health_check") async def head_health_check(): return { "status": "healthy", "version": settings.VERSION } @app.get("/health/help-center-domain", include_in_schema=False, operation_id="help_center_domain_tls_check") async def help_center_domain_tls_check(domain: str = ""): """On-demand-TLS "ask" gate for the edge proxy (e.g. Caddy). Returns 200 only when ``domain`` is a help-center host we actually serve — a ``{slug}.HELP_CENTER_BASE_DOMAIN`` subdomain or a DB-verified custom domain — so the edge provisions a certificate for those and refuses every other name pointed at us. Public by design (the edge calls it unauthenticated) but it leaks nothing: the answer is a bare status code and the lookup reuses the same in-memory cache/slug check as host dispatch, so there is no per-request database hit. """ from fastapi import Response from app.core.help_center_host import is_help_center_host, normalize_host host = normalize_host(domain) if host and is_help_center_host(host): return Response(status_code=200) return Response(status_code=404) # Create upload directories if they don't exist if not os.path.exists("uploads"): os.makedirs("uploads") if not os.path.exists("uploads/agents"): os.makedirs("uploads/agents") # Only the public upload folders are static-mounted. Chat attachments are # served through signed links (GET {API_V1_STR}/files/local/...), and the rest # of uploads/ is never served. check_dir=False: a folder may not exist until # its first upload. for folder in PUBLIC_UPLOAD_FOLDERS: app.mount( f"{settings.API_V1_STR}/uploads/{folder}", StaticFiles(directory=os.path.join("uploads", folder), check_dir=False), name=f"uploads_{folder}", ) app.mount("/assets", StaticFiles(directory=settings.WIDGET_ASSETS_DIR), name="assets") # The API origin is not a website: no robots.txt meant crawlers were free to # discover /api/v1/... endpoints and report them as 404s. install_crawler_policy(app) # Create final ASGI app # Public help center: requests for {slug}. / verified custom # domains are dispatched to the SSR help-center app before reaching the API. from app.api.help_center_public import public_app from app.core.help_center_host import HelpCenterHostMiddleware from app.core.system_maintenance import SystemMaintenanceMiddleware app = SystemMaintenanceMiddleware(socketio.ASGIApp(sio, HelpCenterHostMiddleware(app, public_app)))