Spaces:
Running
Running
| # ============================== | |
| # 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 | |
| # ============================== | |
| def get_delay_settings_route(_: dict = Depends(require_admin)): | |
| return get_delay_settings() | |
| 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 | |
| # ============================== | |
| 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" | |
| 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 = """ | |
| <Response> | |
| <Say>We are unable to connect your call right now. Please text us and we will assist you.</Say> | |
| </Response> | |
| """ | |
| 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""" | |
| <Response> | |
| <Dial{caller_id_attr} timeout="25" action="{action_url}" method="POST"> | |
| <Number>{admin_phone}</Number> | |
| </Dial> | |
| </Response> | |
| """ | |
| return Response(content=twiml.strip(), media_type="text/xml") | |
| 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 = """ | |
| <Response> | |
| <Say>Thank you. Please text us and we will assist you.</Say> | |
| </Response> | |
| """ | |
| return Response(content=twiml.strip(), media_type="text/xml") | |
| # ============================== | |
| # ADMIN REPLY | |
| # ============================== | |
| class ReplyRequest(BaseModel): | |
| phone: str | |
| message: str | |
| 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 | |
| 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 | |
| # ============================== | |
| async def voice_bridge(): | |
| twiml = f""" | |
| <Response> | |
| <Say>Connecting your call</Say> | |
| <Dial> | |
| <Number>{ADMIN_PHONE}</Number> | |
| </Dial> | |
| </Response> | |
| """ | |
| return Response(content=twiml, media_type="text/xml") | |
| # ============================== | |
| # 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 | |
| # ============================== | |
| def root(): | |
| return { | |
| "status": "running", | |
| "ai_enabled": is_ai_enabled(), | |
| "delay_settings": get_delay_settings(), | |
| } | |