feat: add appointments, campaigns, admin, storage, tests and various updates

- Add new routers: admin, appointments, campaigns
- Add storage service and logging config
- Add migrations directory and test suite with pytest config
- Add supabase_migration_features.sql
- Update models, dependencies, config, and existing routers
- Remove whatsapp_service (deleted)
- Update pyproject.toml and uv.lock dependencies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
belviskhoremk
2026-04-03 09:11:58 +00:00
parent 9dccc83293
commit 92d4c2fc5e
51 changed files with 7076 additions and 515 deletions

View File

@@ -1,11 +1,9 @@
import json
import re
import uuid
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
from typing import List, Optional
from typing import Optional
from app.config import settings, PLAN_LIMITS
from app.database import get_supabase
@@ -14,7 +12,6 @@ from app.services.rag import rag_engine
from app.services.telegram_service import (
delete_webhook, get_bot_info, send_message as tg_send, set_webhook,
)
from app.services.whatsapp_service import send_message as wa_send, verify_signature
from app.routers.chat import (
_get_conversation_history, _get_or_create_conversation, _save_message,
)
@@ -32,11 +29,6 @@ class TelegramConnectRequest(BaseModel):
bot_token: str
class WhatsAppConnectRequest(BaseModel):
chatbot_id: str
wa_keyword: Optional[str] = None
# ── Helpers ───────────────────────────────────────────────────────────────────
def _verify_chatbot_ownership(chatbot_id: str, user_id: str, supabase):
@@ -53,18 +45,6 @@ def _verify_chatbot_ownership(chatbot_id: str, user_id: str, supabase):
raise HTTPException(status_code=404, detail="Chatbot not found")
def _generate_keyword(chatbot_name: str, chatbot_id: str, supabase) -> str:
base = re.sub(r"[^A-Z0-9]", "", chatbot_name.upper())[:10] or "BOT"
existing = (
supabase.table("channel_connections")
.select("id").eq("channel", "whatsapp").eq("wa_keyword", base).execute()
)
if not existing.data:
return base
suffix = chatbot_id.replace("-", "")[:4].upper()
return f"{base[:6]}{suffix}"
def _get_or_create_channel_session(
chatbot_id: str, channel: str, external_id: str, supabase
) -> dict:
@@ -85,32 +65,13 @@ def _get_or_create_channel_session(
return result.data[0]
def _upsert_whatsapp_session(chatbot_id: str, phone: str, session_id: str, supabase):
existing = (
supabase.table("channel_sessions")
.select("id").eq("channel", "whatsapp").eq("external_id", phone).execute()
)
if existing.data:
supabase.table("channel_sessions").update(
{"chatbot_id": chatbot_id, "session_id": session_id}
).eq("id", existing.data[0]["id"]).execute()
else:
supabase.table("channel_sessions").insert({
"id": str(uuid.uuid4()),
"chatbot_id": chatbot_id,
"channel": "whatsapp",
"external_id": phone,
"session_id": session_id,
}).execute()
def _check_channel_plan(user_id: str, channel: str, supabase):
"""Raise 402 if the user's plan doesn't include the requested channel."""
sub = supabase.table("subscriptions").select("plan").eq("user_id", user_id).eq("status", "active").execute()
plan = sub.data[0]["plan"] if sub.data else "free"
allowed = PLAN_LIMITS.get(plan, PLAN_LIMITS["free"]).get("channels", [])
if channel not in allowed:
label = {"telegram": "Starter", "whatsapp": "Business"}.get(channel, "a higher")
label = {"telegram": "Starter"}.get(channel, "a higher")
raise HTTPException(status_code=402, detail=f"{channel.title()} channel requires {label} plan or higher")
@@ -151,12 +112,6 @@ def _detect_language(text: str) -> str:
return best if scores[best] > 0.25 else "en"
def _wa_link(keyword: str) -> str:
if settings.whatsapp_display_number:
return f"https://wa.me/{settings.whatsapp_display_number}?text=START+{keyword}"
return ""
# ── CRUD endpoints ────────────────────────────────────────────────────────────
@router.get("")
@@ -165,17 +120,11 @@ async def list_channels(chatbot_id: str = Query(...), user=Depends(get_current_u
_verify_chatbot_ownership(chatbot_id, user.id, supabase)
result = (
supabase.table("channel_connections")
.select("id,channel,bot_username,wa_keyword,is_active,created_at")
.select("id,channel,bot_username,is_active,created_at")
.eq("chatbot_id", chatbot_id)
.execute()
)
rows = result.data or []
for row in rows:
if row["channel"] == "whatsapp" and row.get("wa_keyword"):
row["wa_link"] = _wa_link(row["wa_keyword"])
else:
row["wa_link"] = None
return rows
return result.data or []
@router.post("/telegram")
@@ -224,47 +173,6 @@ async def connect_telegram(data: TelegramConnectRequest, user=Depends(get_curren
}
@router.post("/whatsapp")
async def connect_whatsapp(data: WhatsAppConnectRequest, user=Depends(get_current_user)):
supabase = get_supabase()
_verify_chatbot_ownership(data.chatbot_id, user.id, supabase)
_check_channel_plan(user.id, "whatsapp", supabase)
chatbot = supabase.table("chatbots").select("name").eq("id", data.chatbot_id).execute()
chatbot_name = chatbot.data[0]["name"] if chatbot.data else "BOT"
keyword = (data.wa_keyword or _generate_keyword(chatbot_name, data.chatbot_id, supabase)).upper()
keyword = re.sub(r"[^A-Z0-9]", "", keyword)[:12]
if not keyword:
raise HTTPException(status_code=400, detail="Keyword must contain letters or numbers")
taken = (
supabase.table("channel_connections")
.select("id").eq("channel", "whatsapp").eq("wa_keyword", keyword)
.neq("chatbot_id", data.chatbot_id).execute()
)
if taken.data:
raise HTTPException(status_code=400, detail=f"Keyword '{keyword}' is already taken. Choose a different one.")
existing = (
supabase.table("channel_connections")
.select("id").eq("chatbot_id", data.chatbot_id).eq("channel", "whatsapp").execute()
)
conn_data = {
"chatbot_id": data.chatbot_id,
"channel": "whatsapp",
"wa_keyword": keyword,
"is_active": True,
}
if existing.data:
supabase.table("channel_connections").update(conn_data).eq("id", existing.data[0]["id"]).execute()
else:
conn_data["id"] = str(uuid.uuid4())
supabase.table("channel_connections").insert(conn_data).execute()
return {"success": True, "keyword": keyword, "wa_link": _wa_link(keyword)}
@router.delete("/{connection_id}")
async def disconnect_channel(connection_id: str, user=Depends(get_current_user)):
supabase = get_supabase()
@@ -383,136 +291,3 @@ async def telegram_webhook(bot_token: str, request: Request):
return {"ok": True}
# ── WhatsApp webhooks ─────────────────────────────────────────────────────────
@webhook_router.get("/whatsapp")
async def whatsapp_verify(request: Request):
"""Meta webhook verification challenge."""
params = dict(request.query_params)
if (
params.get("hub.mode") == "subscribe"
and settings.whatsapp_verify_token
and params.get("hub.verify_token") == settings.whatsapp_verify_token
):
return Response(content=params["hub.challenge"], media_type="text/plain")
raise HTTPException(status_code=403, detail="Forbidden")
@webhook_router.post("/whatsapp")
async def whatsapp_webhook(request: Request):
"""Receive messages from WhatsApp Cloud API."""
raw_body = await request.body()
if settings.whatsapp_app_secret:
sig = request.headers.get("X-Hub-Signature-256", "")
if not verify_signature(raw_body, sig, settings.whatsapp_app_secret):
raise HTTPException(status_code=403, detail="Invalid signature")
try:
body = json.loads(raw_body)
value = body["entry"][0]["changes"][0]["value"]
if "messages" not in value:
return {"ok": True}
msg = value["messages"][0]
if msg.get("type") != "text":
return {"ok": True}
from_number = msg["from"]
text = msg["text"]["body"].strip()
except (KeyError, IndexError, json.JSONDecodeError):
return {"ok": True}
supabase = get_supabase()
async def _wa_reply(to: str, message: str):
if settings.whatsapp_access_token and settings.whatsapp_phone_number_id:
await wa_send(settings.whatsapp_phone_number_id, to, message, settings.whatsapp_access_token)
# Handle START <keyword>
if text.upper().startswith("START "):
keyword = re.sub(r"[^A-Z0-9]", "", text[6:].strip().upper())
conn = (
supabase.table("channel_connections")
.select("*").eq("channel", "whatsapp").eq("wa_keyword", keyword).eq("is_active", True).execute()
)
if not conn.data:
await _wa_reply(from_number, f"Sorry, chatbot '{keyword}' not found. Use the link from the business you're contacting.")
return {"ok": True}
chatbot_id = conn.data[0]["chatbot_id"]
chatbot_result = supabase.table("chatbots").select("name,welcome_message").eq("id", chatbot_id).execute()
chatbot = chatbot_result.data[0] if chatbot_result.data else {}
_upsert_whatsapp_session(chatbot_id, from_number, str(uuid.uuid4()), supabase)
welcome = chatbot.get("welcome_message") or f"Hello! I'm {chatbot.get('name', 'your assistant')}. How can I help you?"
await _wa_reply(from_number, welcome)
return {"ok": True}
# Regular message — find active session
session_result = (
supabase.table("channel_sessions")
.select("*").eq("channel", "whatsapp").eq("external_id", from_number).execute()
)
if not session_result.data:
await _wa_reply(from_number, "To start chatting, use the WhatsApp link from the business you're trying to contact.")
return {"ok": True}
session = session_result.data[0]
chatbot_id = session["chatbot_id"]
# Check subscription still allows WhatsApp
if not _check_chatbot_channel_subscription(chatbot_id, "whatsapp", supabase):
await _wa_reply(from_number, "This service is currently unavailable. Please contact the business directly.")
return {"ok": True}
chatbot_result = (
supabase.table("chatbots").select("*, companies(name, logo_url)").eq("id", chatbot_id).execute()
)
if not chatbot_result.data:
return {"ok": True}
chatbot = chatbot_result.data[0]
collection_name = chatbot.get("qdrant_collection_name")
if not collection_name:
await _wa_reply(from_number, "This chatbot isn't ready yet. Please try again later.")
return {"ok": True}
detected_lang = _detect_language(text)
company_data = chatbot.get("companies", {}) or {}
conversation = _get_or_create_conversation(
chatbot_id=chatbot_id,
session_id=session["session_id"],
user_id=None,
language=detected_lang,
supabase=supabase,
)
history = _get_conversation_history(conversation["id"], supabase)
chatbot_config = {**chatbot, "company_name": company_data.get("name", "")}
try:
result = await rag_engine.process_query(
query=text,
collection_name=collection_name,
chatbot_config=chatbot_config,
conversation_history=history,
language=detected_lang,
)
except Exception as e:
logger.error(f"WhatsApp RAG error for chatbot {chatbot_id}: {e}")
await _wa_reply(from_number, "Sorry, I encountered an error. Please try again.")
return {"ok": True}
confidence_score = max((s.score for s in result.get("sources", [])), default=0.0)
_save_message(conversation["id"], "user", text, supabase)
_save_message(
conversation["id"], "assistant", result["response"], supabase,
sources=[s.model_dump() for s in result.get("sources", [])],
model=result.get("model", ""),
confidence_score=confidence_score,
)
supabase.table("conversations").update(
{"message_count": len(history) + 2}
).eq("id", conversation["id"]).execute()
supabase.table("channel_sessions").update({"last_active": "now()"}).eq("id", session["id"]).execute()
await _wa_reply(from_number, result["response"])
return {"ok": True}