mirror of
http://88.130.71.182:3000/BlitTech/contexta_be.git
synced 2026-06-12 23:23:21 +00:00
updates Mar6
This commit is contained in:
148
app/config.py
148
app/config.py
@@ -31,7 +31,8 @@ class Settings(BaseSettings):
|
|||||||
stripe_secret_key: str = ""
|
stripe_secret_key: str = ""
|
||||||
stripe_webhook_secret: str = ""
|
stripe_webhook_secret: str = ""
|
||||||
stripe_starter_price_id: str = ""
|
stripe_starter_price_id: str = ""
|
||||||
stripe_pro_price_id: str = ""
|
stripe_business_price_id: str = ""
|
||||||
|
stripe_agency_price_id: str = ""
|
||||||
|
|
||||||
# Redis
|
# Redis
|
||||||
redis_url: str = "redis://localhost:6379"
|
redis_url: str = "redis://localhost:6379"
|
||||||
@@ -42,6 +43,25 @@ class Settings(BaseSettings):
|
|||||||
# Files
|
# Files
|
||||||
max_file_size_mb: int = 50
|
max_file_size_mb: int = 50
|
||||||
|
|
||||||
|
# App URL (for widget embedding)
|
||||||
|
app_url: str = "http://localhost:5173"
|
||||||
|
|
||||||
|
# Backend API URL (used for Telegram webhook registration)
|
||||||
|
api_url: str = "http://localhost:8000"
|
||||||
|
|
||||||
|
# n8n Handoff webhook
|
||||||
|
n8n_handoff_webhook_url: Optional[str] = None
|
||||||
|
|
||||||
|
# Supabase Storage
|
||||||
|
supabase_storage_url: str = ""
|
||||||
|
|
||||||
|
# WhatsApp Cloud API
|
||||||
|
whatsapp_access_token: str = ""
|
||||||
|
whatsapp_phone_number_id: str = ""
|
||||||
|
whatsapp_verify_token: str = "contexta_whatsapp_verify"
|
||||||
|
whatsapp_app_secret: str = ""
|
||||||
|
whatsapp_display_number: str = "" # E.164 without '+', e.g. "15551234567"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def allowed_origins_list(self) -> List[str]:
|
def allowed_origins_list(self) -> List[str]:
|
||||||
return [o.strip() for o in self.allowed_origins.split(",")]
|
return [o.strip() for o in self.allowed_origins.split(",")]
|
||||||
@@ -161,13 +181,14 @@ MODEL_PROVIDERS = {
|
|||||||
DEFAULT_MODELS = {
|
DEFAULT_MODELS = {
|
||||||
"free": "accounts/fireworks/models/llama-v3p3-70b-instruct",
|
"free": "accounts/fireworks/models/llama-v3p3-70b-instruct",
|
||||||
"starter": "accounts/fireworks/models/qwen3-235b-a22b",
|
"starter": "accounts/fireworks/models/qwen3-235b-a22b",
|
||||||
"pro": "gpt-4o",
|
"business": "gpt-4o",
|
||||||
|
"agency": "gpt-4o",
|
||||||
"enterprise": "gpt-4o",
|
"enterprise": "gpt-4o",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
# PLAN LIMITS — Pricing: Starter $3/mo, Pro $20/mo
|
# PLAN LIMITS — Pricing: Starter $12/mo, Business $29/mo, Agency $79/mo
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
#
|
#
|
||||||
# Cost analysis (per 1M tokens approx):
|
# Cost analysis (per 1M tokens approx):
|
||||||
@@ -185,87 +206,102 @@ DEFAULT_MODELS = {
|
|||||||
# Avg conversation: ~2K tokens input + 1K output = ~3K tokens
|
# Avg conversation: ~2K tokens input + 1K output = ~3K tokens
|
||||||
# Fireworks models: ~$0.001-$0.004 per conversation
|
# Fireworks models: ~$0.001-$0.004 per conversation
|
||||||
# GPT-4o: ~$0.015 per conversation
|
# GPT-4o: ~$0.015 per conversation
|
||||||
# GPT-4o Mini: ~$0.001 per conversation
|
|
||||||
# Claude Haiku: ~$0.006 per conversation
|
|
||||||
# Gemini Flash: ~$0.001 per conversation
|
|
||||||
# Gemini Pro: ~$0.013 per conversation
|
|
||||||
#
|
#
|
||||||
# Starter at $3/mo with 500 convos: max cost ~$2/mo (fireworks) → margin OK
|
# Starter $12/mo, 1500 convos: max cost ~$6/mo (fireworks mix) → margin OK
|
||||||
# Pro at $20/mo with 2,000 convos: max cost ~$12/mo (if all GPT-4o) → margin OK
|
# Business $29/mo, 5000 convos: max cost ~$15/mo (mixed models) → margin OK
|
||||||
# Typical mix: ~$5-8/mo actual cost → healthy margin
|
# Agency $79/mo, 20000 convos: max cost ~$30/mo (fireworks) → healthy margin
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
_ALL_FIREWORKS = [
|
||||||
|
"accounts/fireworks/models/llama-v3p3-70b-instruct",
|
||||||
|
"accounts/fireworks/models/qwen3-235b-a22b",
|
||||||
|
"accounts/fireworks/models/deepseek-v3p1",
|
||||||
|
"accounts/fireworks/models/kimi-k2-instruct-0905",
|
||||||
|
]
|
||||||
|
_ALL_PREMIUM = [
|
||||||
|
"gpt-4o", "gpt-4o-mini",
|
||||||
|
"claude-haiku-4-5-20251001",
|
||||||
|
"gemini-2.5-flash", "gemini-2.5-lite", "gemini-2.5-pro",
|
||||||
|
]
|
||||||
|
|
||||||
PLAN_LIMITS = {
|
PLAN_LIMITS = {
|
||||||
|
# ── Free ─────────────────────────────────────────────────────────────────
|
||||||
|
# Build, test, and go live with one chatbot — no card needed.
|
||||||
"free": {
|
"free": {
|
||||||
"max_chatbots": 999999, # unlimited creation
|
"max_chatbots": 999999,
|
||||||
"max_published": 0, # cannot publish
|
"max_published": 1, # can publish 1 chatbot
|
||||||
"max_documents_per_chatbot": 3,
|
"max_documents_per_chatbot": 3,
|
||||||
"max_document_size_mb": 5,
|
"max_document_size_mb": 5,
|
||||||
"models": [
|
"models": ["accounts/fireworks/models/llama-v3p3-70b-instruct"],
|
||||||
"accounts/fireworks/models/llama-v3p3-70b-instruct",
|
"conversations_limit": 100, # 100 real conversations/month
|
||||||
],
|
|
||||||
"conversations_limit": 50, # 50 preview conversations/month
|
|
||||||
"code_export": False,
|
"code_export": False,
|
||||||
"analytics": False,
|
"analytics": False,
|
||||||
"features": ["preview_mode", "testing"],
|
"channels": [], # no messaging channels
|
||||||
|
"url_sources": 0,
|
||||||
|
"leads_per_month": 0,
|
||||||
|
"show_branding": True, # cannot remove badge
|
||||||
},
|
},
|
||||||
|
# ── Starter $12/mo ───────────────────────────────────────────────────────
|
||||||
|
# For individuals and solo businesses going live.
|
||||||
"starter": {
|
"starter": {
|
||||||
"max_chatbots": 999999,
|
"max_chatbots": 999999,
|
||||||
"max_published": 1,
|
"max_published": 1,
|
||||||
"max_documents_per_chatbot": 10,
|
"max_documents_per_chatbot": 10,
|
||||||
"max_document_size_mb": 10,
|
"max_document_size_mb": 10,
|
||||||
"models": [
|
"models": _ALL_FIREWORKS,
|
||||||
"accounts/fireworks/models/llama-v3p3-70b-instruct",
|
"conversations_limit": 1500,
|
||||||
"accounts/fireworks/models/qwen3-235b-a22b",
|
|
||||||
"accounts/fireworks/models/deepseek-v3p1",
|
|
||||||
"accounts/fireworks/models/kimi-k2-instruct-0905",
|
|
||||||
],
|
|
||||||
"conversations_limit": 500, # 500 conversations/month
|
|
||||||
"code_export": False,
|
"code_export": False,
|
||||||
"analytics": True,
|
"analytics": True,
|
||||||
"features": ["marketplace", "analytics", "branding"],
|
"channels": ["telegram"],
|
||||||
|
"url_sources": 5,
|
||||||
|
"leads_per_month": 500,
|
||||||
|
"show_branding": True, # badge stays
|
||||||
},
|
},
|
||||||
"pro": {
|
# ── Business $29/mo ──────────────────────────────────────────────────────
|
||||||
"max_chatbots": 5,
|
# For growing businesses that need more chatbots and WhatsApp reach.
|
||||||
"max_published": 5,
|
"business": {
|
||||||
|
"max_chatbots": 999999,
|
||||||
|
"max_published": 3,
|
||||||
"max_documents_per_chatbot": 50,
|
"max_documents_per_chatbot": 50,
|
||||||
"max_document_size_mb": 50,
|
"max_document_size_mb": 50,
|
||||||
"models": [
|
"models": _ALL_FIREWORKS + _ALL_PREMIUM,
|
||||||
# Fireworks (included)
|
"conversations_limit": 5000,
|
||||||
"accounts/fireworks/models/llama-v3p3-70b-instruct",
|
"code_export": False,
|
||||||
"accounts/fireworks/models/qwen3-235b-a22b",
|
|
||||||
"accounts/fireworks/models/deepseek-v3p1",
|
|
||||||
"accounts/fireworks/models/kimi-k2-instruct-0905",
|
|
||||||
# OpenAI
|
|
||||||
"gpt-4o",
|
|
||||||
"gpt-4o-mini",
|
|
||||||
# Anthropic
|
|
||||||
"claude-haiku-4-5-20251001",
|
|
||||||
# Google
|
|
||||||
"gemini-2.5-flash",
|
|
||||||
"gemini-2.5-lite",
|
|
||||||
"gemini-2.5-pro",
|
|
||||||
],
|
|
||||||
"conversations_limit": 2000, # 2,000 conversations/month
|
|
||||||
"code_export": True,
|
|
||||||
"analytics": True,
|
"analytics": True,
|
||||||
"features": [
|
"channels": ["telegram", "whatsapp"],
|
||||||
"marketplace",
|
"url_sources": 999999,
|
||||||
"code_export",
|
"leads_per_month": 999999,
|
||||||
"advanced_analytics",
|
"show_branding": False, # can remove badge
|
||||||
"priority_support",
|
|
||||||
"custom_domain",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
"enterprise": {
|
# ── Agency $79/mo ────────────────────────────────────────────────────────
|
||||||
|
# For agencies and large businesses managing many chatbots.
|
||||||
|
"agency": {
|
||||||
"max_chatbots": 999999,
|
"max_chatbots": 999999,
|
||||||
"max_published": 999999,
|
"max_published": 999999,
|
||||||
"max_documents_per_chatbot": 999999,
|
"max_documents_per_chatbot": 999999,
|
||||||
"max_document_size_mb": 200,
|
"max_document_size_mb": 200,
|
||||||
"models": ["*"], # resolves to all MODEL_CATALOG keys
|
"models": _ALL_FIREWORKS + _ALL_PREMIUM,
|
||||||
|
"conversations_limit": 20000,
|
||||||
|
"code_export": True,
|
||||||
|
"analytics": True,
|
||||||
|
"channels": ["telegram", "whatsapp"],
|
||||||
|
"url_sources": 999999,
|
||||||
|
"leads_per_month": 999999,
|
||||||
|
"show_branding": False,
|
||||||
|
},
|
||||||
|
# ── Enterprise (custom) ───────────────────────────────────────────────────
|
||||||
|
"enterprise": {
|
||||||
|
"max_chatbots": 999999,
|
||||||
|
"max_published": 999999,
|
||||||
|
"max_documents_per_chatbot": 999999,
|
||||||
|
"max_document_size_mb": 999999,
|
||||||
|
"models": ["*"],
|
||||||
"conversations_limit": 999999,
|
"conversations_limit": 999999,
|
||||||
"code_export": True,
|
"code_export": True,
|
||||||
"analytics": True,
|
"analytics": True,
|
||||||
"features": ["*"],
|
"channels": ["telegram", "whatsapp"],
|
||||||
|
"url_sources": 999999,
|
||||||
|
"leads_per_month": 999999,
|
||||||
|
"show_branding": False,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -70,7 +70,7 @@ async def get_user_subscription(user=Depends(get_current_user)):
|
|||||||
|
|
||||||
async def require_plan(min_plan: str, user=Depends(get_current_user)):
|
async def require_plan(min_plan: str, user=Depends(get_current_user)):
|
||||||
"""Require a minimum plan level"""
|
"""Require a minimum plan level"""
|
||||||
plan_order = ["free", "starter", "pro", "enterprise"]
|
plan_order = ["free", "starter", "business", "agency", "enterprise"]
|
||||||
subscription = await get_user_subscription(user)
|
subscription = await get_user_subscription(user)
|
||||||
user_plan = subscription.get("plan", "free")
|
user_plan = subscription.get("plan", "free")
|
||||||
|
|
||||||
|
|||||||
22
app/main.py
22
app/main.py
@@ -1,11 +1,14 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse, Response
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.routers import auth, chatbots, documents, chat, marketplace, billing, models, analytics
|
from app.routers import auth, chatbots, documents, chat, marketplace, billing, models, analytics, inbox, leads, upload
|
||||||
|
from app.routers.documents import router_url_sources
|
||||||
|
from app.routers.leads import leads_public_router
|
||||||
|
from app.routers.channels import router as channels_router, webhook_router as channels_webhook_router
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -52,6 +55,21 @@ app.include_router(marketplace.router, prefix="/api/v1")
|
|||||||
app.include_router(billing.router, prefix="/api/v1")
|
app.include_router(billing.router, prefix="/api/v1")
|
||||||
app.include_router(models.router, prefix="/api/v1")
|
app.include_router(models.router, prefix="/api/v1")
|
||||||
app.include_router(analytics.router, prefix="/api/v1")
|
app.include_router(analytics.router, prefix="/api/v1")
|
||||||
|
app.include_router(inbox.router, prefix="/api/v1")
|
||||||
|
app.include_router(leads.router, prefix="/api/v1")
|
||||||
|
app.include_router(upload.router, prefix="/api/v1")
|
||||||
|
app.include_router(router_url_sources, prefix="/api/v1")
|
||||||
|
app.include_router(leads_public_router, prefix="/api/v1")
|
||||||
|
app.include_router(channels_router, prefix="/api/v1")
|
||||||
|
app.include_router(channels_webhook_router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Widget ─────────────────────────────────────────────────────────────────────
|
||||||
|
@app.get("/widget.js")
|
||||||
|
async def serve_widget():
|
||||||
|
from app.services.widget import generate_widget_js
|
||||||
|
return Response(generate_widget_js(settings.app_url), media_type="application/javascript")
|
||||||
|
|
||||||
|
|
||||||
# ── Health & Info ──────────────────────────────────────────────────────────────
|
# ── Health & Info ──────────────────────────────────────────────────────────────
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
111
app/models.py
111
app/models.py
@@ -10,7 +10,8 @@ import uuid
|
|||||||
class PlanType(str, Enum):
|
class PlanType(str, Enum):
|
||||||
free = "free"
|
free = "free"
|
||||||
starter = "starter"
|
starter = "starter"
|
||||||
pro = "pro"
|
business = "business"
|
||||||
|
agency = "agency"
|
||||||
enterprise = "enterprise"
|
enterprise = "enterprise"
|
||||||
|
|
||||||
|
|
||||||
@@ -107,6 +108,14 @@ class ChatbotCreate(BaseModel):
|
|||||||
category: Optional[str] = None
|
category: Optional[str] = None
|
||||||
industry: Optional[str] = None
|
industry: Optional[str] = None
|
||||||
languages: List[str] = ["en"]
|
languages: List[str] = ["en"]
|
||||||
|
show_branding: bool = True
|
||||||
|
lead_capture_enabled: bool = False
|
||||||
|
lead_capture_fields: List[str] = ["email"]
|
||||||
|
lead_capture_trigger: str = "after_first_message"
|
||||||
|
handoff_enabled: bool = False
|
||||||
|
handoff_message: str = "I'll connect you with our team. Please wait."
|
||||||
|
handoff_email: Optional[str] = None
|
||||||
|
handoff_keywords: List[str] = ["human", "agent", "speak to someone", "talk to a person", "real person"]
|
||||||
|
|
||||||
|
|
||||||
class ChatbotUpdate(BaseModel):
|
class ChatbotUpdate(BaseModel):
|
||||||
@@ -122,6 +131,14 @@ class ChatbotUpdate(BaseModel):
|
|||||||
category: Optional[str] = None
|
category: Optional[str] = None
|
||||||
industry: Optional[str] = None
|
industry: Optional[str] = None
|
||||||
languages: Optional[List[str]] = None
|
languages: Optional[List[str]] = None
|
||||||
|
show_branding: Optional[bool] = None
|
||||||
|
lead_capture_enabled: Optional[bool] = None
|
||||||
|
lead_capture_fields: Optional[List[str]] = None
|
||||||
|
lead_capture_trigger: Optional[str] = None
|
||||||
|
handoff_enabled: Optional[bool] = None
|
||||||
|
handoff_message: Optional[str] = None
|
||||||
|
handoff_email: Optional[str] = None
|
||||||
|
handoff_keywords: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
class ChatbotResponse(BaseModel):
|
class ChatbotResponse(BaseModel):
|
||||||
@@ -147,6 +164,14 @@ class ChatbotResponse(BaseModel):
|
|||||||
average_rating: Optional[float] = None
|
average_rating: Optional[float] = None
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
published_at: Optional[datetime] = None
|
published_at: Optional[datetime] = None
|
||||||
|
show_branding: bool = True
|
||||||
|
lead_capture_enabled: bool = False
|
||||||
|
lead_capture_fields: List[str] = ["email"]
|
||||||
|
lead_capture_trigger: str = "after_first_message"
|
||||||
|
handoff_enabled: bool = False
|
||||||
|
handoff_message: str = "I'll connect you with our team. Please wait."
|
||||||
|
handoff_email: Optional[str] = None
|
||||||
|
handoff_keywords: List[str] = ["human", "agent", "speak to someone", "talk to a person", "real person"]
|
||||||
|
|
||||||
|
|
||||||
class ChatbotPublicResponse(BaseModel):
|
class ChatbotPublicResponse(BaseModel):
|
||||||
@@ -203,6 +228,8 @@ class ChatResponse(BaseModel):
|
|||||||
sources: List[SourceDocument] = []
|
sources: List[SourceDocument] = []
|
||||||
model_used: str
|
model_used: str
|
||||||
tokens_used: int = 0
|
tokens_used: int = 0
|
||||||
|
needs_lead_capture: bool = False
|
||||||
|
handoff: bool = False
|
||||||
|
|
||||||
|
|
||||||
class MessageResponse(BaseModel):
|
class MessageResponse(BaseModel):
|
||||||
@@ -239,7 +266,7 @@ class SubscriptionResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CheckoutSessionCreate(BaseModel):
|
class CheckoutSessionCreate(BaseModel):
|
||||||
plan: str # starter or pro
|
plan: str # starter, business, or agency
|
||||||
success_url: str
|
success_url: str
|
||||||
cancel_url: str
|
cancel_url: str
|
||||||
|
|
||||||
@@ -308,3 +335,83 @@ class SuccessResponse(BaseModel):
|
|||||||
class ErrorResponse(BaseModel):
|
class ErrorResponse(BaseModel):
|
||||||
error: str
|
error: str
|
||||||
detail: Optional[str] = None
|
detail: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Lead Models ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class LeadCreate(BaseModel):
|
||||||
|
email: Optional[str] = None
|
||||||
|
name: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
company: Optional[str] = None
|
||||||
|
conversation_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LeadResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
chatbot_id: str
|
||||||
|
conversation_id: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
name: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
company: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── URL Source Models ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class UrlSourceCreate(BaseModel):
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class UrlSourceResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
chatbot_id: str
|
||||||
|
url: str
|
||||||
|
status: str
|
||||||
|
page_title: Optional[str] = None
|
||||||
|
chunk_count: int = 0
|
||||||
|
error_message: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Feedback Models ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class FeedbackCreate(BaseModel):
|
||||||
|
message_id: str
|
||||||
|
feedback: str # 'positive' or 'negative'
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Inbox Models ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class InboxConversation(BaseModel):
|
||||||
|
id: str
|
||||||
|
chatbot_id: str
|
||||||
|
chatbot_name: str
|
||||||
|
session_id: Optional[str] = None
|
||||||
|
language: str
|
||||||
|
message_count: int
|
||||||
|
first_message: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class InboxMessage(BaseModel):
|
||||||
|
id: str
|
||||||
|
role: str
|
||||||
|
content: str
|
||||||
|
sources: Optional[List[Dict]] = None
|
||||||
|
confidence_score: Optional[float] = None
|
||||||
|
is_handoff: bool = False
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Channel Models ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ChannelConnectionResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
channel: str
|
||||||
|
bot_username: Optional[str] = None
|
||||||
|
wa_keyword: Optional[str] = None
|
||||||
|
wa_link: Optional[str] = None
|
||||||
|
is_active: bool
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
@@ -45,6 +45,10 @@ class ChatbotAnalyticsResponse(BaseModel):
|
|||||||
top_queries: List[TopQuery]
|
top_queries: List[TopQuery]
|
||||||
languages_used: Dict[str, int]
|
languages_used: Dict[str, int]
|
||||||
peak_hour: Optional[int] # 0-23
|
peak_hour: Optional[int] # 0-23
|
||||||
|
unanswered_count: int = 0
|
||||||
|
unanswered_queries: List[TopQuery] = []
|
||||||
|
feedback_positive: int = 0
|
||||||
|
feedback_negative: int = 0
|
||||||
|
|
||||||
|
|
||||||
class OverviewAnalyticsResponse(BaseModel):
|
class OverviewAnalyticsResponse(BaseModel):
|
||||||
@@ -212,6 +216,13 @@ async def get_analytics_overview(user=Depends(get_current_user)):
|
|||||||
if rating:
|
if rating:
|
||||||
all_ratings.append(rating)
|
all_ratings.append(rating)
|
||||||
|
|
||||||
|
# Feedback counts
|
||||||
|
fb_result = supabase.table("message_feedback").select("feedback", count="exact") \
|
||||||
|
.eq("chatbot_id", cid).execute()
|
||||||
|
total_fb = fb_result.count or 0
|
||||||
|
fb_pos = sum(1 for f in (fb_result.data or []) if f.get("feedback") == "positive")
|
||||||
|
fb_neg = total_fb - fb_pos
|
||||||
|
|
||||||
# Average messages per conversation
|
# Average messages per conversation
|
||||||
avg_msgs = round(msg_count / conv_count, 1) if conv_count > 0 else 0.0
|
avg_msgs = round(msg_count / conv_count, 1) if conv_count > 0 else 0.0
|
||||||
|
|
||||||
@@ -223,7 +234,7 @@ async def get_analytics_overview(user=Depends(get_current_user)):
|
|||||||
total_messages=msg_count,
|
total_messages=msg_count,
|
||||||
average_messages_per_conversation=avg_msgs,
|
average_messages_per_conversation=avg_msgs,
|
||||||
average_rating=rating,
|
average_rating=rating,
|
||||||
total_ratings=0, # would need a ratings table for precise count
|
total_ratings=total_fb,
|
||||||
conversations_today=today_count,
|
conversations_today=today_count,
|
||||||
conversations_this_week=week_count,
|
conversations_this_week=week_count,
|
||||||
conversations_this_month=month_count,
|
conversations_this_month=month_count,
|
||||||
@@ -231,6 +242,8 @@ async def get_analytics_overview(user=Depends(get_current_user)):
|
|||||||
top_queries=top_queries,
|
top_queries=top_queries,
|
||||||
languages_used=lang_counts,
|
languages_used=lang_counts,
|
||||||
peak_hour=peak,
|
peak_hour=peak,
|
||||||
|
feedback_positive=fb_pos,
|
||||||
|
feedback_negative=fb_neg,
|
||||||
))
|
))
|
||||||
|
|
||||||
# Overall average rating
|
# Overall average rating
|
||||||
@@ -345,6 +358,48 @@ async def get_chatbot_analytics(chatbot_id: str, user=Depends(get_current_user))
|
|||||||
|
|
||||||
avg_msgs = round(msg_count / conv_count, 1) if conv_count > 0 else 0.0
|
avg_msgs = round(msg_count / conv_count, 1) if conv_count > 0 else 0.0
|
||||||
|
|
||||||
|
# Feedback counts
|
||||||
|
fb_pos = 0
|
||||||
|
fb_neg = 0
|
||||||
|
if conv_ids and conv_ids != [""]:
|
||||||
|
feedback = supabase.table("message_feedback").select("feedback") \
|
||||||
|
.eq("chatbot_id", chatbot_id).execute()
|
||||||
|
for f in (feedback.data or []):
|
||||||
|
if f["feedback"] == "positive":
|
||||||
|
fb_pos += 1
|
||||||
|
else:
|
||||||
|
fb_neg += 1
|
||||||
|
|
||||||
|
# Unanswered queries (low confidence)
|
||||||
|
unanswered_queries: List[TopQuery] = []
|
||||||
|
unanswered_count = 0
|
||||||
|
if conv_ids and conv_ids != [""]:
|
||||||
|
try:
|
||||||
|
low_conf_msgs = supabase.table("messages").select("id, conversation_id, confidence_score") \
|
||||||
|
.in_("conversation_id", conv_ids[:100]) \
|
||||||
|
.eq("role", "assistant") \
|
||||||
|
.lt("confidence_score", 0.2) \
|
||||||
|
.limit(200).execute()
|
||||||
|
unanswered_count = len(low_conf_msgs.data or [])
|
||||||
|
# For each low-confidence assistant message, find the preceding user message
|
||||||
|
if low_conf_msgs.data:
|
||||||
|
unanswered_q_counts: Dict[str, int] = {}
|
||||||
|
for lm in low_conf_msgs.data[:20]: # limit work
|
||||||
|
prev_user = supabase.table("messages").select("content") \
|
||||||
|
.eq("conversation_id", lm["conversation_id"]) \
|
||||||
|
.eq("role", "user") \
|
||||||
|
.lt("created_at", lm.get("created_at", "9999")) \
|
||||||
|
.order("created_at", desc=True) \
|
||||||
|
.limit(1).execute()
|
||||||
|
if prev_user.data:
|
||||||
|
q = (prev_user.data[0].get("content") or "")[:100].strip()
|
||||||
|
if q:
|
||||||
|
unanswered_q_counts[q] = unanswered_q_counts.get(q, 0) + 1
|
||||||
|
top_unanswered = sorted(unanswered_q_counts.items(), key=lambda x: -x[1])[:5]
|
||||||
|
unanswered_queries = [TopQuery(query=q, count=n) for q, n in top_unanswered]
|
||||||
|
except Exception:
|
||||||
|
pass # unanswered queries is optional
|
||||||
|
|
||||||
return ChatbotAnalyticsResponse(
|
return ChatbotAnalyticsResponse(
|
||||||
chatbot_id=chatbot_id,
|
chatbot_id=chatbot_id,
|
||||||
chatbot_name=cb.get("name", "Untitled"),
|
chatbot_name=cb.get("name", "Untitled"),
|
||||||
@@ -353,7 +408,7 @@ async def get_chatbot_analytics(chatbot_id: str, user=Depends(get_current_user))
|
|||||||
total_messages=msg_count,
|
total_messages=msg_count,
|
||||||
average_messages_per_conversation=avg_msgs,
|
average_messages_per_conversation=avg_msgs,
|
||||||
average_rating=cb.get("average_rating"),
|
average_rating=cb.get("average_rating"),
|
||||||
total_ratings=0,
|
total_ratings=fb_pos + fb_neg,
|
||||||
conversations_today=today_count,
|
conversations_today=today_count,
|
||||||
conversations_this_week=week_count,
|
conversations_this_week=week_count,
|
||||||
conversations_this_month=month_count,
|
conversations_this_month=month_count,
|
||||||
@@ -361,4 +416,56 @@ async def get_chatbot_analytics(chatbot_id: str, user=Depends(get_current_user))
|
|||||||
top_queries=top_queries,
|
top_queries=top_queries,
|
||||||
languages_used=lang_counts,
|
languages_used=lang_counts,
|
||||||
peak_hour=peak,
|
peak_hour=peak,
|
||||||
|
unanswered_count=unanswered_count,
|
||||||
|
unanswered_queries=unanswered_queries,
|
||||||
|
feedback_positive=fb_pos,
|
||||||
|
feedback_negative=fb_neg,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/chatbot/{chatbot_id}/gaps", response_model=List[TopQuery])
|
||||||
|
async def get_knowledge_gaps(chatbot_id: str, user=Depends(get_current_user)):
|
||||||
|
"""Returns top queries where the bot had low confidence (knowledge gaps). Starter+ only."""
|
||||||
|
plan = _get_user_plan(user.id)
|
||||||
|
_check_analytics_access(plan)
|
||||||
|
|
||||||
|
supabase = get_supabase()
|
||||||
|
company = supabase.table("companies").select("id").eq("owner_id", user.id).execute()
|
||||||
|
if not company.data:
|
||||||
|
raise HTTPException(status_code=404, detail="Company not found")
|
||||||
|
|
||||||
|
chatbot = supabase.table("chatbots").select("id") \
|
||||||
|
.eq("id", chatbot_id).eq("company_id", company.data[0]["id"]).execute()
|
||||||
|
if not chatbot.data:
|
||||||
|
raise HTTPException(status_code=404, detail="Chatbot not found")
|
||||||
|
|
||||||
|
# Find conversations
|
||||||
|
convs = supabase.table("conversations").select("id").eq("chatbot_id", chatbot_id).execute()
|
||||||
|
conv_ids = [c["id"] for c in (convs.data or [])]
|
||||||
|
if not conv_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Low confidence assistant messages
|
||||||
|
low_conf = supabase.table("messages").select("id, conversation_id, created_at") \
|
||||||
|
.in_("conversation_id", conv_ids[:100]) \
|
||||||
|
.eq("role", "assistant") \
|
||||||
|
.lt("confidence_score", 0.2) \
|
||||||
|
.limit(100).execute()
|
||||||
|
|
||||||
|
if not low_conf.data:
|
||||||
|
return []
|
||||||
|
|
||||||
|
q_counts: Dict[str, int] = {}
|
||||||
|
for msg in low_conf.data[:30]:
|
||||||
|
prev = supabase.table("messages").select("content") \
|
||||||
|
.eq("conversation_id", msg["conversation_id"]) \
|
||||||
|
.eq("role", "user") \
|
||||||
|
.order("created_at", desc=True) \
|
||||||
|
.limit(1).execute()
|
||||||
|
if prev.data:
|
||||||
|
content = (prev.data[0].get("content") or "")[:100].strip()
|
||||||
|
if content:
|
||||||
|
q_counts[content] = q_counts.get(content, 0) + 1
|
||||||
|
|
||||||
|
sorted_gaps = sorted(q_counts.items(), key=lambda x: -x[1])[:10]
|
||||||
|
return [TopQuery(query=q, count=n) for q, n in sorted_gaps]
|
||||||
|
|||||||
@@ -2,15 +2,34 @@ from fastapi import APIRouter, HTTPException, status, Depends
|
|||||||
from app.models import UserSignup, UserLogin, UserResponse, TokenResponse
|
from app.models import UserSignup, UserLogin, UserResponse, TokenResponse
|
||||||
from app.database import get_supabase
|
from app.database import get_supabase
|
||||||
from app.dependencies import get_current_user
|
from app.dependencies import get_current_user
|
||||||
|
from app.config import settings
|
||||||
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
from typing import Optional
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
||||||
|
|
||||||
|
|
||||||
|
class ForgotPasswordRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class ResetPasswordRequest(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
new_password: str = Field(min_length=8)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileUpdate(BaseModel):
|
||||||
|
company_name: Optional[str] = None
|
||||||
|
current_password: Optional[str] = None
|
||||||
|
new_password: Optional[str] = Field(default=None, min_length=8)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/signup", response_model=TokenResponse)
|
@router.post("/signup", response_model=TokenResponse)
|
||||||
async def signup(data: UserSignup):
|
async def signup(data: UserSignup):
|
||||||
supabase = get_supabase()
|
supabase = get_supabase()
|
||||||
|
user_id = None
|
||||||
try:
|
try:
|
||||||
# Create auth user
|
# Create auth user
|
||||||
auth_resp = supabase.auth.sign_up(
|
auth_resp = supabase.auth.sign_up(
|
||||||
@@ -20,6 +39,7 @@ async def signup(data: UserSignup):
|
|||||||
raise HTTPException(status_code=400, detail="Failed to create account")
|
raise HTTPException(status_code=400, detail="Failed to create account")
|
||||||
|
|
||||||
user = auth_resp.user
|
user = auth_resp.user
|
||||||
|
user_id = user.id
|
||||||
|
|
||||||
# Create company record
|
# Create company record
|
||||||
supabase.table("companies").insert(
|
supabase.table("companies").insert(
|
||||||
@@ -52,6 +72,12 @@ async def signup(data: UserSignup):
|
|||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Signup error: {e}")
|
logger.error(f"Signup error: {e}")
|
||||||
|
# Rollback auth user if company/subscription creation failed
|
||||||
|
if user_id and "already registered" not in str(e).lower():
|
||||||
|
try:
|
||||||
|
supabase.auth.admin.delete_user(user_id)
|
||||||
|
except Exception as rb_err:
|
||||||
|
logger.error(f"Signup rollback failed: {rb_err}")
|
||||||
if "already registered" in str(e).lower() or "already exists" in str(e).lower():
|
if "already registered" in str(e).lower() or "already exists" in str(e).lower():
|
||||||
raise HTTPException(status_code=400, detail="Email already registered")
|
raise HTTPException(status_code=400, detail="Email already registered")
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -99,6 +125,85 @@ async def login(data: UserLogin):
|
|||||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/forgot-password")
|
||||||
|
async def forgot_password(data: ForgotPasswordRequest):
|
||||||
|
"""Send password reset email via Supabase. Always returns success to prevent email enumeration."""
|
||||||
|
supabase = get_supabase()
|
||||||
|
try:
|
||||||
|
supabase.auth.reset_password_for_email(
|
||||||
|
data.email,
|
||||||
|
options={"redirect_to": f"{settings.app_url}/reset-password"},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Forgot password request error (suppressed): {e}")
|
||||||
|
return {"message": "If that email is registered, a password reset link has been sent."}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reset-password")
|
||||||
|
async def reset_password(data: ResetPasswordRequest):
|
||||||
|
"""Reset user password using the recovery token from the reset email."""
|
||||||
|
supabase = get_supabase()
|
||||||
|
try:
|
||||||
|
user_response = supabase.auth.get_user(data.access_token)
|
||||||
|
if not user_response.user:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid or expired reset token")
|
||||||
|
supabase.auth.admin.update_user_by_id(
|
||||||
|
user_response.user.id,
|
||||||
|
{"password": data.new_password},
|
||||||
|
)
|
||||||
|
return {"message": "Password updated successfully"}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Password reset error: {e}")
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid or expired reset token")
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/profile", response_model=UserResponse)
|
||||||
|
async def update_profile(data: ProfileUpdate, user=Depends(get_current_user)):
|
||||||
|
"""Update company name and/or password."""
|
||||||
|
supabase = get_supabase()
|
||||||
|
|
||||||
|
if data.company_name:
|
||||||
|
supabase.table("companies").update({"name": data.company_name}).eq("owner_id", user.id).execute()
|
||||||
|
|
||||||
|
if data.new_password:
|
||||||
|
if not data.current_password:
|
||||||
|
raise HTTPException(status_code=400, detail="Current password required to change password")
|
||||||
|
try:
|
||||||
|
supabase.auth.sign_in_with_password({"email": user.email, "password": data.current_password})
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||||
|
supabase.auth.admin.update_user_by_id(user.id, {"password": data.new_password})
|
||||||
|
|
||||||
|
company = supabase.table("companies").select("name").eq("owner_id", user.id).execute()
|
||||||
|
company_name = company.data[0]["name"] if company.data else ""
|
||||||
|
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"
|
||||||
|
|
||||||
|
return UserResponse(id=user.id, email=user.email, company_name=company_name, plan=plan)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/account")
|
||||||
|
async def delete_account(user=Depends(get_current_user)):
|
||||||
|
"""Permanently delete account, company, chatbots, and all data."""
|
||||||
|
supabase = get_supabase()
|
||||||
|
|
||||||
|
company = supabase.table("companies").select("id").eq("owner_id", user.id).execute()
|
||||||
|
if company.data:
|
||||||
|
supabase.table("companies").delete().eq("id", company.data[0]["id"]).execute()
|
||||||
|
|
||||||
|
supabase.table("subscriptions").delete().eq("user_id", user.id).execute()
|
||||||
|
|
||||||
|
try:
|
||||||
|
supabase.auth.admin.delete_user(user.id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete auth user {user.id}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to delete account")
|
||||||
|
|
||||||
|
return {"message": "Account deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
async def logout(user=Depends(get_current_user)):
|
async def logout(user=Depends(get_current_user)):
|
||||||
supabase = get_supabase()
|
supabase = get_supabase()
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ router = APIRouter(prefix="/billing", tags=["Billing"])
|
|||||||
|
|
||||||
PLAN_PRICE_IDS = {
|
PLAN_PRICE_IDS = {
|
||||||
"starter": settings.stripe_starter_price_id,
|
"starter": settings.stripe_starter_price_id,
|
||||||
"pro": settings.stripe_pro_price_id,
|
"business": settings.stripe_business_price_id,
|
||||||
|
"agency": settings.stripe_agency_price_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -70,15 +71,20 @@ async def stripe_webhook(
|
|||||||
stripe.api_key = settings.stripe_secret_key
|
stripe.api_key = settings.stripe_secret_key
|
||||||
payload = await request.body()
|
payload = await request.body()
|
||||||
|
|
||||||
if settings.stripe_webhook_secret and stripe_signature:
|
if settings.stripe_webhook_secret:
|
||||||
|
if not stripe_signature:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing Stripe signature")
|
||||||
try:
|
try:
|
||||||
event = stripe.Webhook.construct_event(
|
event = stripe.Webhook.construct_event(
|
||||||
payload, stripe_signature, settings.stripe_webhook_secret
|
payload, stripe_signature, settings.stripe_webhook_secret
|
||||||
)
|
)
|
||||||
except stripe.error.SignatureVerificationError:
|
except stripe.error.SignatureVerificationError:
|
||||||
raise HTTPException(status_code=400, detail="Invalid signature")
|
raise HTTPException(status_code=400, detail="Invalid signature")
|
||||||
|
elif settings.app_env == "production":
|
||||||
|
raise HTTPException(status_code=500, detail="Webhook secret not configured")
|
||||||
else:
|
else:
|
||||||
import json
|
import json
|
||||||
|
logger.warning("Stripe webhook received without signature verification (dev mode only)")
|
||||||
event = json.loads(payload)
|
event = json.loads(payload)
|
||||||
|
|
||||||
supabase = get_supabase()
|
supabase = get_supabase()
|
||||||
@@ -122,6 +128,18 @@ async def stripe_webhook(
|
|||||||
"visibility": "preview",
|
"visibility": "preview",
|
||||||
}).eq("company_id", company.data[0]["id"]).execute()
|
}).eq("company_id", company.data[0]["id"]).execute()
|
||||||
|
|
||||||
|
# Send cancellation notification via n8n
|
||||||
|
if settings.n8n_handoff_webhook_url:
|
||||||
|
try:
|
||||||
|
from app.services.n8n_service import send_notification
|
||||||
|
await send_notification(
|
||||||
|
event_type="subscription_canceled",
|
||||||
|
data={"user_id": user_id},
|
||||||
|
webhook_url=settings.n8n_handoff_webhook_url,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to send cancellation notification: {e}")
|
||||||
|
|
||||||
return {"received": True}
|
return {"received": True}
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|||||||
518
app/routers/channels.py
Normal file
518
app/routers/channels.py
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
import json
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from app.config import settings, PLAN_LIMITS
|
||||||
|
from app.database import get_supabase
|
||||||
|
from app.dependencies import get_current_user
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/channels", tags=["Channels"])
|
||||||
|
webhook_router = APIRouter(prefix="/webhooks", tags=["Webhooks"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Request models ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TelegramConnectRequest(BaseModel):
|
||||||
|
chatbot_id: str
|
||||||
|
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):
|
||||||
|
company = supabase.table("companies").select("id").eq("owner_id", user_id).execute()
|
||||||
|
if not company.data:
|
||||||
|
raise HTTPException(status_code=404, detail="Company not found")
|
||||||
|
chatbot = (
|
||||||
|
supabase.table("chatbots").select("id")
|
||||||
|
.eq("id", chatbot_id)
|
||||||
|
.eq("company_id", company.data[0]["id"])
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
if not chatbot.data:
|
||||||
|
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:
|
||||||
|
existing = (
|
||||||
|
supabase.table("channel_sessions")
|
||||||
|
.select("*").eq("channel", channel).eq("external_id", external_id).execute()
|
||||||
|
)
|
||||||
|
if existing.data:
|
||||||
|
return existing.data[0]
|
||||||
|
row = {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"chatbot_id": chatbot_id,
|
||||||
|
"channel": channel,
|
||||||
|
"external_id": external_id,
|
||||||
|
"session_id": str(uuid.uuid4()),
|
||||||
|
}
|
||||||
|
result = supabase.table("channel_sessions").insert(row).execute()
|
||||||
|
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")
|
||||||
|
raise HTTPException(status_code=402, detail=f"{channel.title()} channel requires {label} plan or higher")
|
||||||
|
|
||||||
|
|
||||||
|
def _check_chatbot_channel_subscription(chatbot_id: str, channel: str, supabase) -> bool:
|
||||||
|
"""Return False (drop message) if the chatbot owner's plan no longer includes this channel."""
|
||||||
|
chatbot = supabase.table("chatbots").select("company_id").eq("id", chatbot_id).execute()
|
||||||
|
if not chatbot.data:
|
||||||
|
return False
|
||||||
|
company = supabase.table("companies").select("owner_id").eq("id", chatbot.data[0]["company_id"]).execute()
|
||||||
|
if not company.data:
|
||||||
|
return False
|
||||||
|
owner_id = company.data[0]["owner_id"]
|
||||||
|
sub = supabase.table("subscriptions").select("plan, status").eq("user_id", owner_id).execute()
|
||||||
|
if not sub.data:
|
||||||
|
return False
|
||||||
|
row = sub.data[0]
|
||||||
|
if row.get("status") != "active":
|
||||||
|
return False
|
||||||
|
allowed = PLAN_LIMITS.get(row.get("plan", "free"), PLAN_LIMITS["free"]).get("channels", [])
|
||||||
|
return channel in allowed
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_language(text: str) -> str:
|
||||||
|
"""Simple script-based language detection for channel messages."""
|
||||||
|
sample = text[:200]
|
||||||
|
total = sum(1 for c in sample if not c.isspace())
|
||||||
|
if total == 0:
|
||||||
|
return "en"
|
||||||
|
scores = {
|
||||||
|
"ar": sum(1 for c in sample if "\u0600" <= c <= "\u06FF") / total,
|
||||||
|
"zh": sum(1 for c in sample if "\u4E00" <= c <= "\u9FFF") / total,
|
||||||
|
"ja": sum(1 for c in sample if "\u3040" <= c <= "\u30FF") / total,
|
||||||
|
"he": sum(1 for c in sample if "\u0590" <= c <= "\u05FF") / total,
|
||||||
|
"ru": sum(1 for c in sample if "\u0400" <= c <= "\u04FF") / total,
|
||||||
|
"fr": 0.0, "es": 0.0, # Latin-based — can't detect from scripts alone
|
||||||
|
}
|
||||||
|
best = max(scores, key=scores.get)
|
||||||
|
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("")
|
||||||
|
async def list_channels(chatbot_id: str = Query(...), user=Depends(get_current_user)):
|
||||||
|
supabase = get_supabase()
|
||||||
|
_verify_chatbot_ownership(chatbot_id, user.id, supabase)
|
||||||
|
result = (
|
||||||
|
supabase.table("channel_connections")
|
||||||
|
.select("id,channel,bot_username,wa_keyword,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
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/telegram")
|
||||||
|
async def connect_telegram(data: TelegramConnectRequest, user=Depends(get_current_user)):
|
||||||
|
supabase = get_supabase()
|
||||||
|
_verify_chatbot_ownership(data.chatbot_id, user.id, supabase)
|
||||||
|
_check_channel_plan(user.id, "telegram", supabase)
|
||||||
|
|
||||||
|
bot_info = await get_bot_info(data.bot_token)
|
||||||
|
if not bot_info:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Invalid bot token. Check your token from @BotFather.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not settings.api_url:
|
||||||
|
raise HTTPException(status_code=500, detail="API_URL not configured on the server")
|
||||||
|
|
||||||
|
webhook_url = f"{settings.api_url.rstrip('/')}/api/v1/webhooks/telegram/{data.bot_token}"
|
||||||
|
ok = await set_webhook(data.bot_token, webhook_url)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to register webhook with Telegram")
|
||||||
|
|
||||||
|
existing = (
|
||||||
|
supabase.table("channel_connections")
|
||||||
|
.select("id").eq("chatbot_id", data.chatbot_id).eq("channel", "telegram").execute()
|
||||||
|
)
|
||||||
|
conn_data = {
|
||||||
|
"chatbot_id": data.chatbot_id,
|
||||||
|
"channel": "telegram",
|
||||||
|
"bot_token": data.bot_token,
|
||||||
|
"bot_username": bot_info.get("username", ""),
|
||||||
|
"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()
|
||||||
|
|
||||||
|
username = bot_info.get("username", "")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"bot_username": username,
|
||||||
|
"bot_link": f"https://t.me/{username}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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()
|
||||||
|
conn = supabase.table("channel_connections").select("*").eq("id", connection_id).execute()
|
||||||
|
if not conn.data:
|
||||||
|
raise HTTPException(status_code=404, detail="Connection not found")
|
||||||
|
|
||||||
|
c = conn.data[0]
|
||||||
|
_verify_chatbot_ownership(c["chatbot_id"], user.id, supabase)
|
||||||
|
|
||||||
|
if c["channel"] == "telegram" and c.get("bot_token"):
|
||||||
|
try:
|
||||||
|
await delete_webhook(c["bot_token"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
supabase.table("channel_connections").delete().eq("id", connection_id).execute()
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Telegram webhook ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@webhook_router.post("/telegram/{bot_token}")
|
||||||
|
async def telegram_webhook(bot_token: str, request: Request):
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
message = body.get("message") or body.get("edited_message")
|
||||||
|
if not message or "text" not in message:
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
chat_id = message["chat"]["id"]
|
||||||
|
text = message["text"].strip()
|
||||||
|
|
||||||
|
supabase = get_supabase()
|
||||||
|
conn = (
|
||||||
|
supabase.table("channel_connections")
|
||||||
|
.select("*").eq("channel", "telegram").eq("bot_token", bot_token).eq("is_active", True).execute()
|
||||||
|
)
|
||||||
|
if not conn.data:
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
chatbot_id = conn.data[0]["chatbot_id"]
|
||||||
|
|
||||||
|
# Check subscription still allows Telegram
|
||||||
|
if not _check_chatbot_channel_subscription(chatbot_id, "telegram", supabase):
|
||||||
|
await tg_send(bot_token, chat_id, "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]
|
||||||
|
|
||||||
|
if not chatbot.get("is_published"):
|
||||||
|
await tg_send(bot_token, chat_id, "This chatbot is not yet published.")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
collection_name = chatbot.get("qdrant_collection_name")
|
||||||
|
if not collection_name:
|
||||||
|
await tg_send(bot_token, chat_id, "This chatbot has no knowledge base configured yet.")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
if text.startswith("/start"):
|
||||||
|
welcome = chatbot.get("welcome_message") or f"Hello! I'm {chatbot.get('name', 'your assistant')}. How can I help you?"
|
||||||
|
await tg_send(bot_token, chat_id, welcome)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
# Use first 8 chars of token as namespace to avoid collisions between bots
|
||||||
|
external_id = f"tg:{bot_token[:8]}:{chat_id}"
|
||||||
|
session = _get_or_create_channel_session(chatbot_id, "telegram", external_id, supabase)
|
||||||
|
|
||||||
|
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"Telegram RAG error for chatbot {chatbot_id}: {e}")
|
||||||
|
await tg_send(bot_token, chat_id, "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 tg_send(bot_token, chat_id, result["response"])
|
||||||
|
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}
|
||||||
@@ -1,15 +1,73 @@
|
|||||||
from fastapi import APIRouter, HTTPException, Depends
|
import time
|
||||||
from app.models import ChatMessage, ChatResponse, ConversationResponse, MessageResponse
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||||
|
from app.models import ChatMessage, ChatResponse, ConversationResponse, MessageResponse, FeedbackCreate
|
||||||
from app.database import get_supabase
|
from app.database import get_supabase
|
||||||
from app.dependencies import get_current_user, get_optional_user
|
from app.dependencies import get_current_user, get_optional_user
|
||||||
from app.services.rag import rag_engine
|
from app.services.rag import rag_engine
|
||||||
|
from app.config import PLAN_LIMITS
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
import uuid
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(tags=["Chat"])
|
router = APIRouter(tags=["Chat"])
|
||||||
|
|
||||||
|
# ── Simple in-memory rate limiter ────────────────────────────────────────────
|
||||||
|
_rate_store: dict = defaultdict(list)
|
||||||
|
_RATE_LIMIT = 30 # max requests
|
||||||
|
_RATE_WINDOW = 60 # per second window
|
||||||
|
|
||||||
|
|
||||||
|
def _check_rate_limit(client_ip: str):
|
||||||
|
now = time.time()
|
||||||
|
_rate_store[client_ip] = [t for t in _rate_store[client_ip] if now - t < _RATE_WINDOW]
|
||||||
|
if len(_rate_store[client_ip]) >= _RATE_LIMIT:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Too many requests. Please wait before sending more messages.",
|
||||||
|
)
|
||||||
|
_rate_store[client_ip].append(now)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_conversation_limit(chatbot: dict, supabase):
|
||||||
|
"""Check if the chatbot owner has remaining conversation quota this month."""
|
||||||
|
company_id = chatbot.get("company_id")
|
||||||
|
if not company_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
company = supabase.table("companies").select("owner_id").eq("id", company_id).execute()
|
||||||
|
if not company.data:
|
||||||
|
return
|
||||||
|
|
||||||
|
owner_id = company.data[0]["owner_id"]
|
||||||
|
sub = supabase.table("subscriptions").select("plan").eq("user_id", owner_id).eq("status", "active").execute()
|
||||||
|
plan = sub.data[0]["plan"] if sub.data else "free"
|
||||||
|
limit = PLAN_LIMITS.get(plan, PLAN_LIMITS["free"]).get("conversations_limit", 100)
|
||||||
|
|
||||||
|
if limit >= 999999:
|
||||||
|
return # unlimited
|
||||||
|
|
||||||
|
month_start = datetime.utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat()
|
||||||
|
chatbots = supabase.table("chatbots").select("id").eq("company_id", company_id).execute()
|
||||||
|
chatbot_ids = [c["id"] for c in (chatbots.data or [])]
|
||||||
|
if not chatbot_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
count_result = supabase.table("conversations").select("id", count="exact") \
|
||||||
|
.in_("chatbot_id", chatbot_ids) \
|
||||||
|
.gte("created_at", month_start) \
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
used = count_result.count or 0
|
||||||
|
if used >= limit:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail=f"This chatbot's monthly conversation limit ({limit}) has been reached. Please try again next month.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _get_public_chatbot(chatbot_id: str, supabase) -> dict:
|
def _get_public_chatbot(chatbot_id: str, supabase) -> dict:
|
||||||
"""Get a published chatbot (or any chatbot for preview)"""
|
"""Get a published chatbot (or any chatbot for preview)"""
|
||||||
@@ -23,8 +81,13 @@ def _get_public_chatbot(chatbot_id: str, supabase) -> dict:
|
|||||||
async def chat(
|
async def chat(
|
||||||
chatbot_id: str,
|
chatbot_id: str,
|
||||||
message: ChatMessage,
|
message: ChatMessage,
|
||||||
|
request: Request,
|
||||||
user=Depends(get_optional_user),
|
user=Depends(get_optional_user),
|
||||||
):
|
):
|
||||||
|
# Rate limiting by IP
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
_check_rate_limit(client_ip)
|
||||||
|
|
||||||
supabase = get_supabase()
|
supabase = get_supabase()
|
||||||
chatbot = _get_public_chatbot(chatbot_id, supabase)
|
chatbot = _get_public_chatbot(chatbot_id, supabase)
|
||||||
|
|
||||||
@@ -43,6 +106,13 @@ async def chat(
|
|||||||
|
|
||||||
# Get or create conversation
|
# Get or create conversation
|
||||||
session_id = message.session_id or str(uuid.uuid4())
|
session_id = message.session_id or str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Only enforce conversation limit for new sessions (not ongoing ones)
|
||||||
|
is_existing = supabase.table("conversations").select("id") \
|
||||||
|
.eq("chatbot_id", chatbot_id).eq("session_id", session_id).execute()
|
||||||
|
if not is_existing.data:
|
||||||
|
_check_conversation_limit(chatbot, supabase)
|
||||||
|
|
||||||
conversation = _get_or_create_conversation(
|
conversation = _get_or_create_conversation(
|
||||||
chatbot_id=chatbot_id,
|
chatbot_id=chatbot_id,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
@@ -70,6 +140,42 @@ async def chat(
|
|||||||
language=message.language,
|
language=message.language,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Compute confidence score
|
||||||
|
confidence_score = max((s.score for s in result.get("sources", [])), default=0.0)
|
||||||
|
|
||||||
|
# Check handoff
|
||||||
|
is_handoff = False
|
||||||
|
if chatbot.get("handoff_enabled"):
|
||||||
|
handoff_keywords = chatbot.get("handoff_keywords", [])
|
||||||
|
msg_lower = message.message.lower()
|
||||||
|
if any(kw.lower() in msg_lower for kw in handoff_keywords):
|
||||||
|
is_handoff = True
|
||||||
|
# Fire n8n notification (async, non-blocking)
|
||||||
|
try:
|
||||||
|
from app.services.n8n_service import send_handoff_notification
|
||||||
|
from app.config import settings as _settings
|
||||||
|
company_data_for_handoff = chatbot.get("companies", {}) or {}
|
||||||
|
await send_handoff_notification(
|
||||||
|
chatbot_name=chatbot.get("name", ""),
|
||||||
|
owner_email=chatbot.get("handoff_email") or "",
|
||||||
|
conversation_history=history,
|
||||||
|
trigger_message=message.message,
|
||||||
|
chatbot_id=chatbot_id,
|
||||||
|
conversation_id=conversation["id"],
|
||||||
|
webhook_url=_settings.n8n_handoff_webhook_url,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # never block chat on handoff failure
|
||||||
|
|
||||||
|
# Check lead capture
|
||||||
|
needs_lead_capture = False
|
||||||
|
if (
|
||||||
|
chatbot.get("lead_capture_enabled")
|
||||||
|
and chatbot.get("lead_capture_trigger") == "after_first_message"
|
||||||
|
and len(history) == 0
|
||||||
|
):
|
||||||
|
needs_lead_capture = True
|
||||||
|
|
||||||
# Save messages
|
# Save messages
|
||||||
_save_message(conversation["id"], "user", message.message, supabase)
|
_save_message(conversation["id"], "user", message.message, supabase)
|
||||||
_save_message(
|
_save_message(
|
||||||
@@ -79,6 +185,8 @@ async def chat(
|
|||||||
supabase,
|
supabase,
|
||||||
sources=[s.model_dump() for s in result.get("sources", [])],
|
sources=[s.model_dump() for s in result.get("sources", [])],
|
||||||
model=result.get("model", ""),
|
model=result.get("model", ""),
|
||||||
|
confidence_score=confidence_score,
|
||||||
|
is_handoff=is_handoff,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update conversation message count
|
# Update conversation message count
|
||||||
@@ -92,6 +200,8 @@ async def chat(
|
|||||||
sources=result.get("sources", []),
|
sources=result.get("sources", []),
|
||||||
model_used=result.get("model", ""),
|
model_used=result.get("model", ""),
|
||||||
tokens_used=result.get("tokens_used", 0),
|
tokens_used=result.get("tokens_used", 0),
|
||||||
|
needs_lead_capture=needs_lead_capture,
|
||||||
|
handoff=is_handoff,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -129,6 +239,30 @@ async def get_chat_history(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chat/{chatbot_id}/feedback")
|
||||||
|
async def submit_feedback(chatbot_id: str, data: FeedbackCreate):
|
||||||
|
"""Submit feedback (thumbs up/down) for a message. No auth required."""
|
||||||
|
import uuid as _uuid
|
||||||
|
if data.feedback not in ("positive", "negative"):
|
||||||
|
raise HTTPException(status_code=400, detail="Feedback must be 'positive' or 'negative'")
|
||||||
|
|
||||||
|
supabase = get_supabase()
|
||||||
|
|
||||||
|
# Verify message exists
|
||||||
|
msg = supabase.table("messages").select("id, conversation_id").eq("id", data.message_id).execute()
|
||||||
|
if not msg.data:
|
||||||
|
raise HTTPException(status_code=404, detail="Message not found")
|
||||||
|
|
||||||
|
supabase.table("message_feedback").insert({
|
||||||
|
"id": str(_uuid.uuid4()),
|
||||||
|
"message_id": data.message_id,
|
||||||
|
"chatbot_id": chatbot_id,
|
||||||
|
"feedback": data.feedback,
|
||||||
|
}).execute()
|
||||||
|
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
# ── OLD analytics endpoint REMOVED ───────────────────────────────────────────
|
# ── OLD analytics endpoint REMOVED ───────────────────────────────────────────
|
||||||
# The /analytics/{chatbot_id} endpoint that was here has been replaced by
|
# The /analytics/{chatbot_id} endpoint that was here has been replaced by
|
||||||
# the dedicated analytics router (app/routers/analytics.py) which provides:
|
# the dedicated analytics router (app/routers/analytics.py) which provides:
|
||||||
@@ -188,6 +322,8 @@ def _save_message(
|
|||||||
supabase,
|
supabase,
|
||||||
sources: Optional[list] = None,
|
sources: Optional[list] = None,
|
||||||
model: str = "",
|
model: str = "",
|
||||||
|
confidence_score: Optional[float] = None,
|
||||||
|
is_handoff: bool = False,
|
||||||
):
|
):
|
||||||
supabase.table("messages").insert({
|
supabase.table("messages").insert({
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
@@ -196,4 +332,6 @@ def _save_message(
|
|||||||
"content": content,
|
"content": content,
|
||||||
"sources": sources,
|
"sources": sources,
|
||||||
"model": model,
|
"model": model,
|
||||||
|
"confidence_score": confidence_score,
|
||||||
|
"is_handoff": is_handoff,
|
||||||
}).execute()
|
}).execute()
|
||||||
@@ -73,6 +73,14 @@ async def create_chatbot(data: ChatbotCreate, user=Depends(get_current_user)):
|
|||||||
"visibility": "preview",
|
"visibility": "preview",
|
||||||
"is_published": False,
|
"is_published": False,
|
||||||
"qdrant_collection_name": collection_name,
|
"qdrant_collection_name": collection_name,
|
||||||
|
"show_branding": data.show_branding,
|
||||||
|
"lead_capture_enabled": data.lead_capture_enabled,
|
||||||
|
"lead_capture_fields": data.lead_capture_fields,
|
||||||
|
"lead_capture_trigger": data.lead_capture_trigger,
|
||||||
|
"handoff_enabled": data.handoff_enabled,
|
||||||
|
"handoff_message": data.handoff_message,
|
||||||
|
"handoff_email": data.handoff_email,
|
||||||
|
"handoff_keywords": data.handoff_keywords,
|
||||||
}
|
}
|
||||||
|
|
||||||
result = supabase.table("chatbots").insert(chatbot_data).execute()
|
result = supabase.table("chatbots").insert(chatbot_data).execute()
|
||||||
@@ -180,8 +188,8 @@ async def export_chatbot(chatbot_id: str, user=Depends(get_current_user)):
|
|||||||
# Check plan
|
# Check plan
|
||||||
sub = supabase.table("subscriptions").select("plan").eq("user_id", user.id).eq("status", "active").execute()
|
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"
|
plan = sub.data[0]["plan"] if sub.data else "free"
|
||||||
if plan not in ("pro", "enterprise"):
|
if plan not in ("agency", "enterprise"):
|
||||||
raise HTTPException(status_code=402, detail="Code export requires Pro plan or higher")
|
raise HTTPException(status_code=402, detail="Code export requires Agency plan or higher")
|
||||||
|
|
||||||
zip_bytes = generate_export_package(
|
zip_bytes = generate_export_package(
|
||||||
chatbot=chatbot,
|
chatbot=chatbot,
|
||||||
@@ -198,6 +206,55 @@ async def export_chatbot(chatbot_id: str, user=Depends(get_current_user)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{chatbot_id}/public")
|
||||||
|
async def get_chatbot_public(chatbot_id: str):
|
||||||
|
"""Public endpoint - returns basic info for a published chatbot (used by PublicChatPage)."""
|
||||||
|
supabase = get_supabase()
|
||||||
|
result = supabase.table("chatbots").select("id, name, welcome_message, primary_color, logo_url, show_branding, is_published, description").eq("id", chatbot_id).execute()
|
||||||
|
if not result.data:
|
||||||
|
raise HTTPException(status_code=404, detail="Chatbot not found")
|
||||||
|
chatbot = result.data[0]
|
||||||
|
if not chatbot.get("is_published"):
|
||||||
|
raise HTTPException(status_code=404, detail="Chatbot not found or not published")
|
||||||
|
return {
|
||||||
|
"id": chatbot["id"],
|
||||||
|
"name": chatbot["name"],
|
||||||
|
"welcome_message": chatbot.get("welcome_message", "Hello! How can I help?"),
|
||||||
|
"primary_color": chatbot.get("primary_color", "#6366f1"),
|
||||||
|
"logo_url": chatbot.get("logo_url"),
|
||||||
|
"show_branding": chatbot.get("show_branding", True),
|
||||||
|
"is_published": chatbot.get("is_published", False),
|
||||||
|
"description": chatbot.get("description"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{chatbot_id}/embed")
|
||||||
|
async def get_chatbot_embed(chatbot_id: str, user=Depends(get_current_user)):
|
||||||
|
"""Returns embed info including the widget script tag for a chatbot."""
|
||||||
|
from app.config import settings
|
||||||
|
supabase = get_supabase()
|
||||||
|
company = _get_user_company(user.id, supabase)
|
||||||
|
chatbot = _get_owned_chatbot(chatbot_id, company["id"], supabase)
|
||||||
|
|
||||||
|
api_url = "http://localhost:8000" # In production, read from settings
|
||||||
|
app_url = settings.app_url
|
||||||
|
|
||||||
|
embed_script = f'<script src="{api_url}/widget.js" data-chatbot="{chatbot_id}"></script>'
|
||||||
|
chat_url = f"{app_url}/chat/{chatbot_id}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"chatbot_id": chatbot_id,
|
||||||
|
"name": chatbot.get("name"),
|
||||||
|
"primary_color": chatbot.get("primary_color", "#6366f1"),
|
||||||
|
"welcome_message": chatbot.get("welcome_message"),
|
||||||
|
"logo_url": chatbot.get("logo_url"),
|
||||||
|
"show_branding": chatbot.get("show_branding", True),
|
||||||
|
"embed_script": embed_script,
|
||||||
|
"chat_url": chat_url,
|
||||||
|
"is_published": chatbot.get("is_published", False),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _get_owned_chatbot(chatbot_id: str, company_id: str, supabase) -> dict:
|
def _get_owned_chatbot(chatbot_id: str, company_id: str, supabase) -> dict:
|
||||||
@@ -238,4 +295,12 @@ def _format_chatbot(chatbot: dict, supabase) -> ChatbotResponse:
|
|||||||
conversation_count=conv_count.count or 0,
|
conversation_count=conv_count.count or 0,
|
||||||
created_at=chatbot.get("created_at"),
|
created_at=chatbot.get("created_at"),
|
||||||
published_at=chatbot.get("published_at"),
|
published_at=chatbot.get("published_at"),
|
||||||
|
show_branding=chatbot.get("show_branding", True),
|
||||||
|
lead_capture_enabled=chatbot.get("lead_capture_enabled", False),
|
||||||
|
lead_capture_fields=chatbot.get("lead_capture_fields") or ["email"],
|
||||||
|
lead_capture_trigger=chatbot.get("lead_capture_trigger", "after_first_message"),
|
||||||
|
handoff_enabled=chatbot.get("handoff_enabled", False),
|
||||||
|
handoff_message=chatbot.get("handoff_message", "I'll connect you with our team. Please wait."),
|
||||||
|
handoff_email=chatbot.get("handoff_email"),
|
||||||
|
handoff_keywords=chatbot.get("handoff_keywords") or ["human", "agent", "speak to someone", "talk to a person", "real person"],
|
||||||
)
|
)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks
|
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks
|
||||||
from app.models import DocumentResponse, SuccessResponse
|
from app.models import DocumentResponse, SuccessResponse, UrlSourceCreate, UrlSourceResponse
|
||||||
from app.database import get_supabase
|
from app.database import get_supabase
|
||||||
from app.dependencies import get_current_user
|
from app.dependencies import get_current_user
|
||||||
from app.services.document_processor import process_document
|
from app.services.document_processor import process_document
|
||||||
@@ -12,6 +12,7 @@ import logging
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/chatbots/{chatbot_id}/documents", tags=["Documents"])
|
router = APIRouter(prefix="/chatbots/{chatbot_id}/documents", tags=["Documents"])
|
||||||
|
url_router = APIRouter(prefix="/chatbots/{chatbot_id}/url-sources", tags=["URL Sources"])
|
||||||
|
|
||||||
ALLOWED_TYPES = {
|
ALLOWED_TYPES = {
|
||||||
"application/pdf": ".pdf",
|
"application/pdf": ".pdf",
|
||||||
@@ -206,3 +207,168 @@ async def delete_document(chatbot_id: str, document_id: str, user=Depends(get_cu
|
|||||||
|
|
||||||
supabase.table("documents").delete().eq("id", document_id).execute()
|
supabase.table("documents").delete().eq("id", document_id).execute()
|
||||||
return SuccessResponse(success=True, message="Document deleted")
|
return SuccessResponse(success=True, message="Document deleted")
|
||||||
|
|
||||||
|
|
||||||
|
# ── URL Sources ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@url_router.get("", response_model=List[UrlSourceResponse])
|
||||||
|
async def list_url_sources(chatbot_id: str, user=Depends(get_current_user)):
|
||||||
|
supabase = get_supabase()
|
||||||
|
_get_user_chatbot(chatbot_id, user.id, supabase)
|
||||||
|
result = supabase.table("url_sources").select("*") \
|
||||||
|
.eq("chatbot_id", chatbot_id) \
|
||||||
|
.order("created_at", desc=True) \
|
||||||
|
.execute()
|
||||||
|
return result.data or []
|
||||||
|
|
||||||
|
|
||||||
|
@url_router.post("", status_code=201)
|
||||||
|
async def add_url_source(
|
||||||
|
chatbot_id: str,
|
||||||
|
data: UrlSourceCreate,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
from app.config import PLAN_LIMITS
|
||||||
|
supabase = get_supabase()
|
||||||
|
chatbot = _get_user_chatbot(chatbot_id, user.id, supabase)
|
||||||
|
|
||||||
|
# Plan check
|
||||||
|
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"
|
||||||
|
max_sources = PLAN_LIMITS.get(plan, PLAN_LIMITS["free"]).get("url_sources", 0)
|
||||||
|
if max_sources == 0:
|
||||||
|
raise HTTPException(status_code=402, detail="URL sources require Starter plan or higher")
|
||||||
|
|
||||||
|
# Count existing
|
||||||
|
existing = supabase.table("url_sources").select("id", count="exact").eq("chatbot_id", chatbot_id).execute()
|
||||||
|
if (existing.count or 0) >= max_sources:
|
||||||
|
raise HTTPException(status_code=402, detail=f"URL source limit reached ({max_sources}). Upgrade to add more.")
|
||||||
|
|
||||||
|
source_id = str(uuid.uuid4())
|
||||||
|
source_data = {
|
||||||
|
"id": source_id,
|
||||||
|
"chatbot_id": chatbot_id,
|
||||||
|
"url": data.url,
|
||||||
|
"status": "pending",
|
||||||
|
}
|
||||||
|
result = supabase.table("url_sources").insert(source_data).execute()
|
||||||
|
if not result.data:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create URL source")
|
||||||
|
|
||||||
|
# Process in background
|
||||||
|
background_tasks.add_task(
|
||||||
|
_process_url_source,
|
||||||
|
source_id=source_id,
|
||||||
|
url=data.url,
|
||||||
|
chatbot=chatbot,
|
||||||
|
supabase=supabase,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.data[0]
|
||||||
|
|
||||||
|
|
||||||
|
@url_router.delete("/{source_id}", response_model=SuccessResponse)
|
||||||
|
async def delete_url_source(chatbot_id: str, source_id: str, user=Depends(get_current_user)):
|
||||||
|
supabase = get_supabase()
|
||||||
|
_get_user_chatbot(chatbot_id, user.id, supabase)
|
||||||
|
|
||||||
|
source = supabase.table("url_sources").select("*").eq("id", source_id).eq("chatbot_id", chatbot_id).execute()
|
||||||
|
if not source.data:
|
||||||
|
raise HTTPException(status_code=404, detail="URL source not found")
|
||||||
|
|
||||||
|
supabase.table("url_sources").delete().eq("id", source_id).execute()
|
||||||
|
return SuccessResponse(success=True, message="URL source deleted")
|
||||||
|
|
||||||
|
|
||||||
|
async def _process_url_source(source_id: str, url: str, chatbot: dict, supabase):
|
||||||
|
"""Background task to scrape a URL and add its content to the vector store."""
|
||||||
|
from app.services.web_scraper import scrape_url
|
||||||
|
from app.services.document_processor import chunk_text
|
||||||
|
from app.services.embeddings import embedding_service
|
||||||
|
from app.services.vector_store import vector_store
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Update status to processing
|
||||||
|
supabase.table("url_sources").update({"status": "processing"}).eq("id", source_id).execute()
|
||||||
|
|
||||||
|
# Scrape URL
|
||||||
|
scraped = await scrape_url(url)
|
||||||
|
if "error" in scraped:
|
||||||
|
supabase.table("url_sources").update({
|
||||||
|
"status": "failed",
|
||||||
|
"error_message": scraped["error"],
|
||||||
|
}).eq("id", source_id).execute()
|
||||||
|
return
|
||||||
|
|
||||||
|
text = scraped["text"]
|
||||||
|
title = scraped.get("title", url)
|
||||||
|
collection_name = chatbot.get("qdrant_collection_name")
|
||||||
|
|
||||||
|
if not collection_name:
|
||||||
|
supabase.table("url_sources").update({
|
||||||
|
"status": "failed",
|
||||||
|
"error_message": "No vector store configured",
|
||||||
|
}).eq("id", source_id).execute()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ensure collection exists
|
||||||
|
if not vector_store.collection_exists(collection_name):
|
||||||
|
vector_store.create_collection(collection_name)
|
||||||
|
|
||||||
|
# Chunk text
|
||||||
|
chunks = chunk_text(text)
|
||||||
|
if not chunks:
|
||||||
|
supabase.table("url_sources").update({
|
||||||
|
"status": "failed",
|
||||||
|
"error_message": "No content extracted",
|
||||||
|
}).eq("id", source_id).execute()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Embed and upsert
|
||||||
|
all_ids = []
|
||||||
|
all_vectors = []
|
||||||
|
all_payloads = []
|
||||||
|
batch_size = 50
|
||||||
|
|
||||||
|
for i in range(0, len(chunks), batch_size):
|
||||||
|
batch = chunks[i:i + batch_size]
|
||||||
|
vectors = embedding_service.embed_batch(batch)
|
||||||
|
ids = [str(uuid.uuid4()) for _ in vectors]
|
||||||
|
payloads = [{
|
||||||
|
"document_id": source_id,
|
||||||
|
"company_id": chatbot.get("company_id", ""),
|
||||||
|
"file_name": f"[URL] {title}",
|
||||||
|
"page_number": i // batch_size + 1,
|
||||||
|
"chunk_index": i + j,
|
||||||
|
"text": chunk,
|
||||||
|
"source_url": url,
|
||||||
|
} for j, chunk in enumerate(batch)]
|
||||||
|
all_ids.extend(ids)
|
||||||
|
all_vectors.extend(vectors)
|
||||||
|
all_payloads.extend(payloads)
|
||||||
|
|
||||||
|
vector_store.upsert_vectors(
|
||||||
|
collection_name=collection_name,
|
||||||
|
vectors=all_vectors,
|
||||||
|
payloads=all_payloads,
|
||||||
|
ids=all_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
supabase.table("url_sources").update({
|
||||||
|
"status": "completed",
|
||||||
|
"page_title": title,
|
||||||
|
"chunk_count": len(chunks),
|
||||||
|
}).eq("id", source_id).execute()
|
||||||
|
|
||||||
|
logger.info(f"URL source {source_id} processed: {len(chunks)} chunks from {url}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"URL source processing error {source_id}: {e}")
|
||||||
|
supabase.table("url_sources").update({
|
||||||
|
"status": "failed",
|
||||||
|
"error_message": str(e)[:500],
|
||||||
|
}).eq("id", source_id).execute()
|
||||||
|
|
||||||
|
|
||||||
|
router_url_sources = url_router
|
||||||
|
|||||||
162
app/routers/inbox.py
Normal file
162
app/routers/inbox.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||||
|
from app.database import get_supabase
|
||||||
|
from app.dependencies import get_current_user
|
||||||
|
from app.config import PLAN_LIMITS
|
||||||
|
from app.models import InboxConversation, InboxMessage
|
||||||
|
from typing import List, Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/inbox", tags=["Inbox"])
|
||||||
|
|
||||||
|
|
||||||
|
def _check_inbox_access(user_id: str, supabase):
|
||||||
|
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"
|
||||||
|
if plan not in ("starter", "business", "agency", "enterprise"):
|
||||||
|
raise HTTPException(status_code=402, detail="Conversation inbox requires Starter plan or higher")
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_company_id(user_id: str, supabase) -> str:
|
||||||
|
result = supabase.table("companies").select("id").eq("owner_id", user_id).execute()
|
||||||
|
if not result.data:
|
||||||
|
raise HTTPException(status_code=404, detail="Company not found")
|
||||||
|
return result.data[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/conversations", response_model=List[InboxConversation])
|
||||||
|
async def list_inbox_conversations(
|
||||||
|
chatbot_id: Optional[str] = Query(None),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List conversations for all (or one) of the user's chatbots."""
|
||||||
|
supabase = get_supabase()
|
||||||
|
_check_inbox_access(user.id, supabase)
|
||||||
|
company_id = _get_user_company_id(user.id, supabase)
|
||||||
|
|
||||||
|
# Get user's chatbots
|
||||||
|
chatbots_query = supabase.table("chatbots").select("id, name").eq("company_id", company_id)
|
||||||
|
if chatbot_id:
|
||||||
|
chatbots_query = chatbots_query.eq("id", chatbot_id)
|
||||||
|
chatbots_result = chatbots_query.execute()
|
||||||
|
chatbot_map = {c["id"]: c["name"] for c in (chatbots_result.data or [])}
|
||||||
|
|
||||||
|
if not chatbot_map:
|
||||||
|
return []
|
||||||
|
|
||||||
|
chatbot_ids = list(chatbot_map.keys())
|
||||||
|
offset = (page - 1) * limit
|
||||||
|
|
||||||
|
# Query conversations
|
||||||
|
convs = supabase.table("conversations").select("*") \
|
||||||
|
.in_("chatbot_id", chatbot_ids) \
|
||||||
|
.order("created_at", desc=True) \
|
||||||
|
.range(offset, offset + limit - 1) \
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for conv in (convs.data or []):
|
||||||
|
cid = conv["id"]
|
||||||
|
|
||||||
|
# Get first user message for preview
|
||||||
|
first_msg = supabase.table("messages").select("content") \
|
||||||
|
.eq("conversation_id", cid) \
|
||||||
|
.eq("role", "user") \
|
||||||
|
.order("created_at", desc=False) \
|
||||||
|
.limit(1) \
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
first_message_text = first_msg.data[0]["content"] if first_msg.data else None
|
||||||
|
|
||||||
|
results.append(InboxConversation(
|
||||||
|
id=cid,
|
||||||
|
chatbot_id=conv["chatbot_id"],
|
||||||
|
chatbot_name=chatbot_map.get(conv["chatbot_id"], "Unknown"),
|
||||||
|
session_id=conv.get("session_id"),
|
||||||
|
language=conv.get("language", "en"),
|
||||||
|
message_count=conv.get("message_count", 0),
|
||||||
|
first_message=first_message_text,
|
||||||
|
created_at=conv.get("created_at"),
|
||||||
|
))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/conversations/{conversation_id}")
|
||||||
|
async def get_inbox_conversation(
|
||||||
|
conversation_id: str,
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get a full conversation thread with all messages."""
|
||||||
|
supabase = get_supabase()
|
||||||
|
_check_inbox_access(user.id, supabase)
|
||||||
|
company_id = _get_user_company_id(user.id, supabase)
|
||||||
|
|
||||||
|
# Verify ownership
|
||||||
|
conv = supabase.table("conversations").select("*, chatbots(company_id, name)") \
|
||||||
|
.eq("id", conversation_id) \
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
if not conv.data:
|
||||||
|
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||||
|
|
||||||
|
c = conv.data[0]
|
||||||
|
chatbot_data = c.get("chatbots") or {}
|
||||||
|
if chatbot_data.get("company_id") != company_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
|
||||||
|
# Get messages
|
||||||
|
msgs = supabase.table("messages").select("*") \
|
||||||
|
.eq("conversation_id", conversation_id) \
|
||||||
|
.order("created_at", desc=False) \
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
InboxMessage(
|
||||||
|
id=m["id"],
|
||||||
|
role=m["role"],
|
||||||
|
content=m["content"],
|
||||||
|
sources=m.get("sources"),
|
||||||
|
confidence_score=m.get("confidence_score"),
|
||||||
|
is_handoff=m.get("is_handoff", False),
|
||||||
|
created_at=m.get("created_at"),
|
||||||
|
)
|
||||||
|
for m in (msgs.data or [])
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"chatbot_name": chatbot_data.get("name", "Unknown"),
|
||||||
|
"language": c.get("language", "en"),
|
||||||
|
"session_id": c.get("session_id"),
|
||||||
|
"created_at": c.get("created_at"),
|
||||||
|
"messages": [m.model_dump() for m in messages],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/conversations/{conversation_id}")
|
||||||
|
async def delete_inbox_conversation(
|
||||||
|
conversation_id: str,
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Delete a conversation and all its messages."""
|
||||||
|
supabase = get_supabase()
|
||||||
|
_check_inbox_access(user.id, supabase)
|
||||||
|
company_id = _get_user_company_id(user.id, supabase)
|
||||||
|
|
||||||
|
# Verify ownership
|
||||||
|
conv = supabase.table("conversations").select("*, chatbots(company_id)") \
|
||||||
|
.eq("id", conversation_id).execute()
|
||||||
|
|
||||||
|
if not conv.data:
|
||||||
|
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||||
|
|
||||||
|
chatbot_data = conv.data[0].get("chatbots") or {}
|
||||||
|
if chatbot_data.get("company_id") != company_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
|
||||||
|
supabase.table("conversations").delete().eq("id", conversation_id).execute()
|
||||||
|
return {"success": True}
|
||||||
150
app/routers/leads.py
Normal file
150
app/routers/leads.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from app.database import get_supabase
|
||||||
|
from app.dependencies import get_current_user
|
||||||
|
from app.models import LeadCreate, LeadResponse
|
||||||
|
from typing import List, Optional
|
||||||
|
import uuid
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/leads", tags=["Leads"])
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_company_id(user_id: str, supabase) -> str:
|
||||||
|
result = supabase.table("companies").select("id").eq("owner_id", user_id).execute()
|
||||||
|
if not result.data:
|
||||||
|
raise HTTPException(status_code=404, detail="Company not found")
|
||||||
|
return result.data[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _check_leads_access(user_id: str, supabase):
|
||||||
|
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"
|
||||||
|
if plan not in ("starter", "business", "agency", "enterprise"):
|
||||||
|
raise HTTPException(status_code=402, detail="Lead capture requires Starter plan or higher")
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=List[LeadResponse])
|
||||||
|
async def list_leads(
|
||||||
|
chatbot_id: Optional[str] = Query(None),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List leads for the user's chatbots."""
|
||||||
|
supabase = get_supabase()
|
||||||
|
_check_leads_access(user.id, supabase)
|
||||||
|
company_id = _get_user_company_id(user.id, supabase)
|
||||||
|
|
||||||
|
# Get owned chatbot IDs
|
||||||
|
chatbots_q = supabase.table("chatbots").select("id").eq("company_id", company_id)
|
||||||
|
if chatbot_id:
|
||||||
|
chatbots_q = chatbots_q.eq("id", chatbot_id)
|
||||||
|
chatbots = chatbots_q.execute()
|
||||||
|
chatbot_ids = [c["id"] for c in (chatbots.data or [])]
|
||||||
|
|
||||||
|
if not chatbot_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
offset = (page - 1) * limit
|
||||||
|
result = supabase.table("leads").select("*") \
|
||||||
|
.in_("chatbot_id", chatbot_ids) \
|
||||||
|
.order("created_at", desc=True) \
|
||||||
|
.range(offset, offset + limit - 1) \
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
return [LeadResponse(**lead) for lead in (result.data or [])]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export")
|
||||||
|
async def export_leads_csv(
|
||||||
|
chatbot_id: Optional[str] = Query(None),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Export leads as CSV file."""
|
||||||
|
supabase = get_supabase()
|
||||||
|
_check_leads_access(user.id, supabase)
|
||||||
|
company_id = _get_user_company_id(user.id, supabase)
|
||||||
|
|
||||||
|
chatbots_q = supabase.table("chatbots").select("id").eq("company_id", company_id)
|
||||||
|
if chatbot_id:
|
||||||
|
chatbots_q = chatbots_q.eq("id", chatbot_id)
|
||||||
|
chatbots = chatbots_q.execute()
|
||||||
|
chatbot_ids = [c["id"] for c in (chatbots.data or [])]
|
||||||
|
|
||||||
|
if not chatbot_ids:
|
||||||
|
leads_data = []
|
||||||
|
else:
|
||||||
|
result = supabase.table("leads").select("*") \
|
||||||
|
.in_("chatbot_id", chatbot_ids) \
|
||||||
|
.order("created_at", desc=True) \
|
||||||
|
.execute()
|
||||||
|
leads_data = result.data or []
|
||||||
|
|
||||||
|
# Build CSV
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.DictWriter(output, fieldnames=["id", "chatbot_id", "email", "name", "phone", "company", "created_at"])
|
||||||
|
writer.writeheader()
|
||||||
|
for lead in leads_data:
|
||||||
|
writer.writerow({
|
||||||
|
"id": lead.get("id", ""),
|
||||||
|
"chatbot_id": lead.get("chatbot_id", ""),
|
||||||
|
"email": lead.get("email", ""),
|
||||||
|
"name": lead.get("name", ""),
|
||||||
|
"phone": lead.get("phone", ""),
|
||||||
|
"company": lead.get("company", ""),
|
||||||
|
"created_at": lead.get("created_at", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
csv_bytes = output.getvalue().encode("utf-8")
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([csv_bytes]),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=leads.csv"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Public endpoint — no auth required
|
||||||
|
leads_public_router = APIRouter(tags=["Leads"])
|
||||||
|
|
||||||
|
|
||||||
|
@leads_public_router.post("/chatbots/{chatbot_id}/leads", response_model=LeadResponse, status_code=201)
|
||||||
|
async def submit_lead(chatbot_id: str, data: LeadCreate):
|
||||||
|
"""Submit a lead for a chatbot (public endpoint, no auth required)."""
|
||||||
|
supabase = get_supabase()
|
||||||
|
|
||||||
|
# Verify chatbot exists
|
||||||
|
chatbot = supabase.table("chatbots").select("id, lead_capture_enabled").eq("id", chatbot_id).execute()
|
||||||
|
if not chatbot.data:
|
||||||
|
raise HTTPException(status_code=404, detail="Chatbot not found")
|
||||||
|
if not chatbot.data[0].get("lead_capture_enabled", False):
|
||||||
|
raise HTTPException(status_code=400, detail="Lead capture not enabled for this chatbot")
|
||||||
|
|
||||||
|
# Deduplicate by email+chatbot_id
|
||||||
|
if data.email:
|
||||||
|
existing = supabase.table("leads").select("*") \
|
||||||
|
.eq("chatbot_id", chatbot_id) \
|
||||||
|
.eq("email", data.email) \
|
||||||
|
.execute()
|
||||||
|
if existing.data:
|
||||||
|
return LeadResponse(**existing.data[0])
|
||||||
|
|
||||||
|
lead_data = {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"chatbot_id": chatbot_id,
|
||||||
|
"conversation_id": data.conversation_id,
|
||||||
|
"email": data.email,
|
||||||
|
"name": data.name,
|
||||||
|
"phone": data.phone,
|
||||||
|
"company": data.company,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = supabase.table("leads").insert(lead_data).execute()
|
||||||
|
if not result.data:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to save lead")
|
||||||
|
|
||||||
|
return LeadResponse(**result.data[0])
|
||||||
@@ -91,12 +91,12 @@ async def get_available_models(user=Depends(get_current_user)):
|
|||||||
))
|
))
|
||||||
|
|
||||||
# Determine upgrade messaging
|
# Determine upgrade messaging
|
||||||
has_premium = plan in ("pro", "enterprise")
|
has_premium = plan in ("business", "agency", "enterprise")
|
||||||
upgrade_label = None
|
upgrade_label = None
|
||||||
if plan == "free":
|
if plan == "free":
|
||||||
upgrade_label = "Upgrade to Starter for more models and publishing"
|
upgrade_label = "Upgrade to Starter for more AI models and messaging channels"
|
||||||
elif plan == "starter":
|
elif plan == "starter":
|
||||||
upgrade_label = "Upgrade to Pro for GPT-4o, Claude, Gemini"
|
upgrade_label = "Upgrade to Business for GPT-4o, Claude, Gemini and WhatsApp"
|
||||||
|
|
||||||
return ModelsResponse(
|
return ModelsResponse(
|
||||||
models=models,
|
models=models,
|
||||||
|
|||||||
56
app/routers/upload.py
Normal file
56
app/routers/upload.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
|
||||||
|
from app.database import get_supabase
|
||||||
|
from app.dependencies import get_current_user
|
||||||
|
from app.config import settings
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/upload", tags=["Upload"])
|
||||||
|
|
||||||
|
ALLOWED_IMAGE_TYPES = {"image/png", "image/jpeg", "image/jpg", "image/gif", "image/svg+xml", "image/webp"}
|
||||||
|
MAX_LOGO_SIZE = 2 * 1024 * 1024 # 2MB
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logo")
|
||||||
|
async def upload_logo(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Upload a chatbot logo to Supabase Storage. Returns public URL."""
|
||||||
|
supabase = get_supabase()
|
||||||
|
|
||||||
|
# Validate content type
|
||||||
|
if file.content_type not in ALLOWED_IMAGE_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid file type. Allowed: PNG, JPG, GIF, SVG, WebP"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Read file
|
||||||
|
file_bytes = await file.read()
|
||||||
|
if len(file_bytes) > MAX_LOGO_SIZE:
|
||||||
|
raise HTTPException(status_code=413, detail="Image must be under 2MB")
|
||||||
|
|
||||||
|
# Determine extension
|
||||||
|
content_type = file.content_type or "image/png"
|
||||||
|
ext_map = {
|
||||||
|
"image/png": "png", "image/jpeg": "jpg", "image/jpg": "jpg",
|
||||||
|
"image/gif": "gif", "image/svg+xml": "svg", "image/webp": "webp"
|
||||||
|
}
|
||||||
|
ext = ext_map.get(content_type, "png")
|
||||||
|
|
||||||
|
# Upload to Supabase Storage
|
||||||
|
path = f"{user.id}/{uuid.uuid4().hex}.{ext}"
|
||||||
|
try:
|
||||||
|
result = supabase.storage.from_("logos").upload(
|
||||||
|
path=path,
|
||||||
|
file=file_bytes,
|
||||||
|
file_options={"content-type": content_type, "upsert": "true"},
|
||||||
|
)
|
||||||
|
# Get public URL
|
||||||
|
public_url = supabase.storage.from_("logos").get_public_url(path)
|
||||||
|
return {"url": public_url}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Logo upload failed: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)[:200]}")
|
||||||
62
app/services/n8n_service.py
Normal file
62
app/services/n8n_service.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import httpx
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_notification(
|
||||||
|
event_type: str,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
webhook_url: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Generic n8n notification sender.
|
||||||
|
Returns True if sent, False if not configured or failed.
|
||||||
|
"""
|
||||||
|
if not webhook_url:
|
||||||
|
return False
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"event": event_type,
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
**data,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
response = await client.post(webhook_url, json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
logger.info(f"n8n notification sent: {event_type}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send n8n notification ({event_type}): {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def send_handoff_notification(
|
||||||
|
chatbot_name: str,
|
||||||
|
owner_email: str,
|
||||||
|
conversation_history: List[dict],
|
||||||
|
trigger_message: str,
|
||||||
|
chatbot_id: str,
|
||||||
|
conversation_id: str,
|
||||||
|
webhook_url: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Send a human handoff notification to the configured n8n webhook.
|
||||||
|
Returns True if sent, False if not configured or failed.
|
||||||
|
"""
|
||||||
|
return await send_notification(
|
||||||
|
event_type="handoff",
|
||||||
|
data={
|
||||||
|
"chatbot_name": chatbot_name,
|
||||||
|
"owner_email": owner_email,
|
||||||
|
"trigger_message": trigger_message,
|
||||||
|
"conversation_history": conversation_history[-10:],
|
||||||
|
"chatbot_id": chatbot_id,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
},
|
||||||
|
webhook_url=webhook_url,
|
||||||
|
)
|
||||||
57
app/services/telegram_service.py
Normal file
57
app/services/telegram_service.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import httpx
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_BASE = "https://api.telegram.org/bot{token}"
|
||||||
|
|
||||||
|
|
||||||
|
async def get_bot_info(bot_token: str) -> Optional[dict]:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.get(f"https://api.telegram.org/bot{bot_token}/getMe")
|
||||||
|
data = r.json()
|
||||||
|
if data.get("ok"):
|
||||||
|
return data["result"]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Telegram getMe error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def set_webhook(bot_token: str, webhook_url: str) -> bool:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"https://api.telegram.org/bot{bot_token}/setWebhook",
|
||||||
|
json={"url": webhook_url, "allowed_updates": ["message"]},
|
||||||
|
)
|
||||||
|
return r.json().get("ok", False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Telegram setWebhook error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_webhook(bot_token: str) -> bool:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"https://api.telegram.org/bot{bot_token}/deleteWebhook"
|
||||||
|
)
|
||||||
|
return r.json().get("ok", False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Telegram deleteWebhook error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def send_message(bot_token: str, chat_id, text: str) -> bool:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"https://api.telegram.org/bot{bot_token}/sendMessage",
|
||||||
|
json={"chat_id": chat_id, "text": text},
|
||||||
|
)
|
||||||
|
return r.json().get("ok", False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Telegram sendMessage error: {e}")
|
||||||
|
return False
|
||||||
65
app/services/web_scraper.py
Normal file
65
app/services/web_scraper.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import httpx
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MAX_TEXT_BYTES = 100 * 1024 # 100KB
|
||||||
|
|
||||||
|
|
||||||
|
async def scrape_url(url: str) -> dict:
|
||||||
|
"""
|
||||||
|
Fetch a URL and extract clean text content.
|
||||||
|
Returns: {title, text, url} or {error, url}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (compatible; ContextaBot/1.0; +https://contexta.ai)",
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||||
|
response = await client.get(url, headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
content_type = response.headers.get("content-type", "")
|
||||||
|
if "text/html" not in content_type and "text/plain" not in content_type:
|
||||||
|
return {"error": f"Unsupported content type: {content_type}", "url": url}
|
||||||
|
|
||||||
|
html = response.text
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
|
||||||
|
# Extract title
|
||||||
|
title_tag = soup.find("title")
|
||||||
|
title = title_tag.get_text(strip=True) if title_tag else ""
|
||||||
|
|
||||||
|
# Remove unwanted tags
|
||||||
|
for tag in soup.find_all(["nav", "header", "footer", "script", "style", "noscript", "aside", "advertisement"]):
|
||||||
|
tag.decompose()
|
||||||
|
|
||||||
|
# Extract main content (prefer article/main/body)
|
||||||
|
main = soup.find("main") or soup.find("article") or soup.find("body") or soup
|
||||||
|
text = main.get_text(separator="\n", strip=True)
|
||||||
|
|
||||||
|
# Clean up whitespace
|
||||||
|
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
||||||
|
text = "\n".join(lines)
|
||||||
|
|
||||||
|
# Limit size
|
||||||
|
if len(text.encode("utf-8")) > MAX_TEXT_BYTES:
|
||||||
|
text = text[:MAX_TEXT_BYTES].rsplit("\n", 1)[0]
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
return {"error": "No text content found on page", "url": url}
|
||||||
|
|
||||||
|
logger.info(f"Scraped {url}: {len(text)} chars, title='{title}'")
|
||||||
|
return {"title": title, "text": text, "url": url}
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
return {"error": "Request timed out", "url": url}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
return {"error": f"HTTP {e.response.status_code}", "url": url}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Scrape error for {url}: {e}")
|
||||||
|
return {"error": str(e)[:200], "url": url}
|
||||||
36
app/services/whatsapp_service.py
Normal file
36
app/services/whatsapp_service.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import httpx
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_META_API = "https://graph.facebook.com/v19.0"
|
||||||
|
|
||||||
|
|
||||||
|
async def send_message(phone_number_id: str, to: str, text: str, access_token: str) -> bool:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{_META_API}/{phone_number_id}/messages",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
json={
|
||||||
|
"messaging_product": "whatsapp",
|
||||||
|
"to": to,
|
||||||
|
"type": "text",
|
||||||
|
"text": {"body": text},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return r.status_code == 200
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"WhatsApp send error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def verify_signature(payload: bytes, signature: str, app_secret: str) -> bool:
|
||||||
|
expected = "sha256=" + hmac.new(
|
||||||
|
app_secret.encode("utf-8"),
|
||||||
|
payload,
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
|
return hmac.compare_digest(expected, signature)
|
||||||
140
app/services/widget.py
Normal file
140
app/services/widget.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
def generate_widget_js(app_url: str) -> str:
|
||||||
|
"""Generate the embeddable widget JavaScript with app_url baked in."""
|
||||||
|
return f"""
|
||||||
|
(function() {{
|
||||||
|
var APP_URL = "{app_url}";
|
||||||
|
|
||||||
|
// Find script tag to get chatbot ID
|
||||||
|
var scripts = document.querySelectorAll('script[data-chatbot]');
|
||||||
|
var chatbotId = null;
|
||||||
|
if (scripts.length > 0) {{
|
||||||
|
chatbotId = scripts[scripts.length - 1].getAttribute('data-chatbot');
|
||||||
|
}}
|
||||||
|
if (!chatbotId) {{
|
||||||
|
console.warn('[Contexta] No data-chatbot attribute found on script tag');
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
var style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
.contexta-btn {{
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #6366f1;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 999998;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}}
|
||||||
|
.contexta-btn:hover {{
|
||||||
|
transform: scale(1.08);
|
||||||
|
box-shadow: 0 6px 20px rgba(0,0,0,0.25);
|
||||||
|
}}
|
||||||
|
.contexta-btn svg {{
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
fill: white;
|
||||||
|
}}
|
||||||
|
.contexta-container {{
|
||||||
|
position: fixed;
|
||||||
|
bottom: 92px;
|
||||||
|
right: 24px;
|
||||||
|
width: 380px;
|
||||||
|
height: 580px;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
|
||||||
|
z-index: 999999;
|
||||||
|
overflow: hidden;
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}}
|
||||||
|
.contexta-container.open {{
|
||||||
|
display: flex;
|
||||||
|
}}
|
||||||
|
.contexta-iframe {{
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
flex: 1;
|
||||||
|
}}
|
||||||
|
.contexta-close {{
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1;
|
||||||
|
}}
|
||||||
|
@media (max-width: 480px) {{
|
||||||
|
.contexta-container {{
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
border-radius: 0;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
// Button
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.className = 'contexta-btn';
|
||||||
|
btn.setAttribute('aria-label', 'Open chat');
|
||||||
|
btn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/></svg>';
|
||||||
|
document.body.appendChild(btn);
|
||||||
|
|
||||||
|
// Container with iframe
|
||||||
|
var container = document.createElement('div');
|
||||||
|
container.className = 'contexta-container';
|
||||||
|
|
||||||
|
var closeBtn = document.createElement('button');
|
||||||
|
closeBtn.className = 'contexta-close';
|
||||||
|
closeBtn.innerHTML = '×';
|
||||||
|
closeBtn.setAttribute('aria-label', 'Close chat');
|
||||||
|
container.appendChild(closeBtn);
|
||||||
|
|
||||||
|
var iframe = document.createElement('iframe');
|
||||||
|
iframe.className = 'contexta-iframe';
|
||||||
|
iframe.src = APP_URL + '/chat/' + chatbotId;
|
||||||
|
iframe.setAttribute('allow', 'microphone');
|
||||||
|
container.appendChild(iframe);
|
||||||
|
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
// Toggle logic
|
||||||
|
var isOpen = false;
|
||||||
|
function toggle() {{
|
||||||
|
isOpen = !isOpen;
|
||||||
|
if (isOpen) {{
|
||||||
|
container.classList.add('open');
|
||||||
|
btn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>';
|
||||||
|
}} else {{
|
||||||
|
container.classList.remove('open');
|
||||||
|
btn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/></svg>';
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
btn.addEventListener('click', toggle);
|
||||||
|
closeBtn.addEventListener('click', toggle);
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
@@ -13,4 +13,14 @@ dependencies = [
|
|||||||
"stripe>=14.3.0",
|
"stripe>=14.3.0",
|
||||||
"supabase>=2.28.0",
|
"supabase>=2.28.0",
|
||||||
"uvicorn>=0.41.0",
|
"uvicorn>=0.41.0",
|
||||||
|
"beautifulsoup4>=4.12.0",
|
||||||
|
"httpx>=0.27.0",
|
||||||
|
"anthropic>=0.40.0",
|
||||||
|
"google-generativeai>=0.8.0",
|
||||||
|
"python-docx>=1.1.0",
|
||||||
|
"pypdf>=4.0.0",
|
||||||
|
"openpyxl>=3.1.0",
|
||||||
|
"pandas>=2.2.0",
|
||||||
|
"python-multipart>=0.0.9",
|
||||||
|
"pydantic-settings>=2.0.0",
|
||||||
]
|
]
|
||||||
|
|||||||
119
supabase_migration.sql
Normal file
119
supabase_migration.sql
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
-- Contexta DB Migration
|
||||||
|
-- Run this in your Supabase SQL Editor
|
||||||
|
|
||||||
|
-- ── chatbots: new columns ─────────────────────────────────────────────────────
|
||||||
|
ALTER TABLE chatbots ADD COLUMN IF NOT EXISTS logo_url TEXT;
|
||||||
|
ALTER TABLE chatbots ADD COLUMN IF NOT EXISTS show_branding BOOLEAN DEFAULT TRUE;
|
||||||
|
ALTER TABLE chatbots ADD COLUMN IF NOT EXISTS lead_capture_enabled BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE chatbots ADD COLUMN IF NOT EXISTS lead_capture_fields JSONB DEFAULT '["email"]';
|
||||||
|
ALTER TABLE chatbots ADD COLUMN IF NOT EXISTS lead_capture_trigger VARCHAR(50) DEFAULT 'after_first_message';
|
||||||
|
ALTER TABLE chatbots ADD COLUMN IF NOT EXISTS handoff_enabled BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE chatbots ADD COLUMN IF NOT EXISTS handoff_message TEXT DEFAULT 'I''ll connect you with our team. Please wait.';
|
||||||
|
ALTER TABLE chatbots ADD COLUMN IF NOT EXISTS handoff_email TEXT;
|
||||||
|
ALTER TABLE chatbots ADD COLUMN IF NOT EXISTS handoff_keywords JSONB DEFAULT '["human", "agent", "speak to someone", "talk to a person", "real person"]';
|
||||||
|
|
||||||
|
-- ── messages: new columns ─────────────────────────────────────────────────────
|
||||||
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS confidence_score DECIMAL(4,3) DEFAULT NULL;
|
||||||
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS is_handoff BOOLEAN DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- ── leads table ───────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS leads (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
chatbot_id UUID REFERENCES chatbots(id) ON DELETE CASCADE,
|
||||||
|
conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL,
|
||||||
|
email VARCHAR(255),
|
||||||
|
name VARCHAR(255),
|
||||||
|
phone VARCHAR(50),
|
||||||
|
company VARCHAR(255),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_leads_chatbot ON leads(chatbot_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_leads_created ON leads(created_at DESC);
|
||||||
|
ALTER TABLE leads ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY "leads_owner" ON leads FOR ALL USING (
|
||||||
|
chatbot_id IN (
|
||||||
|
SELECT c.id FROM chatbots c
|
||||||
|
JOIN companies co ON c.company_id = co.id
|
||||||
|
WHERE co.owner_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── url_sources table ─────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS url_sources (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
chatbot_id UUID REFERENCES chatbots(id) ON DELETE CASCADE,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
status VARCHAR(50) DEFAULT 'pending',
|
||||||
|
page_title TEXT,
|
||||||
|
chunk_count INT DEFAULT 0,
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_url_sources_chatbot ON url_sources(chatbot_id);
|
||||||
|
ALTER TABLE url_sources ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY "url_sources_owner" ON url_sources FOR ALL USING (
|
||||||
|
chatbot_id IN (
|
||||||
|
SELECT c.id FROM chatbots c
|
||||||
|
JOIN companies co ON c.company_id = co.id
|
||||||
|
WHERE co.owner_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── message_feedback table ────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS message_feedback (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
message_id UUID REFERENCES messages(id) ON DELETE CASCADE,
|
||||||
|
chatbot_id UUID REFERENCES chatbots(id) ON DELETE CASCADE,
|
||||||
|
feedback VARCHAR(20) NOT NULL CHECK (feedback IN ('positive', 'negative')),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_feedback_chatbot ON message_feedback(chatbot_id);
|
||||||
|
ALTER TABLE message_feedback ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY "feedback_insert" ON message_feedback FOR INSERT WITH CHECK (true);
|
||||||
|
CREATE POLICY "feedback_select_owner" ON message_feedback FOR SELECT USING (
|
||||||
|
chatbot_id IN (
|
||||||
|
SELECT c.id FROM chatbots c
|
||||||
|
JOIN companies co ON c.company_id = co.id
|
||||||
|
WHERE co.owner_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Supabase Storage ──────────────────────────────────────────────────────────
|
||||||
|
-- Create the 'logos' bucket manually in the Supabase dashboard:
|
||||||
|
-- Storage → New bucket → Name: logos → Public: ON
|
||||||
|
|
||||||
|
-- ── channel_connections table ─────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS channel_connections (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
chatbot_id UUID NOT NULL REFERENCES chatbots(id) ON DELETE CASCADE,
|
||||||
|
channel VARCHAR(20) NOT NULL CHECK (channel IN ('telegram', 'whatsapp')),
|
||||||
|
bot_token TEXT,
|
||||||
|
bot_username TEXT,
|
||||||
|
wa_keyword VARCHAR(50),
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(chatbot_id, channel)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channel_connections_chatbot ON channel_connections(chatbot_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channel_connections_wa_keyword ON channel_connections(wa_keyword) WHERE channel = 'whatsapp';
|
||||||
|
ALTER TABLE channel_connections ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY "channel_connections_owner" ON channel_connections FOR ALL USING (
|
||||||
|
chatbot_id IN (
|
||||||
|
SELECT c.id FROM chatbots c
|
||||||
|
JOIN companies co ON c.company_id = co.id
|
||||||
|
WHERE co.owner_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── channel_sessions table ────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS channel_sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
chatbot_id UUID NOT NULL REFERENCES chatbots(id) ON DELETE CASCADE,
|
||||||
|
channel VARCHAR(20) NOT NULL,
|
||||||
|
external_id TEXT NOT NULL,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
last_active TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(channel, external_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channel_sessions_lookup ON channel_sessions(channel, external_id);
|
||||||
48
supabase_migration_channels.sql
Normal file
48
supabase_migration_channels.sql
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
-- Contexta — Channels Migration
|
||||||
|
-- Run this in your Supabase SQL Editor
|
||||||
|
|
||||||
|
-- ── channel_connections table ─────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS channel_connections (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
chatbot_id UUID NOT NULL REFERENCES chatbots(id) ON DELETE CASCADE,
|
||||||
|
channel VARCHAR(20) NOT NULL CHECK (channel IN ('telegram', 'whatsapp')),
|
||||||
|
bot_token TEXT,
|
||||||
|
bot_username TEXT,
|
||||||
|
wa_keyword VARCHAR(50),
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(chatbot_id, channel)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channel_connections_chatbot ON channel_connections(chatbot_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channel_connections_wa_keyword ON channel_connections(wa_keyword) WHERE channel = 'whatsapp';
|
||||||
|
ALTER TABLE channel_connections ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY "channel_connections_owner" ON channel_connections FOR ALL USING (
|
||||||
|
chatbot_id IN (
|
||||||
|
SELECT c.id FROM chatbots c
|
||||||
|
JOIN companies co ON c.company_id = co.id
|
||||||
|
WHERE co.owner_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── channel_sessions table ────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS channel_sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
chatbot_id UUID NOT NULL REFERENCES chatbots(id) ON DELETE CASCADE,
|
||||||
|
channel VARCHAR(20) NOT NULL,
|
||||||
|
external_id TEXT NOT NULL,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
last_active TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(channel, external_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channel_sessions_lookup ON channel_sessions(channel, external_id);
|
||||||
|
ALTER TABLE channel_sessions ENABLE ROW LEVEL SECURITY;
|
||||||
|
-- Webhook handlers use the service_role key so they bypass RLS.
|
||||||
|
-- This policy lets authenticated owners read their own sessions via the dashboard.
|
||||||
|
CREATE POLICY "channel_sessions_owner" ON channel_sessions FOR SELECT USING (
|
||||||
|
chatbot_id IN (
|
||||||
|
SELECT c.id FROM chatbots c
|
||||||
|
JOIN companies co ON c.company_id = co.id
|
||||||
|
WHERE co.owner_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user