TIQ-Test-Backend / main.py
Shakeel401's picture
Update main.py
3ea5474 verified
Raw
History Blame Contribute Delete
33.7 kB
# ==============================
# 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 = """
<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")
@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 = """
<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
@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"""
<Response>
<Say>Connecting your call</Say>
<Dial>
<Number>{ADMIN_PHONE}</Number>
</Dial>
</Response>
"""
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(),
}