# ============================== # IMPORTS # ============================== import os import asyncio import random import base64 import hashlib import hmac import json from datetime import datetime, timedelta, timezone from typing import Any, Optional from xml.sax.saxutils import escape from fastapi import FastAPI, Request, Depends, Header, HTTPException from fastapi.responses import JSONResponse, Response from dotenv import load_dotenv from supabase import create_client from twilio.rest import Client from pydantic import BaseModel from agents import Runner from tiq_agent import tiq_agent from session import get_session from settings import router as settings_router from routes.dashboard_routes import ( router as dashboard_router, get_conversation_ai_enabled_for_phone, ) from routes.knowledge_routes import router as knowledge_router from routes.training_routes import router as training_router from routes.broadcast_routes import router as broadcast_router from routes.notification_routes import ( router as notification_router, notify_new_message, notify_failed_ai_reply, ) from routes.voice_routes import router as voice_router from customer_category_service import classify_conversation_category_safely from runtime_context import set_thread_id, set_ai_enabled, is_ai_enabled # ============================== # LOAD ENV # ============================== load_dotenv() SUPABASE_URL = os.getenv("SUPABASE_URL") SUPABASE_KEY = os.getenv("SUPABASE_KEY") JWT_SECRET = os.getenv("JWT_SECRET") TWILIO_SID = os.getenv("TWILIO_SID") TWILIO_AUTH = os.getenv("TWILIO_AUTH") TWILIO_NUMBER = os.getenv("TWILIO_NUMBER") ADMIN_PHONE = os.getenv("ADMIN_PHONE") BASE_URL = os.getenv("BASE_URL") if not SUPABASE_URL or not SUPABASE_KEY: raise RuntimeError("SUPABASE_URL and SUPABASE_KEY are required") if not JWT_SECRET: raise RuntimeError("JWT_SECRET is required") supabase = create_client(SUPABASE_URL, SUPABASE_KEY) twilio_client = Client(TWILIO_SID, TWILIO_AUTH) # ============================== # APP INIT # ============================== app = FastAPI() app.include_router(settings_router) app.include_router(dashboard_router, prefix="/api") app.include_router(knowledge_router, prefix="/api") app.include_router(training_router, prefix="/api") app.include_router(broadcast_router, prefix="/api") app.include_router(notification_router, prefix="/api") app.include_router(voice_router) # ============================== # AUTH HELPERS FOR APK SETTINGS ROUTES # ============================== def utc_now() -> datetime: return datetime.now(timezone.utc) def verify_token(token: str) -> dict[str, Any]: try: body, signature = token.split(".", 1) expected_sig = hmac.new(JWT_SECRET.encode(), body.encode(), hashlib.sha256).digest() expected = base64.urlsafe_b64encode(expected_sig).decode().rstrip("=") if not hmac.compare_digest(signature, expected): raise ValueError("Bad signature") padded_body = body + "=" * (-len(body) % 4) payload = json.loads(base64.urlsafe_b64decode(padded_body.encode())) if int(payload.get("exp", 0)) < int(utc_now().timestamp()): raise ValueError("Expired token") return payload except Exception: raise HTTPException(status_code=401, detail="Invalid or expired token") def require_admin(authorization: Optional[str] = Header(default=None)) -> dict[str, Any]: if not authorization or not authorization.startswith("Bearer "): raise HTTPException(status_code=401, detail="Missing auth token") token = authorization.replace("Bearer ", "").strip() return verify_token(token) # ============================== # DELAY SETTINGS # ============================== DEFAULT_DELAY_SETTINGS = { "delay_enabled": True, "first_reply_delay_min_seconds": 3.0, "first_reply_delay_max_seconds": 6.0, "followup_reply_delay_min_seconds": 0.5, "followup_reply_delay_max_seconds": 2.0, "typing_speed_min_chars_per_second": 8.0, "typing_speed_max_chars_per_second": 12.0, "random_delay_min_seconds": 0.0, "random_delay_max_seconds": 0.75, "max_delay_seconds": 12.0, "manual_takeover_delay_enabled": False, "manual_takeover_delay_seconds": 0.0, } DELAY_SETTINGS = DEFAULT_DELAY_SETTINGS.copy() class DelaySettingsUpdate(BaseModel): delay_enabled: Optional[bool] = None first_reply_delay_min_seconds: Optional[float] = None first_reply_delay_max_seconds: Optional[float] = None followup_reply_delay_min_seconds: Optional[float] = None followup_reply_delay_max_seconds: Optional[float] = None typing_speed_min_chars_per_second: Optional[float] = None typing_speed_max_chars_per_second: Optional[float] = None random_delay_min_seconds: Optional[float] = None random_delay_max_seconds: Optional[float] = None max_delay_seconds: Optional[float] = None manual_takeover_delay_enabled: Optional[bool] = None manual_takeover_delay_seconds: Optional[float] = None def as_float(value: Any, fallback: float) -> float: try: if value is None: return fallback return float(value) except Exception: return fallback def normalize_delay_settings(row: Optional[dict[str, Any]]) -> dict[str, Any]: row = row or {} settings = DEFAULT_DELAY_SETTINGS.copy() settings["delay_enabled"] = bool(row.get("delay_enabled", settings["delay_enabled"])) settings["first_reply_delay_min_seconds"] = as_float(row.get("first_reply_delay_min_seconds"), settings["first_reply_delay_min_seconds"]) settings["first_reply_delay_max_seconds"] = as_float(row.get("first_reply_delay_max_seconds"), settings["first_reply_delay_max_seconds"]) settings["followup_reply_delay_min_seconds"] = as_float(row.get("followup_reply_delay_min_seconds"), settings["followup_reply_delay_min_seconds"]) settings["followup_reply_delay_max_seconds"] = as_float(row.get("followup_reply_delay_max_seconds"), settings["followup_reply_delay_max_seconds"]) settings["typing_speed_min_chars_per_second"] = as_float(row.get("typing_speed_min_chars_per_second"), settings["typing_speed_min_chars_per_second"]) settings["typing_speed_max_chars_per_second"] = as_float(row.get("typing_speed_max_chars_per_second"), settings["typing_speed_max_chars_per_second"]) settings["random_delay_min_seconds"] = as_float(row.get("random_delay_min_seconds"), settings["random_delay_min_seconds"]) settings["random_delay_max_seconds"] = as_float(row.get("random_delay_max_seconds"), settings["random_delay_max_seconds"]) settings["max_delay_seconds"] = as_float(row.get("max_delay_seconds"), settings["max_delay_seconds"]) settings["manual_takeover_delay_enabled"] = bool(row.get("manual_takeover_delay_enabled", settings["manual_takeover_delay_enabled"])) settings["manual_takeover_delay_seconds"] = as_float(row.get("manual_takeover_delay_seconds"), settings["manual_takeover_delay_seconds"]) settings["first_reply_delay_min_seconds"] = max(0, settings["first_reply_delay_min_seconds"]) settings["first_reply_delay_max_seconds"] = max(settings["first_reply_delay_min_seconds"], settings["first_reply_delay_max_seconds"]) settings["followup_reply_delay_min_seconds"] = max(0, settings["followup_reply_delay_min_seconds"]) settings["followup_reply_delay_max_seconds"] = max(settings["followup_reply_delay_min_seconds"], settings["followup_reply_delay_max_seconds"]) settings["typing_speed_min_chars_per_second"] = max(1, settings["typing_speed_min_chars_per_second"]) settings["typing_speed_max_chars_per_second"] = max(settings["typing_speed_min_chars_per_second"], settings["typing_speed_max_chars_per_second"]) settings["random_delay_min_seconds"] = max(0, settings["random_delay_min_seconds"]) settings["random_delay_max_seconds"] = max(settings["random_delay_min_seconds"], settings["random_delay_max_seconds"]) settings["max_delay_seconds"] = max(0, settings["max_delay_seconds"]) settings["manual_takeover_delay_seconds"] = max(0, settings["manual_takeover_delay_seconds"]) return settings def get_latest_bot_settings_row() -> Optional[dict[str, Any]]: result = ( supabase.table("bot_settings") .select("*") .order("updated_at", desc=True) .limit(1) .execute() ) if result.data: return result.data[0] created = supabase.table("bot_settings").insert({ "ai_enabled": True, **DEFAULT_DELAY_SETTINGS, }).execute() if created.data: return created.data[0] return None def load_runtime_settings(): global DELAY_SETTINGS try: row = get_latest_bot_settings_row() if row: set_ai_enabled(bool(row.get("ai_enabled", True))) DELAY_SETTINGS = normalize_delay_settings(row) return set_ai_enabled(True) DELAY_SETTINGS = DEFAULT_DELAY_SETTINGS.copy() except Exception as e: print("RUNTIME SETTINGS LOAD ERROR:", e) set_ai_enabled(True) DELAY_SETTINGS = DEFAULT_DELAY_SETTINGS.copy() def get_delay_settings() -> dict[str, Any]: global DELAY_SETTINGS if not DELAY_SETTINGS: load_runtime_settings() return DELAY_SETTINGS.copy() def save_delay_settings_to_supabase(settings: dict[str, Any]) -> dict[str, Any]: existing = ( supabase.table("bot_settings") .select("id") .order("updated_at", desc=True) .limit(1) .execute() ) update_data = { **settings, "updated_at": utc_now().isoformat(), } if existing.data: result = ( supabase.table("bot_settings") .update(update_data) .eq("id", existing.data[0]["id"]) .execute() ) else: result = ( supabase.table("bot_settings") .insert({ "ai_enabled": is_ai_enabled(), **update_data, }) .execute() ) if not result.data: raise RuntimeError("Could not save delay settings") return result.data[0] def should_ai_reply_to_customer(phone: str) -> bool: try: return bool(get_conversation_ai_enabled_for_phone(phone)) except Exception as e: print("CUSTOMER AI STATUS CHECK ERROR:", e) return True load_runtime_settings() # ============================== # DELAY SETTINGS API ROUTES # ============================== @app.get("/api/settings/delay") def get_delay_settings_route(_: dict = Depends(require_admin)): return get_delay_settings() @app.patch("/api/settings/delay") def update_delay_settings_route( payload: DelaySettingsUpdate, _: dict = Depends(require_admin), ): global DELAY_SETTINGS current = get_delay_settings() updates = payload.model_dump(exclude_unset=True) next_settings = { **current, **updates, } next_settings = normalize_delay_settings(next_settings) DELAY_SETTINGS = next_settings.copy() save_delay_settings_to_supabase(next_settings) return next_settings # ============================== # HUMAN DELAY LOGIC # ============================== def calculate_delay(message: str, is_first_message: bool): settings = get_delay_settings() if not settings["delay_enabled"]: return 0 length = len(message) typing_speed = random.uniform( settings["typing_speed_min_chars_per_second"], settings["typing_speed_max_chars_per_second"], ) typing_time = length / typing_speed if is_first_message: base_delay = random.uniform( settings["first_reply_delay_min_seconds"], settings["first_reply_delay_max_seconds"], ) else: base_delay = random.uniform( settings["followup_reply_delay_min_seconds"], settings["followup_reply_delay_max_seconds"], ) random_delay = random.uniform( settings["random_delay_min_seconds"], settings["random_delay_max_seconds"], ) delay = base_delay + typing_time + random_delay return min(delay, settings["max_delay_seconds"]) async def human_delay(message: str, phone: str): try: settings = get_delay_settings() if not settings["delay_enabled"]: print("HUMAN DELAY: disabled") return res = supabase.table("messages") \ .select("id") \ .eq("phone", phone) \ .limit(2) \ .execute() is_first_message = len(res.data) <= 1 delay = calculate_delay(message, is_first_message) print(f"HUMAN DELAY: {round(delay, 2)} sec (first={is_first_message})") if delay > 0: await asyncio.sleep(delay) except Exception as e: print("DELAY ERROR:", e) await asyncio.sleep(2) async def manual_takeover_delay(): try: settings = get_delay_settings() if not settings["manual_takeover_delay_enabled"]: return delay = settings["manual_takeover_delay_seconds"] if delay > 0: print(f"MANUAL TAKEOVER DELAY: {round(delay, 2)} sec") await asyncio.sleep(delay) except Exception as e: print("MANUAL DELAY ERROR:", e) # ============================== # SAFE TWILIO SMS # ============================== def safe_send_sms(body: str, to: str): try: message = twilio_client.messages.create( body=body, from_=TWILIO_NUMBER, to=to ) return {"success": True, "sid": message.sid} except Exception as e: print("TWILIO ERROR:", repr(e)) return {"success": False, "error": str(e)} # ============================== # BACKGROUND HELPER # ============================== def run_background(coro): asyncio.create_task(coro) # ============================================================ # CUSTOMER + CONVERSATION HELPERS # ============================================================ def get_or_create_customer(phone: str): try: res = supabase.table("customers").select("id").eq("phone", phone).limit(1).execute() if res.data: return res.data[0]["id"] new_customer = supabase.table("customers").insert({ "phone": phone }).execute() return new_customer.data[0]["id"] except Exception as e: print("CUSTOMER ERROR:", e) return None def get_or_create_conversation(phone: str, customer_id: str): try: res = supabase.table("conversations") \ .select("id") \ .eq("thread_id", phone) \ .limit(1) \ .execute() if res.data: return res.data[0]["id"] new_conv = supabase.table("conversations").insert({ "customer_id": customer_id, "thread_id": phone, "phone": phone, "status": "active", "ai_enabled": True, }).execute() return new_conv.data[0]["id"] except Exception as e: print("CONVERSATION ERROR:", e) return None def update_conversation_after_message(conv_id: str, message: str, sender: str, is_user: bool): try: update_data = { "last_message": message, "last_sender": sender, "last_message_at": "now()", "updated_at": "now()" } try: conv = supabase.table("conversations") \ .select("unread_count") \ .eq("id", conv_id) \ .limit(1) \ .execute() unread = conv.data[0]["unread_count"] if conv.data else 0 except Exception: unread = 0 if is_user: update_data["unread_count"] = unread + 1 if sender == "admin": update_data["unread_count"] = 0 supabase.table("conversations").update(update_data).eq("id", conv_id).execute() except Exception as e: print("CONVERSATION UPDATE ERROR:", e) # ============================================================ # NOTIFICATION HELPERS # ============================================================ async def notify_new_message_async( phone: str, message: str, conversation_id: Optional[str], customer_id: Optional[str], ): try: await asyncio.to_thread( notify_new_message, conversation_id, customer_id, phone, message, ) except Exception as e: print("NEW MESSAGE NOTIFICATION ERROR:", e) async def notify_failed_ai_reply_async( phone: str, error_message: Optional[str] = None, ): try: customer_id = await asyncio.to_thread(get_or_create_customer, phone) conversation_id = await asyncio.to_thread(get_or_create_conversation, phone, customer_id) await asyncio.to_thread( notify_failed_ai_reply, conversation_id, customer_id, error_message, ) except Exception as e: print("FAILED AI NOTIFICATION ERROR:", e) # ============================================================ # SAVE MESSAGE # ============================================================ async def save_message_async(phone, message, sender, pair_type): try: customer_id = await asyncio.to_thread(get_or_create_customer, phone) conversation_id = await asyncio.to_thread(get_or_create_conversation, phone, customer_id) source_map = { "user": "customer", "ai": "ai", "admin": "manual" } source = source_map.get(sender, "system") metadata = { "length": len(message), "channel": "sms", "thread": phone, "sender_type": sender } await asyncio.to_thread( lambda: supabase.table("messages").insert({ "phone": phone, "thread_id": phone, "customer_id": customer_id, "conversation_id": conversation_id, "message": message, "sender": sender, "pair_type": pair_type, "source": source, "status": "received" if sender == "user" else "sent", "metadata": metadata, "trained": False }).execute() ) await asyncio.to_thread( update_conversation_after_message, conversation_id, message, sender, sender == "user" ) if sender == "user" and conversation_id: run_background(classify_conversation_category_safely(conversation_id)) return { "customer_id": customer_id, "conversation_id": conversation_id, } except Exception as e: print("SAVE ERROR:", e) return None async def save_inbound_message_and_notify(phone: str, message: str, pair_type): saved = await save_message_async(phone, message, "user", pair_type) if saved: await notify_new_message_async( phone=phone, message=message, conversation_id=saved.get("conversation_id"), customer_id=saved.get("customer_id"), ) # ============================== # SMS WEBHOOK # ============================== @app.post("/twilio/sms") async def twilio_sms(request: Request): try: form = await request.form() user_message = form.get("Body") phone = form.get("From") if not user_message or not phone: return JSONResponse({"error": "Invalid request"}, status_code=400) session = get_session(phone) run_background(save_inbound_message_and_notify(phone, user_message, None)) if not is_ai_enabled(): return {"status": "manual_mode_saved"} customer_ai_enabled = await asyncio.to_thread(should_ai_reply_to_customer, phone) if not customer_ai_enabled: return {"status": "customer_ai_off", "phone": phone} set_thread_id(phone) try: result = await Runner.run( tiq_agent, user_message, session=session ) except Exception as e: run_background(notify_failed_ai_reply_async(phone, str(e))) raise ai_reply = result.final_output await human_delay(ai_reply, phone) sms_result = safe_send_sms(ai_reply, phone) if not sms_result["success"]: run_background(notify_failed_ai_reply_async(phone, sms_result.get("error"))) return {"status": "received_but_reply_failed", "error": sms_result["error"]} run_background(save_message_async(phone, ai_reply, "ai", "ai")) return {"status": "success"} except Exception as e: print("SMS ERROR:", repr(e)) return JSONResponse({"error": str(e)}, status_code=500) # ============================== # CALL WEBHOOK # ============================== def customer_has_previous_contact(phone: Optional[str]) -> bool: if not phone: return False try: messages = ( supabase.table("messages") .select("id", count="exact") .eq("phone", phone) .limit(1) .execute() ) if messages.count and messages.count > 0: return True conversations = ( supabase.table("conversations") .select("id", count="exact") .or_(f"phone.eq.{phone},thread_id.eq.{phone}") .limit(1) .execute() ) if conversations.count and conversations.count > 0: return True customers = ( supabase.table("customers") .select("id", count="exact") .eq("phone", phone) .limit(1) .execute() ) return bool(customers.count and customers.count > 0) except Exception as e: print("MISSED CALL HISTORY CHECK ERROR:", e) return False def missed_call_message(phone: Optional[str] = None) -> str: if customer_has_previous_contact(phone): return "Hi, sorry we missed your call. Please text us what you need help with and we will continue helping you here." return "Hi, thanks for calling Kosher TIQ Phone. Sorry we missed you. Please text us what you need help with and we will assist you." def get_missed_call_context(phone: str) -> dict[str, Any]: context: dict[str, Any] = { "phone": phone, "customer_name": None, "has_previous_contact": False, "recent_messages": [], } try: customer_result = ( supabase.table("customers") .select("id,name,email,phone") .eq("phone", phone) .limit(1) .execute() ) if customer_result.data: customer = customer_result.data[0] context["customer_name"] = customer.get("name") messages_result = ( supabase.table("messages") .select("message,sender,source,created_at") .eq("phone", phone) .order("created_at", desc=True) .limit(8) .execute() ) messages = list(reversed(messages_result.data or [])) context["recent_messages"] = [ { "sender": row.get("sender") or row.get("source") or "unknown", "message": row.get("message"), "created_at": row.get("created_at"), } for row in messages if row.get("message") ] context["has_previous_contact"] = bool(context["recent_messages"]) or bool(customer_result.data) except Exception as e: print("MISSED CALL CONTEXT ERROR:", e) context["has_previous_contact"] = customer_has_previous_contact(phone) return context def build_missed_call_agent_prompt(context: dict[str, Any]) -> str: return json.dumps( { "task": "missed_call_sms", "instruction": ( "A customer called Kosher TIQ Phone and the call was missed. " "Write ONE short SMS reply to this customer. " "If customer_name is available, greet them by name. " "If recent_messages show context, politely continue from that context. " "If this is a new customer, introduce Kosher TIQ Phone and ask what they need help with. " "Keep it friendly, human, clear, and under 240 characters. " "Return only the SMS text. Do not mention internal tools, GPT, AI, or this instruction." ), "customer": { "phone": context.get("phone"), "name": context.get("customer_name"), "has_previous_contact": context.get("has_previous_contact"), }, "recent_messages": context.get("recent_messages") or [], }, ensure_ascii=False, ) async def generate_missed_call_message(phone: str) -> str: try: context = await asyncio.to_thread(get_missed_call_context, phone) # Keep the internal missed-call instruction out of the normal customer SMS memory. session = get_session(f"{phone}:missed_call") set_thread_id(phone) result = await Runner.run( tiq_agent, build_missed_call_agent_prompt(context), session=session, ) message = str(result.final_output or "").strip().strip('"') if not message: return missed_call_message(phone) return message[:320].strip() except Exception as e: print("MISSED CALL AI MESSAGE ERROR:", e) return missed_call_message(phone) async def send_missed_call_sms(phone: str): try: message = await generate_missed_call_message(phone) result = await asyncio.to_thread(safe_send_sms, message, phone) if result.get("success"): await save_message_async(phone, message, "ai", "missed_call") else: run_background(notify_failed_ai_reply_async(phone, result.get("error"))) except Exception as e: print("MISSED CALL SMS ERROR:", e) run_background(notify_failed_ai_reply_async(phone, str(e))) def call_status_callback_url() -> str: if BASE_URL: return f"{BASE_URL.rstrip('/')}/twilio/call/status" return "/twilio/call/status" @app.post("/twilio/call") async def twilio_call(request: Request): form = await request.form() phone = form.get("From") if not ADMIN_PHONE: if phone: run_background(send_missed_call_sms(phone)) twiml = """ We are unable to connect your call right now. Please text us and we will assist you. """ return Response(content=twiml.strip(), media_type="text/xml") action_url = escape(call_status_callback_url()) admin_phone = escape(ADMIN_PHONE) caller_id = escape(TWILIO_NUMBER or "") caller_id_attr = f' callerId="{caller_id}"' if caller_id else "" twiml = f""" {admin_phone} """ return Response(content=twiml.strip(), media_type="text/xml") @app.post("/twilio/call/status") async def twilio_call_status(request: Request): form = await request.form() phone = form.get("From") dial_status = (form.get("DialCallStatus") or "").lower() missed_statuses = {"no-answer", "busy", "failed", "canceled"} if phone and dial_status in missed_statuses: run_background(send_missed_call_sms(phone)) twiml = """ Thank you. Please text us and we will assist you. """ return Response(content=twiml.strip(), media_type="text/xml") # ============================== # ADMIN REPLY # ============================== class ReplyRequest(BaseModel): phone: str message: str @app.post("/admin/reply") async def admin_reply(data: ReplyRequest): try: await manual_takeover_delay() result = safe_send_sms(data.message, data.phone) run_background(save_message_async(data.phone, data.message, "admin", "manual")) await asyncio.to_thread(update_latest_escalation) return {"success": result["success"]} except Exception as e: return {"success": False, "error": str(e)} def update_latest_escalation(): try: esc = supabase.table("escalations") \ .select("id") \ .eq("status", "pending") \ .order("created_at", desc=True) \ .limit(1) \ .execute() if esc.data: supabase.table("escalations") \ .update({"status": "resolved"}) \ .eq("id", esc.data[0]["id"]) \ .execute() except Exception as e: print("ESCALATION UPDATE ERROR:", e) # ============================== # CALL CUSTOMER # ============================== class CallRequest(BaseModel): phone: str @app.post("/call/customer") async def call_customer(data: CallRequest): try: call = twilio_client.calls.create( to=data.phone, from_=TWILIO_NUMBER, url=f"{os.getenv('BASE_URL')}/voice/bridge" ) return {"status": "calling", "sid": call.sid} except Exception as e: return {"error": str(e)} # ============================== # VOICE BRIDGE # ============================== @app.post("/voice/bridge") async def voice_bridge(): twiml = f""" Connecting your call {ADMIN_PHONE} """ return Response(content=twiml, media_type="text/xml") # ============================== # TEST CHAT # ============================== @app.post("/test/chat") async def test_chat(request: Request): try: form = await request.form() user_message = form.get("Body") phone = form.get("From") print("\nTEST CHAT") print("PHONE:", phone) print("MESSAGE:", user_message) if not user_message or not phone: return JSONResponse( {"error": "Phone and message required"}, status_code=400 ) session = get_session(phone) set_thread_id(phone) run_background( save_inbound_message_and_notify(phone, user_message, "test") ) if not is_ai_enabled(): return { "success": True, "status": "manual_mode_saved", "phone": phone, "user_message": user_message, "reply": None } customer_ai_enabled = await asyncio.to_thread(should_ai_reply_to_customer, phone) if not customer_ai_enabled: return { "success": True, "status": "customer_ai_off", "phone": phone, "user_message": user_message, "reply": None } try: result = await Runner.run( tiq_agent, user_message, session=session ) except Exception as e: run_background(notify_failed_ai_reply_async(phone, str(e))) raise ai_reply = result.final_output print("TEST AI REPLY:", ai_reply) await human_delay(ai_reply, phone) run_background( save_message_async(phone, ai_reply, "ai", "test_ai") ) return { "success": True, "status": "success", "phone": phone, "user_message": user_message, "reply": ai_reply, "delay_simulated": True, "delay_settings": get_delay_settings(), } except Exception as e: print("TEST CHAT ERROR:", repr(e)) return JSONResponse( {"error": str(e)}, status_code=500 ) # ============================== # ROOT # ============================== @app.get("/") def root(): return { "status": "running", "ai_enabled": is_ai_enabled(), "delay_settings": get_delay_settings(), }