updates Mar6

This commit is contained in:
belviskhoremk
2026-03-06 22:37:40 +00:00
parent 2ed998058e
commit 9dccc83293
23 changed files with 2257 additions and 74 deletions

518
app/routers/channels.py Normal file
View 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}