feat: Added the ComfyUI engine (#12)
#11 Added the complete ComfyUI engine. Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
40
backend/.env.example
Normal file
40
backend/.env.example
Normal file
@@ -0,0 +1,40 @@
|
||||
# ── Meta Graph API ────────────────────────────────────────────────────────────
|
||||
# Long-lived System User Access Token from Meta Business Manager
|
||||
# Business Settings → System Users → Generate Token
|
||||
META_ACCESS_TOKEN=PLACEHOLDER_your_meta_system_user_token
|
||||
|
||||
# Ad Account ID — format: act_XXXXXXXXXX
|
||||
# Meta Business Manager → Ad Accounts
|
||||
META_AD_ACCOUNT_ID=PLACEHOLDER_act_1234567890
|
||||
|
||||
# Business Portfolio ID
|
||||
# Meta Business Settings → Business Info → Business ID
|
||||
META_BUSINESS_ID=PLACEHOLDER_1234567890
|
||||
|
||||
# App ID & Secret — from Meta Developers → Your App → Basic Settings
|
||||
META_APP_ID=PLACEHOLDER_9876543210
|
||||
META_APP_SECRET=PLACEHOLDER_your_app_secret
|
||||
|
||||
# API Version (use latest stable)
|
||||
META_API_VERSION=v21.0
|
||||
|
||||
# ── Supabase (CRM) ────────────────────────────────────────────────────────────
|
||||
# Project URL from Supabase Dashboard → Settings → API
|
||||
SUPABASE_URL=PLACEHOLDER_https://xxxxxxxxxxx.supabase.co
|
||||
|
||||
# Anon/Public key (for server-side reads)
|
||||
SUPABASE_ANON_KEY=PLACEHOLDER_your_supabase_anon_key
|
||||
|
||||
# Service Role key (for elevated writes — keep secret!)
|
||||
SUPABASE_SERVICE_ROLE_KEY=PLACEHOLDER_your_supabase_service_role_key
|
||||
|
||||
# ── ComfyUI ───────────────────────────────────────────────────────────────────
|
||||
# Base URL of ComfyUI server running locally or on GPU node
|
||||
COMFY_BASE_URL=http://localhost:8188
|
||||
|
||||
# ── Backend ───────────────────────────────────────────────────────────────────
|
||||
# CORS origins — comma-separated list of allowed frontend origins
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
|
||||
# Secret key for signing internal JWTs/sessions
|
||||
SECRET_KEY=PLACEHOLDER_generate_with_openssl_rand_hex_32
|
||||
435
backend/api/routes_catalyst.py
Normal file
435
backend/api/routes_catalyst.py
Normal file
@@ -0,0 +1,435 @@
|
||||
"""
|
||||
routes_catalyst.py
|
||||
Meta Marketing API wrappers for The Catalyst module.
|
||||
|
||||
Routes:
|
||||
POST /api/catalyst/campaigns/create — Bulk campaign creation
|
||||
POST /api/catalyst/creative/sync — Upload ComfyUI assets to Meta
|
||||
GET /api/catalyst/insights/realtime — Poll Ads Insights API
|
||||
POST /api/catalyst/audiences/lookalike — Push CRM leads → Meta Custom Audience
|
||||
POST /api/catalyst/auth/meta — OAuth token acquisition
|
||||
"""
|
||||
|
||||
import os
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Any
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_sdk() -> tuple[Any, str]:
|
||||
"""
|
||||
Initialise the facebook-business SDK lazily.
|
||||
Returns (FacebookAdsApi instance, ad_account_id).
|
||||
Raises HTTPException 503 if credentials are missing or SDK init fails.
|
||||
"""
|
||||
try:
|
||||
from facebook_business.api import FacebookAdsApi # type: ignore
|
||||
access_token = os.getenv("META_ACCESS_TOKEN", "")
|
||||
app_id = os.getenv("META_APP_ID", "")
|
||||
app_secret = os.getenv("META_APP_SECRET", "")
|
||||
account_id = os.getenv("META_AD_ACCOUNT_ID", "")
|
||||
|
||||
if not access_token or access_token.startswith("PLACEHOLDER"):
|
||||
raise ValueError("META_ACCESS_TOKEN is not configured.")
|
||||
if not account_id or account_id.startswith("PLACEHOLDER"):
|
||||
raise ValueError("META_AD_ACCOUNT_ID is not configured.")
|
||||
|
||||
FacebookAdsApi.init(app_id, app_secret, access_token)
|
||||
return FacebookAdsApi.get_default_api(), account_id
|
||||
except ImportError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="facebook-business SDK not installed. Run: pip install facebook-business",
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
|
||||
def _get_supabase():
|
||||
"""Initialise the Supabase client lazily."""
|
||||
try:
|
||||
from supabase import create_client # type: ignore
|
||||
url = os.getenv("SUPABASE_URL", "")
|
||||
key = os.getenv("SUPABASE_SERVICE_ROLE_KEY", "")
|
||||
if not url or url.startswith("PLACEHOLDER"):
|
||||
raise ValueError("SUPABASE_URL is not configured.")
|
||||
return create_client(url, key)
|
||||
except ImportError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="supabase SDK not installed. Run: pip install supabase",
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
|
||||
def _ok(data: Any, meta: dict | None = None) -> dict:
|
||||
return {"status": "ok", "data": data, "meta": meta or {}}
|
||||
|
||||
|
||||
def _sha256_hash(value: str) -> str:
|
||||
"""SHA-256 hash an email for Meta's hashed audience upload."""
|
||||
return hashlib.sha256(value.strip().lower().encode()).hexdigest()
|
||||
|
||||
|
||||
# ── Request / Response Models ─────────────────────────────────────────────────
|
||||
|
||||
class CampaignCreateRequest(BaseModel):
|
||||
name: str = Field(..., description="Campaign display name")
|
||||
objective: str = Field("OUTCOME_LEADS", description="Meta campaign objective enum")
|
||||
budget_daily: int = Field(..., gt=0, description="Daily budget in cents (AED × 100)")
|
||||
status: str = Field("PAUSED", description="Initial campaign status — start PAUSED for review")
|
||||
special_ad_categories: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CampaignCreateResponse(BaseModel):
|
||||
campaign_id: str
|
||||
name: str
|
||||
status: str
|
||||
created_at: str
|
||||
|
||||
|
||||
class CreativeSyncRequest(BaseModel):
|
||||
asset_url: str = Field(..., description="Public URL of the ComfyUI-rendered image or video")
|
||||
asset_name: str = Field(..., description="Human-readable asset name")
|
||||
asset_type: str = Field(..., description="'image' or 'video'")
|
||||
ad_account_id: str | None = Field(None, description="Override ad account ID (optional)")
|
||||
|
||||
|
||||
class LookalikeAudienceRequest(BaseModel):
|
||||
country: str = Field("AE", description="ISO 3166-1 alpha-2 country code for lookalike")
|
||||
ratio: float = Field(0.01, ge=0.01, le=0.20, description="Lookalike ratio (1%–20%)")
|
||||
crm_filter_status: str = Field("Closed/Won", description="Supabase lead status to filter on")
|
||||
|
||||
|
||||
class MetaAuthRequest(BaseModel):
|
||||
short_lived_token: str = Field(..., description="Short-lived user access token from Meta OAuth")
|
||||
|
||||
|
||||
# ── 1. POST /campaigns/create ─────────────────────────────────────────────────
|
||||
|
||||
@router.post("/campaigns/create", summary="Bulk-create Meta Marketing campaigns")
|
||||
async def create_campaigns(
|
||||
request: Request,
|
||||
payload: CampaignCreateRequest,
|
||||
) -> dict:
|
||||
"""
|
||||
Triggers `facebook_business.adobjects.campaign.Campaign` to create a campaign
|
||||
under the configured Ad Account.
|
||||
|
||||
Requires: META_ACCESS_TOKEN, META_AD_ACCOUNT_ID
|
||||
"""
|
||||
_api, account_id = _get_sdk()
|
||||
|
||||
try:
|
||||
from facebook_business.adobjects.adaccount import AdAccount # type: ignore
|
||||
from facebook_business.adobjects.campaign import Campaign # type: ignore
|
||||
|
||||
account = AdAccount(account_id)
|
||||
params = {
|
||||
Campaign.Field.name: payload.name,
|
||||
Campaign.Field.objective: payload.objective,
|
||||
Campaign.Field.status: payload.status,
|
||||
Campaign.Field.daily_budget: payload.budget_daily,
|
||||
Campaign.Field.special_ad_categories: payload.special_ad_categories,
|
||||
}
|
||||
campaign = account.create_campaign(params=params)
|
||||
|
||||
# Broadcast live event via WebSocket
|
||||
if hasattr(request.app.state, "broadcast_live_event"):
|
||||
await request.app.state.broadcast_live_event(
|
||||
"create",
|
||||
f"Created campaign '{payload.name}' (objective: {payload.objective}).",
|
||||
payload.name,
|
||||
f"Budget: AED {payload.budget_daily / 100:.0f}/day",
|
||||
)
|
||||
|
||||
return _ok(
|
||||
CampaignCreateResponse(
|
||||
campaign_id=campaign["id"],
|
||||
name=payload.name,
|
||||
status=payload.status,
|
||||
created_at=datetime.utcnow().isoformat(),
|
||||
).model_dump(),
|
||||
meta={"account_id": account_id},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Campaign creation failed: %s", exc)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
|
||||
|
||||
|
||||
# ── 2. POST /creative/sync ────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/creative/sync", summary="Upload ComfyUI asset to Meta Ad Library")
|
||||
async def sync_creative(
|
||||
request: Request,
|
||||
payload: CreativeSyncRequest,
|
||||
) -> dict:
|
||||
"""
|
||||
Uploads an image or video URL (from ComfyUI / Wan 2.2 / Qwen-Image 2512) to
|
||||
the Meta Ad Library (Creative Hub) and returns the Meta Asset ID.
|
||||
|
||||
Requires: META_ACCESS_TOKEN, META_AD_ACCOUNT_ID
|
||||
"""
|
||||
_api, account_id = _get_sdk()
|
||||
account_id = payload.ad_account_id or account_id
|
||||
|
||||
try:
|
||||
from facebook_business.adobjects.adaccount import AdAccount # type: ignore
|
||||
from facebook_business.adobjects.advideo import AdVideo # type: ignore
|
||||
from facebook_business.adobjects.adimage import AdImage # type: ignore
|
||||
|
||||
account = AdAccount(account_id)
|
||||
|
||||
if payload.asset_type == "video":
|
||||
# Video upload via file_url
|
||||
result = account.create_ad_video(params={
|
||||
AdVideo.Field.name: payload.asset_name,
|
||||
AdVideo.Field.file_url: payload.asset_url,
|
||||
})
|
||||
meta_asset_id = result["id"]
|
||||
else:
|
||||
# Image upload via url
|
||||
result = account.create_ad_image(params={
|
||||
"filename": payload.asset_name,
|
||||
"url": payload.asset_url,
|
||||
})
|
||||
# AdImage returns a hash dict — extract hash key
|
||||
meta_asset_id = list(result["images"].values())[0]["hash"] \
|
||||
if "images" in result else result.get("id", "unknown")
|
||||
|
||||
return _ok({
|
||||
"meta_asset_id": meta_asset_id,
|
||||
"asset_name": payload.asset_name,
|
||||
"asset_type": payload.asset_type,
|
||||
"uploaded_at": datetime.utcnow().isoformat(),
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.error("Creative sync failed: %s", exc)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
|
||||
|
||||
|
||||
# ── 3. GET /insights/realtime ─────────────────────────────────────────────────
|
||||
|
||||
@router.get("/insights/realtime", summary="Poll Meta Ads Insights API")
|
||||
async def get_realtime_insights(
|
||||
date_preset: str = "last_7_days",
|
||||
level: str = "adset",
|
||||
) -> dict:
|
||||
"""
|
||||
Polls `AdAccount.get_insights()` for CTR, CPA, spend, impressions across Ad Sets.
|
||||
Supports `date_preset` (e.g. 'today', 'last_7_days', 'last_30_days') and
|
||||
`level` ('campaign', 'adset', 'ad').
|
||||
|
||||
Requires: META_ACCESS_TOKEN, META_AD_ACCOUNT_ID
|
||||
"""
|
||||
_api, account_id = _get_sdk()
|
||||
|
||||
try:
|
||||
from facebook_business.adobjects.adaccount import AdAccount # type: ignore
|
||||
from facebook_business.adobjects.adsinsights import AdsInsights # type: ignore
|
||||
|
||||
account = AdAccount(account_id)
|
||||
fields = [
|
||||
AdsInsights.Field.campaign_name,
|
||||
AdsInsights.Field.adset_name,
|
||||
AdsInsights.Field.spend,
|
||||
AdsInsights.Field.impressions,
|
||||
AdsInsights.Field.clicks,
|
||||
AdsInsights.Field.ctr,
|
||||
AdsInsights.Field.cpp, # cost per purchase (proxy for CPA)
|
||||
AdsInsights.Field.date_start,
|
||||
AdsInsights.Field.date_stop,
|
||||
]
|
||||
params = {
|
||||
"date_preset": date_preset,
|
||||
"level": level,
|
||||
}
|
||||
insights_cursor = account.get_insights(fields=fields, params=params)
|
||||
results = [dict(row) for row in insights_cursor]
|
||||
|
||||
return _ok(results, meta={
|
||||
"account_id": account_id,
|
||||
"date_preset": date_preset,
|
||||
"level": level,
|
||||
"count": len(results),
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.error("Insights fetch failed: %s", exc)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
|
||||
|
||||
|
||||
# ── 4. POST /audiences/lookalike ──────────────────────────────────────────────
|
||||
|
||||
@router.post("/audiences/lookalike", summary="Push Supabase CRM leads → Meta Lookalike Audience")
|
||||
async def create_lookalike_audience(
|
||||
request: Request,
|
||||
payload: LookalikeAudienceRequest,
|
||||
) -> dict:
|
||||
"""
|
||||
1. Queries the Supabase `leads` table for rows matching `status = payload.crm_filter_status`.
|
||||
2. SHA-256 hashes their email addresses.
|
||||
3. Creates (or updates) a Meta Custom Audience with the hashed emails.
|
||||
4. Creates a Lookalike Audience from that Custom Audience.
|
||||
|
||||
Requires: META_ACCESS_TOKEN, META_AD_ACCOUNT_ID, SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY
|
||||
"""
|
||||
_api, account_id = _get_sdk()
|
||||
supabase = _get_supabase()
|
||||
|
||||
# ── Step 1: Fetch qualified leads from Supabase CRM ──
|
||||
try:
|
||||
response = supabase.table("leads") \
|
||||
.select("id, email, name") \
|
||||
.eq("status", payload.crm_filter_status) \
|
||||
.execute()
|
||||
leads = response.data or []
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Supabase query failed: {exc}")
|
||||
|
||||
if not leads:
|
||||
return _ok({"message": f"No leads found with status '{payload.crm_filter_status}'."})
|
||||
|
||||
# ── Step 2: Hash emails ──
|
||||
hashed_emails = [
|
||||
_sha256_hash(lead["email"])
|
||||
for lead in leads
|
||||
if lead.get("email")
|
||||
]
|
||||
if not hashed_emails:
|
||||
raise HTTPException(status_code=422, detail="No valid email addresses found in the filtered leads.")
|
||||
|
||||
# ── Step 3: Create / update Meta Custom Audience ──
|
||||
try:
|
||||
from facebook_business.adobjects.adaccount import AdAccount # type: ignore
|
||||
from facebook_business.adobjects.customaudience import CustomAudience # type: ignore
|
||||
|
||||
account = AdAccount(account_id)
|
||||
audience_name = f"Velocity CRM — {payload.crm_filter_status} Leads"
|
||||
|
||||
# Create custom audience
|
||||
custom_audience = account.create_custom_audience(params={
|
||||
CustomAudience.Field.name: audience_name,
|
||||
CustomAudience.Field.subtype: "CUSTOM",
|
||||
CustomAudience.Field.description: f"Auto-generated from Velocity CRM — {len(hashed_emails)} leads",
|
||||
"customer_file_source": "USER_PROVIDED_ONLY",
|
||||
})
|
||||
audience_id = custom_audience["id"]
|
||||
|
||||
# Add users via hashed emails
|
||||
custom_audience.create_users_replace(params={
|
||||
"payload": {
|
||||
"schema": ["EMAIL_SHA256"],
|
||||
"data": [[h] for h in hashed_emails],
|
||||
}
|
||||
})
|
||||
|
||||
# ── Step 4: Create Lookalike Audience ──
|
||||
lookalike = account.create_lookalike_audience(params={
|
||||
"name": f"Velocity Lookalike — {payload.crm_filter_status} ({int(payload.ratio * 100)}%)",
|
||||
"origin_audience_id": audience_id,
|
||||
"lookalike_spec": {
|
||||
"type": "similarity",
|
||||
"ratio": payload.ratio,
|
||||
"country": payload.country,
|
||||
},
|
||||
})
|
||||
|
||||
# Broadcast live event
|
||||
if hasattr(request.app.state, "broadcast_live_event"):
|
||||
await request.app.state.broadcast_live_event(
|
||||
"create",
|
||||
f"Created Lookalike Audience from {len(hashed_emails)} CRM Closed/Won leads.",
|
||||
None,
|
||||
f"+{len(hashed_emails):,} leads",
|
||||
)
|
||||
|
||||
return _ok({
|
||||
"custom_audience_id": audience_id,
|
||||
"lookalike_audience_id": lookalike["id"],
|
||||
"leads_processed": len(hashed_emails),
|
||||
"country": payload.country,
|
||||
"ratio": payload.ratio,
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.error("Audience creation failed: %s", exc)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
|
||||
|
||||
|
||||
# ── 5. POST /auth/meta ────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/auth/meta", summary="Exchange short-lived token for System User token")
|
||||
async def meta_oauth(payload: MetaAuthRequest) -> dict:
|
||||
"""
|
||||
Exchanges a short-lived Meta user token for a long-lived token using the
|
||||
`/oauth/access_token` endpoint, then stores it in Supabase for persistence.
|
||||
|
||||
Requires: META_APP_ID, META_APP_SECRET
|
||||
"""
|
||||
import httpx
|
||||
|
||||
app_id = os.getenv("META_APP_ID", "")
|
||||
app_secret = os.getenv("META_APP_SECRET", "")
|
||||
api_ver = os.getenv("META_API_VERSION", "v21.0")
|
||||
|
||||
if app_id.startswith("PLACEHOLDER") or app_secret.startswith("PLACEHOLDER"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="META_APP_ID or META_APP_SECRET not configured.",
|
||||
)
|
||||
|
||||
url = f"https://graph.facebook.com/{api_ver}/oauth/access_token"
|
||||
params = {
|
||||
"grant_type": "fb_exchange_token",
|
||||
"client_id": app_id,
|
||||
"client_secret": app_secret,
|
||||
"fb_exchange_token": payload.short_lived_token,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url, params=params, timeout=15.0)
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Meta OAuth error: {resp.text}",
|
||||
)
|
||||
|
||||
token_data = resp.json()
|
||||
long_lived_token = token_data.get("access_token")
|
||||
|
||||
if not long_lived_token:
|
||||
raise HTTPException(status_code=502, detail="No access_token in Meta response.")
|
||||
|
||||
# Persist to Supabase (best-effort — don't block on failure)
|
||||
try:
|
||||
supabase = _get_supabase()
|
||||
supabase.table("catalyst_settings").upsert({
|
||||
"key": "META_ACCESS_TOKEN",
|
||||
"value": long_lived_token,
|
||||
"updated_at": datetime.utcnow().isoformat(),
|
||||
}).execute()
|
||||
except Exception as exc:
|
||||
logger.warning("Could not persist Meta token to Supabase: %s", exc)
|
||||
|
||||
return _ok({
|
||||
"access_token": long_lived_token,
|
||||
"token_type": token_data.get("token_type", "bearer"),
|
||||
"expires_in": token_data.get("expires_in"),
|
||||
})
|
||||
115
backend/main.py
115
backend/main.py
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
The Catalyst — FastAPI Backend
|
||||
Autonomous Digital Marketing Agency powered by Meta Marketing API.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Set
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from api.routes_catalyst import router as catalyst_router
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# ── App ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
app = FastAPI(
|
||||
title="Velocity — Catalyst Backend",
|
||||
description="Meta Marketing API integration for autonomous campaign management.",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
# ── CORS ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
origins = [o.strip() for o in os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",")]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ── Routers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.include_router(catalyst_router, prefix="/api/catalyst", tags=["Catalyst"])
|
||||
|
||||
# ── WebSocket — Live Optimization Feed ────────────────────────────────────────
|
||||
|
||||
class ConnectionManager:
|
||||
"""Manages active WebSocket connections for live optimization broadcasts."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.active: Set[WebSocket] = set()
|
||||
|
||||
async def connect(self, ws: WebSocket) -> None:
|
||||
await ws.accept()
|
||||
self.active.add(ws)
|
||||
|
||||
def disconnect(self, ws: WebSocket) -> None:
|
||||
self.active.discard(ws)
|
||||
|
||||
async def broadcast(self, payload: dict) -> None:
|
||||
dead: Set[WebSocket] = set()
|
||||
for ws in self.active:
|
||||
try:
|
||||
await ws.send_text(json.dumps(payload))
|
||||
except Exception:
|
||||
dead.add(ws)
|
||||
self.active -= dead
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
|
||||
@app.websocket("/ws/catalyst")
|
||||
async def websocket_endpoint(ws: WebSocket) -> None:
|
||||
"""
|
||||
WebSocket endpoint for streaming live Claw Agent optimization events.
|
||||
Clients connect from <LiveOptimizationFeed /> in Catalyst.tsx.
|
||||
"""
|
||||
await manager.connect(ws)
|
||||
try:
|
||||
while True:
|
||||
# Keep-alive: wait for any incoming ping/message
|
||||
data = await ws.receive_text()
|
||||
# Echo back as acknowledgment (clients may send heartbeat pings)
|
||||
await ws.send_text(json.dumps({"type": "ack", "data": data}))
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(ws)
|
||||
|
||||
|
||||
# ── Helper: broadcast a live event (called from routes after API mutations) ───
|
||||
|
||||
async def broadcast_live_event(
|
||||
event_type: str,
|
||||
message: str,
|
||||
campaign_name: str | None = None,
|
||||
value: str | None = None,
|
||||
) -> None:
|
||||
payload = {
|
||||
"type": event_type,
|
||||
"message": message,
|
||||
"campaignName": campaign_name,
|
||||
"value": value,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
await manager.broadcast(payload)
|
||||
|
||||
|
||||
# Attach broadcaster so routes can call it
|
||||
app.state.broadcast_live_event = broadcast_live_event
|
||||
|
||||
|
||||
# ── Health check ──────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/health", tags=["Health"])
|
||||
async def health() -> dict:
|
||||
return {"status": "ok", "service": "catalyst-backend", "version": "1.0.0"}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.32.0
|
||||
facebook-business>=21.0.0
|
||||
supabase>=2.10.0
|
||||
python-dotenv>=1.0.0
|
||||
httpx>=0.27.0
|
||||
pydantic>=2.9.0
|
||||
python-multipart>=0.0.12
|
||||
|
||||
Reference in New Issue
Block a user