"""Meta Login and Marketing API access for the signed-in customer only. User tokens stay on the server. Responses never include tokens, raw Graph payloads, or unmasked internal identifiers except the opaque ids the console needs to call a follow-up action. """ from __future__ import annotations import json import re import uuid from datetime import datetime, timedelta, timezone from typing import Any from urllib.parse import urlencode import httpx from fastapi import HTTPException from sqlalchemy import select from sqlalchemy.orm import Session from .auth import create_token, decode_token from .config import settings from .models import MetaAuditEvent, MetaConnection, UserAccount from .whatsapp_onboarding import list_accounts META_SCOPES = ( "public_profile", "pages_show_list", "pages_read_engagement", "business_management", "ads_read", "ads_management", ) SCOPE_LABELS = { "public_profile": "Public profile", "pages_show_list": "Page list", "pages_read_engagement": "Page engagement", "business_management": "Business portfolio", "ads_read": "Ads reporting", "ads_management": "Ads management", } PUBLISHABLE_OBJECTIVES = {"OUTCOME_TRAFFIC", "OUTCOME_ENGAGEMENT", "OUTCOME_AWARENESS"} OBJECTIVE_LABELS = { "OUTCOME_TRAFFIC": "Traffic", "OUTCOME_ENGAGEMENT": "Engagement", "OUTCOME_AWARENESS": "Awareness", "OUTCOME_LEADS": "Leads", "OUTCOME_SALES": "Sales", "OUTCOME_APP_PROMOTION": "App promotion", } CTA_TYPES = {"LEARN_MORE", "SHOP_NOW", "SIGN_UP", "CONTACT_US", "WHATSAPP_MESSAGE", "SEND_MESSAGE"} ACCOUNT_STATUS = { 1: "Active", 2: "Disabled", 3: "Unsettled", 7: "Pending review", 8: "Pending settlement", 9: "Grace period", 100: "Pending closure", 101: "Closed", 201: "Active", 202: "Closed", } ZERO_DECIMAL = {"BIF", "CLP", "DJF", "GNF", "JPY", "KMF", "KRW", "MGA", "PYG", "RWF", "VND", "VUV", "XAF", "XOF", "XPF"} class MetaApiError(Exception): def __init__( self, status_code: int, code: str, message: str, permission: str | None = None, graph_code: int | None = None, ) -> None: self.status_code = status_code self.code = code self.message = message self.permission = permission self.graph_code = graph_code super().__init__(message) def http_meta(exc: MetaApiError) -> HTTPException: detail: dict[str, Any] = {"code": exc.code, "message": exc.message} if exc.permission: detail["permission"] = exc.permission if exc.graph_code is not None: detail["graph_code"] = exc.graph_code return HTTPException(status_code=exc.status_code, detail=detail) def safe_text(value: Any, limit: int = 400) -> str: text = str(value or "") text = re.sub(r"(access_token|token|secret)=([^&\s]+)", r"\1=redacted", text, flags=re.I) return text[:limit] def mask_identifier(value: str) -> str: raw = (value or "").strip() if not raw: return "" prefix = "act_" if raw.startswith("act_") else "" body = raw[4:] if prefix else raw if len(body) <= 4: return f"{prefix}••••" return f"{prefix}••••{body[-4:]}" def _same_account(left: Any, right: Any) -> bool: return act_id(str(left or "")) == act_id(str(right or "")) def act_id(value: str) -> str: raw = (value or "").strip() if not raw: return "" return raw if raw.startswith("act_") else f"act_{raw}" def _equal_filter(field: str, value: str) -> str: return json.dumps([{"field": field, "operator": "EQUAL", "value": value}]) def _in_filter(field: str, values: list[str]) -> str: return json.dumps([{"field": field, "operator": "IN", "value": values}]) def _is_ads_api_path(path: str) -> bool: """ads_read / ads_management live on Marketing API paths, not Page/Business Graph.""" cleaned = path.lstrip("/") if cleaned == "me/adaccounts" or cleaned.startswith("me/adaccounts?"): return True if cleaned.startswith("act_"): return True return bool(re.fullmatch(r"\d+(?:/[A-Za-z_]+)?", cleaned)) async def ads_api( db: Session, conn: MetaConnection, method: str, path: str, params: dict[str, Any] | None = None, body: dict[str, Any] | None = None, ) -> dict[str, Any]: """Marketing API for ads_read and ads_management (campaigns, ad sets, ads, insights).""" if not _is_ads_api_path(path): raise MetaApiError(500, "graph_error", "Ads operations must use the Marketing API on an ad account.") return await graph_as_user(db, conn, method, path.lstrip("/"), params, body) async def marketing_get( db: Session, conn: MetaConnection, account_id: str, edge: str, params: dict[str, Any] | None = None, ) -> dict[str, Any]: """GET /act_{id}/{edge} — Ads Insights / Ads Management read.""" return await ads_api(db, conn, "GET", f"{act_id(account_id)}/{edge.lstrip('/')}", params) async def marketing_post( db: Session, conn: MetaConnection, account_id: str, edge: str, body: dict[str, Any], ) -> dict[str, Any]: """POST /act_{id}/{edge} — Ads Management write.""" return await ads_api(db, conn, "POST", f"{act_id(account_id)}/{edge.lstrip('/')}", body=body) async def ads_object_post( db: Session, conn: MetaConnection, object_id: str, body: dict[str, Any], ) -> dict[str, Any]: """POST /{campaign|adset|ad id} status and field updates (Marketing API object).""" if not re.fullmatch(r"\d+", str(object_id)): raise MetaApiError(422, "validation", "That Ads object is not valid for this ad account.") return await ads_api(db, conn, "POST", str(object_id), body=body) def currency_offset(currency: str) -> int: return 1 if (currency or "").upper() in ZERO_DECIMAL else 100 def minor_to_major(amount: Any, currency: str) -> float | None: if amount is None or amount == "": return None try: return float(amount) / currency_offset(currency) except (TypeError, ValueError): return None def money(amount: float | None, currency: str) -> str | None: if amount is None: return None code = (currency or "").upper() if code == "INR": return f"₹{amount:,.2f}" if not code: return f"{amount:,.2f}" return f"{code} {amount:,.2f}" def scopes_of(conn: MetaConnection) -> set[str]: return {item.strip() for item in (conn.granted_scopes or "").split(",") if item.strip()} def has_scope(conn: MetaConnection, scope: str) -> bool: return scope in scopes_of(conn) def capabilities(conn: MetaConnection | None) -> dict[str, bool]: granted = scopes_of(conn) if conn and conn.status == "connected" else set() return { "profile": "public_profile" in granted or bool(conn and conn.meta_user_name and conn.status == "connected"), "pages": "pages_show_list" in granted, "page_engagement": "pages_read_engagement" in granted, "business": "business_management" in granted, "ads_read": "ads_read" in granted, "ads_manage": "ads_management" in granted, } def load_connection(db: Session, user: UserAccount) -> MetaConnection | None: return db.scalar(select(MetaConnection).where(MetaConnection.user_id == user.id)) def ensure_configured() -> None: if not settings.meta_oauth_configured: raise MetaApiError( 503, "not_configured", "Meta Login is not configured for this Urmi environment yet.", ) def require_connection(db: Session, user: UserAccount) -> MetaConnection: conn = load_connection(db, user) if not conn or conn.status == "disconnected" or not conn.access_token: raise MetaApiError(409, "not_connected", "Connect Meta before using this screen.") expires = conn.token_expires_at if expires and expires.tzinfo is None: expires = expires.replace(tzinfo=timezone.utc) if conn.status == "expired" or (expires and expires <= datetime.now(timezone.utc)): conn.status = "expired" conn.last_error = "The Meta connection has expired. Reconnect to continue." db.commit() raise MetaApiError(401, "token_expired", conn.last_error, graph_code=190) return conn def require_scope(conn: MetaConnection, scope: str) -> None: if has_scope(conn, scope): return label = SCOPE_LABELS.get(scope, scope) raise MetaApiError( 403, "permission_denied", f"Meta has not granted {label} for this connection.", permission=scope, ) def audit( db: Session, user: UserAccount, action: str, *, asset_type: str = "", asset_label: str = "", asset_id: str = "", detail: str = "", status: str = "success", error_message: str = "", graph_code: int | None = None, ) -> None: db.add( MetaAuditEvent( user_id=user.id, action=action, asset_type=asset_type, asset_label=asset_label[:255], asset_id=asset_id[:64], detail=safe_text(detail, 1000), status=status, error_message=safe_text(error_message), graph_code=graph_code, ) ) db.commit() def public_connection(conn: MetaConnection | None) -> dict[str, Any]: redirect_uri = settings.meta_oauth_redirect_uri_resolved if not conn or conn.status == "disconnected": return { "configured": settings.meta_oauth_configured, "connected": False, "status": "disconnected", "user_name": "", "connected_at": None, "expires_at": None, "scopes": [], "capabilities": capabilities(None), "selected_page_name": "", "selected_ad_account_name": "", "last_error": "", "oauth_redirect_uri": redirect_uri, } return { "configured": settings.meta_oauth_configured, "connected": conn.status == "connected", "status": conn.status, "user_name": conn.meta_user_name, "connected_at": conn.connected_at, "expires_at": conn.token_expires_at, "scopes": [ {"scope": scope, "label": SCOPE_LABELS.get(scope, scope)} for scope in META_SCOPES if scope in scopes_of(conn) ], "capabilities": capabilities(conn), "selected_page_name": conn.selected_page_name, "selected_ad_account_name": conn.selected_ad_account_name, "last_error": conn.last_error, "oauth_redirect_uri": redirect_uri, } def authorization_url(user: UserAccount) -> str: ensure_configured() state = create_token(user.id, purpose="meta_oauth", minutes=10) params = { "client_id": settings.meta_app_id, "redirect_uri": settings.meta_oauth_redirect_uri_resolved, "state": state, "response_type": "code", "scope": ",".join(META_SCOPES), } if settings.meta_login_config_id: params["config_id"] = settings.meta_login_config_id version = settings.whatsapp_api_version or "v21.0" return f"https://www.facebook.com/{version}/dialog/oauth?{urlencode(params)}" async def finish_oauth(db: Session, code: str, state: str) -> None: ensure_configured() payload = decode_token(state, purpose="meta_oauth") try: user_id = uuid.UUID(payload["sub"]) except (KeyError, ValueError) as exc: raise MetaApiError(401, "token_expired", "The Meta login attempt expired. Start it again.") from exc user = db.scalar(select(UserAccount).where(UserAccount.id == user_id, UserAccount.status == "active")) if not user: raise MetaApiError(401, "token_expired", "The Meta login attempt expired. Start it again.") short = await _exchange_code(code) long_token = await _exchange_long_lived(short["access_token"]) token = long_token.get("access_token") or short["access_token"] expires_in = long_token.get("expires_in") or short.get("expires_in") profile = await graph("GET", "me", token, {"fields": "id,name"}) permission_rows = await graph("GET", "me/permissions", token) granted = [ row.get("permission") for row in permission_rows.get("data") or [] if isinstance(row, dict) and row.get("status") == "granted" and row.get("permission") ] conn = load_connection(db, user) or MetaConnection(user_id=user.id) conn.meta_user_id = str(profile.get("id") or "") conn.meta_user_name = str(profile.get("name") or "") conn.granted_scopes = ",".join(scope for scope in META_SCOPES if scope in set(granted)) conn.access_token = token conn.status = "connected" conn.last_error = "" conn.last_graph_code = None conn.connected_at = datetime.now(timezone.utc) if expires_in: conn.token_expires_at = datetime.now(timezone.utc) + timedelta(seconds=int(expires_in)) else: conn.token_expires_at = None db.add(conn) db.commit() audit(db, user, "connection.authorized", asset_type="connection", asset_label=conn.meta_user_name, detail="Meta Login completed") async def connect_with_user_token(db: Session, user: UserAccount, access_token: str) -> dict[str, Any]: """Store a customer-supplied user token without the Facebook login redirect.""" token = "".join(str(access_token or "").split()) if token.lower().startswith("bearer"): token = token[6:] if len(token) < 20: raise MetaApiError(400, "validation", "Paste the access token from Meta.") profile = await graph("GET", "me", token, {"fields": "id,name"}) permission_rows = await graph("GET", "me/permissions", token) granted = [ row.get("permission") for row in permission_rows.get("data") or [] if isinstance(row, dict) and row.get("status") == "granted" and row.get("permission") ] scopes = [scope for scope in META_SCOPES if scope in set(granted)] if not any(scope != "public_profile" for scope in scopes): raise MetaApiError( 403, "permission_denied", "This token cannot see Pages or ad accounts. Use a token that includes those permissions.", ) stored = token expires_at = None if settings.meta_app_id and settings.meta_graph_app_secret: long_token = await _exchange_long_lived(token) if long_token.get("access_token"): stored = str(long_token["access_token"]) expires_in = long_token.get("expires_in") if expires_in: expires_at = datetime.now(timezone.utc) + timedelta(seconds=int(expires_in)) conn = load_connection(db, user) or MetaConnection(user_id=user.id) conn.meta_user_id = str(profile.get("id") or "") conn.meta_user_name = str(profile.get("name") or "") conn.granted_scopes = ",".join(scopes) conn.access_token = stored conn.status = "connected" conn.last_error = "" conn.last_graph_code = None conn.connected_at = datetime.now(timezone.utc) conn.token_expires_at = expires_at db.add(conn) db.commit() audit(db, user, "connection.authorized", asset_type="connection", asset_label=conn.meta_user_name, detail="Access token saved") return public_connection(conn) async def _exchange_code(code: str) -> dict[str, Any]: return await graph( "GET", "oauth/access_token", token=None, params={ "client_id": settings.meta_app_id, "client_secret": settings.meta_graph_app_secret, "redirect_uri": settings.meta_oauth_redirect_uri_resolved, "code": code, }, ) async def _exchange_long_lived(token: str) -> dict[str, Any]: try: return await graph( "GET", "oauth/access_token", token=None, params={ "grant_type": "fb_exchange_token", "client_id": settings.meta_app_id, "client_secret": settings.meta_graph_app_secret, "fb_exchange_token": token, }, ) except MetaApiError: return {} async def graph( method: str, path: str, token: str | None, params: dict[str, Any] | None = None, body: dict[str, Any] | None = None, ) -> dict[str, Any]: version = settings.whatsapp_api_version or "v21.0" url = f"https://graph.facebook.com/{version}/{path.lstrip('/')}" headers = {"Authorization": f"Bearer {token}"} if token else {} async with httpx.AsyncClient(timeout=25) as client: response = await client.request(method, url, params=params, json=body, headers=headers) payload: dict[str, Any] = {} try: parsed = response.json() if isinstance(parsed, dict): payload = parsed except ValueError: payload = {} if response.status_code >= 400: err = payload.get("error") if isinstance(payload.get("error"), dict) else {} graph_code = err.get("code") if isinstance(err.get("code"), int) else None message = safe_text(err.get("message") or "Meta could not complete this request.") if graph_code == 190: raise MetaApiError(401, "token_expired", "The Meta connection has expired. Reconnect to continue.", graph_code=190) if graph_code in {10, 200, 294} or "permission" in message.lower() or "requires" in message.lower(): raise MetaApiError( 403, "permission_denied", "Meta did not allow this request with the permissions granted to Urmi.", graph_code=graph_code, ) raise MetaApiError(502, "graph_error", message, graph_code=graph_code) return payload def remember_graph_error(db: Session, conn: MetaConnection, exc: MetaApiError) -> None: conn.last_error = exc.message conn.last_graph_code = exc.graph_code if exc.code == "token_expired": conn.status = "expired" db.commit() async def graph_as_user( db: Session, conn: MetaConnection, method: str, path: str, params: dict[str, Any] | None = None, body: dict[str, Any] | None = None, ) -> dict[str, Any]: try: return await graph(method, path, conn.access_token, params, body) except MetaApiError as exc: remember_graph_error(db, conn, exc) raise def disconnect(db: Session, user: UserAccount) -> None: conn = load_connection(db, user) if not conn or conn.status == "disconnected": raise MetaApiError(409, "not_connected", "Meta is not connected for this workspace.") label = conn.meta_user_name conn.access_token = "" conn.status = "disconnected" conn.selected_page_id = "" conn.selected_page_name = "" conn.selected_ad_account_id = "" conn.selected_ad_account_name = "" conn.last_error = "" conn.last_graph_code = None db.commit() audit(db, user, "connection.disconnected", asset_type="connection", asset_label=label, detail="Customer disconnected Meta") async def list_pages(db: Session, user: UserAccount) -> dict[str, Any]: conn = require_connection(db, user) require_scope(conn, "pages_show_list") data = await graph_as_user( db, conn, "GET", "me/accounts", {"fields": "id,name,username,link,fan_count,category,tasks", "limit": "50"}, ) pages = [] for row in data.get("data") or []: if not isinstance(row, dict) or not row.get("id"): continue pages.append(_page_summary(row, selected=row.get("id") == conn.selected_page_id)) return {"pages": pages, "selected_name": conn.selected_page_name} def _page_summary(row: dict[str, Any], selected: bool) -> dict[str, Any]: tasks = row.get("tasks") if isinstance(row.get("tasks"), list) else [] return { "id": str(row.get("id")), "name": row.get("name") or "Untitled Page", "username": row.get("username") or "", "category": row.get("category") or "", "link": row.get("link") or "", "fan_count": row.get("fan_count") if isinstance(row.get("fan_count"), int) else None, "tasks": [str(task) for task in tasks], "selected": selected, } async def page_detail(db: Session, user: UserAccount, page_id: str) -> dict[str, Any]: conn = require_connection(db, user) require_scope(conn, "pages_show_list") page, page_token = await _authorized_page(db, conn, page_id) posts: list[dict[str, Any]] = [] engagement_error = None if has_scope(conn, "pages_read_engagement"): try: payload = await graph( "GET", f"{page_id}/posts", page_token or conn.access_token, { "fields": "message,created_time,permalink_url,full_picture,likes.summary(true),comments.summary(true)", "limit": "15", }, ) for row in payload.get("data") or []: if isinstance(row, dict): posts.append(_post_summary(row)) except MetaApiError as exc: remember_graph_error(db, conn, exc) engagement_error = {"code": exc.code, "message": exc.message, "permission": exc.permission} else: engagement_error = { "code": "permission_denied", "message": "Meta has not granted Page engagement for this connection.", "permission": "pages_read_engagement", } return { "page": page, "posts": posts, "engagement": { "available": engagement_error is None, "error": engagement_error, "note": "Page insight charts need a separate Page insights permission, which this connection does not include.", }, } def _post_summary(row: dict[str, Any]) -> dict[str, Any]: likes = row.get("likes") if isinstance(row.get("likes"), dict) else {} comments = row.get("comments") if isinstance(row.get("comments"), dict) else {} like_summary = likes.get("summary") if isinstance(likes.get("summary"), dict) else {} comment_summary = comments.get("summary") if isinstance(comments.get("summary"), dict) else {} return { "message": row.get("message") or "", "created_time": row.get("created_time"), "permalink_url": row.get("permalink_url") or "", "picture_url": row.get("full_picture") or "", "reactions": like_summary.get("total_count") if isinstance(like_summary.get("total_count"), int) else 0, "comments": comment_summary.get("total_count") if isinstance(comment_summary.get("total_count"), int) else 0, } async def _authorized_page(db: Session, conn: MetaConnection, page_id: str) -> tuple[dict[str, Any], str]: data = await graph_as_user( db, conn, "GET", "me/accounts", {"fields": "id,name,username,link,fan_count,category,tasks,access_token", "limit": "100"}, ) for row in data.get("data") or []: if isinstance(row, dict) and str(row.get("id")) == page_id: token = str(row.get("access_token") or "") summary = _page_summary(row, selected=page_id == conn.selected_page_id) return summary, token raise MetaApiError(404, "not_found", "That Page is not available to this Meta connection.") async def select_page(db: Session, user: UserAccount, page_id: str) -> dict[str, Any]: conn = require_connection(db, user) require_scope(conn, "pages_show_list") page, _token = await _authorized_page(db, conn, page_id) conn.selected_page_id = page_id conn.selected_page_name = str(page["name"])[:255] db.commit() audit(db, user, "page.selected", asset_type="page", asset_label=conn.selected_page_name, asset_id=page_id) return {"name": conn.selected_page_name} async def list_businesses(db: Session, user: UserAccount) -> dict[str, Any]: conn = require_connection(db, user) require_scope(conn, "business_management") data = await graph_as_user(db, conn, "GET", "me/businesses", {"fields": "id,name", "limit": "25"}) businesses = [] for row in data.get("data") or []: if isinstance(row, dict) and row.get("name"): businesses.append({"name": row.get("name")}) return {"businesses": businesses} async def list_ad_accounts(db: Session, user: UserAccount) -> dict[str, Any]: conn = require_connection(db, user) if not (has_scope(conn, "ads_read") or has_scope(conn, "ads_management")): raise MetaApiError( 403, "permission_denied", "Meta has not granted Ads reporting or Ads management for this connection.", permission="ads_read", ) data = await ads_api( db, conn, "GET", "me/adaccounts", {"fields": "id,name,account_status,currency,amount_spent,business{name}", "limit": "50"}, ) rows: dict[str, dict[str, Any]] = {} for row in data.get("data") or []: if not isinstance(row, dict) or not row.get("id"): continue business = row.get("business") if isinstance(row.get("business"), dict) else {} row["business_name"] = business.get("name") or "" rows[act_id(str(row["id"]))] = row accounts = [ _account_summary(row, selected=act_id(str(row.get("id"))) == act_id(conn.selected_ad_account_id)) for row in rows.values() ] accounts.sort(key=lambda item: item["name"].lower()) return {"accounts": accounts, "selected_name": conn.selected_ad_account_name} def _account_summary(row: dict[str, Any], selected: bool) -> dict[str, Any]: currency = str(row.get("currency") or "") spent = minor_to_major(row.get("amount_spent"), currency) status_code = row.get("account_status") return { "id": act_id(str(row.get("id"))), "name": row.get("name") or "Ad account", "business_name": row.get("business_name") or "", "currency": currency, "status": ACCOUNT_STATUS.get(status_code, "Unknown") if isinstance(status_code, int) else "Unknown", "amount_spent": money(spent, currency), "selected": selected, "ref": mask_identifier(act_id(str(row.get("id")))), } async def select_ad_account(db: Session, user: UserAccount, account_id: str) -> dict[str, Any]: conn = require_connection(db, user) if not (has_scope(conn, "ads_read") or has_scope(conn, "ads_management")): raise MetaApiError(403, "permission_denied", "Meta has not granted Ads reporting for this connection.", permission="ads_read") accounts = await list_ad_accounts(db, user) match = next((row for row in accounts["accounts"] if row["id"] == act_id(account_id)), None) if not match: raise MetaApiError(404, "not_found", "That ad account is not available to this Meta connection.") conn.selected_ad_account_id = match["id"] conn.selected_ad_account_name = str(match["name"])[:255] db.commit() audit(db, user, "ad_account.selected", asset_type="ad_account", asset_label=conn.selected_ad_account_name, asset_id=conn.selected_ad_account_id) return {"name": conn.selected_ad_account_name} async def _selected_account(db: Session, user: UserAccount, manage: bool) -> tuple[MetaConnection, str]: conn = require_connection(db, user) if manage: require_scope(conn, "ads_management") elif not (has_scope(conn, "ads_read") or has_scope(conn, "ads_management")): require_scope(conn, "ads_read") if not conn.selected_ad_account_id: raise MetaApiError(409, "no_ad_account", "Select an ad account before opening campaigns.") return conn, act_id(conn.selected_ad_account_id) async def _account_object( db: Session, conn: MetaConnection, account_id: str, edge: str, object_id: str, fields: str, ) -> dict[str, Any]: data = await marketing_get( db, conn, account_id, edge, {"fields": fields, "filtering": _equal_filter("id", object_id), "limit": "1"}, ) rows = [row for row in data.get("data") or [] if isinstance(row, dict) and str(row.get("id")) == object_id] if not rows: raise MetaApiError(404, "not_found", "That object is not in the selected ad account.") return rows[0] def _adset_summary(row: dict[str, Any], currency: str) -> dict[str, Any]: campaign = row.get("campaign") if isinstance(row.get("campaign"), dict) else {} daily = minor_to_major(row.get("daily_budget"), currency) lifetime = minor_to_major(row.get("lifetime_budget"), currency) budget = money(daily, currency) if daily is not None else money(lifetime, currency) if lifetime is not None else None goal = str(row.get("optimization_goal") or "").replace("_", " ").title() return { "id": str(row.get("id")), "name": row.get("name") or "Ad set", "campaign_id": str(row.get("campaign_id") or campaign.get("id") or ""), "campaign_name": campaign.get("name") or "", "status": row.get("effective_status") or row.get("status") or "", "optimization_goal": goal or "—", "billing_event": str(row.get("billing_event") or "").replace("_", " ").title() or "—", "budget": budget, "start_time": row.get("start_time"), "end_time": row.get("end_time"), } async def list_adsets(db: Session, user: UserAccount, campaign_id: str | None = None) -> dict[str, Any]: conn, account_id = await _selected_account(db, user, manage=False) currency = await _account_currency(db, conn, account_id) params: dict[str, Any] = { "fields": "id,name,status,effective_status,daily_budget,lifetime_budget,optimization_goal,billing_event,campaign_id,start_time,end_time,campaign{name}", "limit": "50", } if campaign_id: params["filtering"] = _equal_filter("campaign.id", campaign_id) data = await marketing_get(db, conn, account_id, "adsets", params) adsets = [_adset_summary(row, currency) for row in data.get("data") or [] if isinstance(row, dict) and row.get("id")] return { "account_name": conn.selected_ad_account_name, "currency": currency, "adsets": adsets, } async def campaign_detail(db: Session, user: UserAccount, campaign_id: str) -> dict[str, Any]: conn, account_id = await _selected_account(db, user, manage=False) current = await _account_object( db, conn, account_id, "campaigns", campaign_id, "id,name,status,effective_status,objective,created_time", ) currency = await _account_currency(db, conn, account_id) adsets_payload = await marketing_get( db, conn, account_id, "adsets", { "fields": "id,name,status,effective_status,daily_budget,lifetime_budget,optimization_goal,billing_event,campaign_id,start_time,end_time", "filtering": _equal_filter("campaign.id", campaign_id), "limit": "50", }, ) adsets = [_adset_summary(row, currency) for row in adsets_payload.get("data") or [] if isinstance(row, dict) and row.get("id")] insights_error = None metrics: dict[str, Any] = {} try: report = await marketing_get( db, conn, account_id, "insights", { "fields": "campaign_id,spend,impressions,clicks,reach,ctr", "date_preset": "last_30d", "level": "campaign", "filtering": _in_filter("campaign.id", [campaign_id]), }, ) row = (report.get("data") or [{}])[0] if isinstance(report.get("data"), list) else {} if isinstance(row, dict): spend = _float(row.get("spend")) metrics = { "spend": money(spend, currency) if row else None, "impressions": _int(row.get("impressions")), "clicks": _int(row.get("clicks")), "reach": _int(row.get("reach")), "ctr": _float(row.get("ctr")), } except MetaApiError as exc: insights_error = {"code": exc.code, "message": exc.message} return { "account_name": conn.selected_ad_account_name, "currency": currency, "can_manage": has_scope(conn, "ads_management"), "campaign": { "id": str(current.get("id")), "name": current.get("name") or "Campaign", "status": current.get("effective_status") or current.get("status") or "", "objective": OBJECTIVE_LABELS.get(str(current.get("objective")), str(current.get("objective") or "").replace("OUTCOME_", "").title()), "created_time": current.get("created_time"), }, "adsets": adsets, "metrics": metrics, "insights_error": insights_error, } async def list_campaigns(db: Session, user: UserAccount) -> dict[str, Any]: conn, account_id = await _selected_account(db, user, manage=False) data = await marketing_get( db, conn, account_id, "campaigns", { "fields": "id,name,status,effective_status,objective,created_time", "limit": "50", }, ) insights: dict[str, dict[str, Any]] = {} insights_error = None try: report = await marketing_get( db, conn, account_id, "insights", { "level": "campaign", "fields": "campaign_id,spend,impressions,clicks,ctr,reach", "date_preset": "last_30d", "limit": "50", }, ) for row in report.get("data") or []: if isinstance(row, dict) and row.get("campaign_id"): insights[str(row["campaign_id"])] = row except MetaApiError as exc: insights_error = {"code": exc.code, "message": exc.message} currency = await _account_currency(db, conn, account_id) campaigns = [] for row in data.get("data") or []: if not isinstance(row, dict) or not row.get("id"): continue metric = insights.get(str(row["id"]), {}) spend = _float(metric.get("spend")) campaigns.append( { "id": str(row["id"]), "name": row.get("name") or "Campaign", "status": row.get("effective_status") or row.get("status") or "", "objective": OBJECTIVE_LABELS.get(str(row.get("objective")), str(row.get("objective") or "").replace("OUTCOME_", "").title()), "created_time": row.get("created_time"), "spend": money(spend, currency) if metric else None, "impressions": _int(metric.get("impressions")) if metric else None, "clicks": _int(metric.get("clicks")) if metric else None, "ctr": _float(metric.get("ctr")) if metric else None, "reach": _int(metric.get("reach")) if metric else None, } ) return { "account_name": conn.selected_ad_account_name, "currency": currency, "campaigns": campaigns, "can_manage": has_scope(conn, "ads_management"), "insights_error": insights_error, } async def _account_currency(db: Session, conn: MetaConnection, account_id: str) -> str: try: data = await ads_api(db, conn, "GET", account_id, {"fields": "currency,account_status,name"}) return str(data.get("currency") or "") except MetaApiError: return "" def _float(value: Any) -> float | None: try: return float(value) except (TypeError, ValueError): return None def _int(value: Any) -> int | None: try: return int(float(value)) except (TypeError, ValueError): return None async def set_campaign_status(db: Session, user: UserAccount, campaign_id: str, status: str) -> dict[str, Any]: if status not in {"ACTIVE", "PAUSED"}: raise MetaApiError(422, "validation", "Choose pause or resume.") conn, account_id = await _selected_account(db, user, manage=True) current = await _account_object(db, conn, account_id, "campaigns", campaign_id, "id,name") name = str(current.get("name") or "Campaign") action = "campaign.resume" if status == "ACTIVE" else "campaign.pause" try: await ads_object_post(db, conn, campaign_id, {"status": status}) except MetaApiError as exc: audit(db, user, action, asset_type="campaign", asset_label=name, asset_id=campaign_id, status="failed", error_message=exc.message, graph_code=exc.graph_code) raise audit(db, user, action, asset_type="campaign", asset_label=name, asset_id=campaign_id, detail=f"Status set to {status}") return {"name": name, "status": status} async def update_campaign(db: Session, user: UserAccount, campaign_id: str, name: str | None, daily_budget: float | None) -> dict[str, Any]: conn, account_id = await _selected_account(db, user, manage=True) current = await _account_object(db, conn, account_id, "campaigns", campaign_id, "id,name") if not name and daily_budget is None: raise MetaApiError(422, "validation", "Add a name or daily budget to update.") label = name or str(current.get("name") or "Campaign") try: if name: await ads_object_post(db, conn, campaign_id, {"name": name[:120]}) if daily_budget is not None: currency = await _account_currency(db, conn, account_id) adsets = await marketing_get( db, conn, account_id, "adsets", {"fields": "id,name", "filtering": _equal_filter("campaign.id", campaign_id), "limit": "5"}, ) rows = [row for row in adsets.get("data") or [] if isinstance(row, dict) and row.get("id")] if len(rows) != 1: raise MetaApiError(422, "feature_unavailable", "Budget can be edited here only when the campaign has one ad set.") minor = str(int(round(daily_budget * currency_offset(currency)))) await ads_object_post(db, conn, str(rows[0]["id"]), {"daily_budget": minor}) except MetaApiError as exc: audit(db, user, "campaign.edit", asset_type="campaign", asset_label=label, asset_id=campaign_id, status="failed", error_message=exc.message, graph_code=exc.graph_code) raise audit(db, user, "campaign.edit", asset_type="campaign", asset_label=label, asset_id=campaign_id, detail="Campaign details updated") return {"name": label} async def publish_campaign(db: Session, user: UserAccount, payload: dict[str, Any]) -> dict[str, Any]: conn, account_id = await _selected_account(db, user, manage=True) objective = str(payload.get("objective") or "") destination = str(payload.get("destination") or "") if objective not in PUBLISHABLE_OBJECTIVES: raise MetaApiError( 422, "feature_unavailable", "Leads, sales, and app promotion stay unavailable until this connection includes the assets those objectives require.", ) if destination not in {"website", "whatsapp", "page"}: raise MetaApiError(422, "validation", "Choose a destination Urmi can send to Meta.") if destination == "whatsapp" and objective == "OUTCOME_AWARENESS": raise MetaApiError(422, "feature_unavailable", "Awareness cannot be published to WhatsApp from this connection.") name = str(payload.get("name") or "").strip()[:120] message = str(payload.get("message") or "").strip() headline = str(payload.get("headline") or "").strip()[:80] link = str(payload.get("link") or "").strip() cta = str(payload.get("call_to_action") or "LEARN_MORE") page_id = str(payload.get("page_id") or "") countries = [str(code).upper() for code in payload.get("countries") or [] if re.fullmatch(r"[A-Za-z]{2}", str(code))] age_min = int(payload.get("age_min") or 18) age_max = int(payload.get("age_max") or 65) daily_budget = float(payload.get("daily_budget") or 0) activate = bool(payload.get("activate")) if not name or not message or not page_id or not countries or daily_budget <= 0 or age_min < 18 or age_max < age_min or age_max > 65: raise MetaApiError(422, "validation", "Add a name, Page, message, country, and daily budget before publishing.") if cta not in CTA_TYPES: raise MetaApiError(422, "validation", "Choose a supported call to action.") if destination == "website" and not link.startswith(("http://", "https://")): raise MetaApiError(422, "validation", "Website destination needs an http or https link.") page, _page_token = await _authorized_page(db, conn, page_id) page_link = str(page.get("link") or "") if destination != "whatsapp" and not (link or page_link): raise MetaApiError(422, "validation", "Meta did not return a link for this Page. Add a website link or choose a Page that has one.") currency = await _account_currency(db, conn, account_id) status = "ACTIVE" if activate else "PAUSED" created_campaign = "" try: campaign = await marketing_post( db, conn, account_id, "campaigns", { "name": name, "objective": objective, "status": "PAUSED", "special_ad_categories": [], "is_adset_budget_sharing_enabled": False, }, ) created_campaign = str(campaign.get("id") or "") optimization = "REACH" if objective == "OUTCOME_AWARENESS" else "POST_ENGAGEMENT" if objective == "OUTCOME_ENGAGEMENT" and destination != "whatsapp" else "CONVERSATIONS" if destination == "whatsapp" else "LINK_CLICKS" adset_body: dict[str, Any] = { "name": f"{name} ad set", "campaign_id": created_campaign, "daily_budget": str(int(round(daily_budget * currency_offset(currency)))), "billing_event": "IMPRESSIONS", "optimization_goal": optimization, "bid_strategy": "LOWEST_COST_WITHOUT_CAP", "status": "PAUSED", "promoted_object": {"page_id": page_id}, "targeting": { "geo_locations": {"countries": countries[:5]}, "age_min": age_min, "age_max": age_max, "targeting_automation": {"advantage_audience": 0}, }, } if destination == "whatsapp": adset_body["destination_type"] = "WHATSAPP" elif destination == "website": adset_body["destination_type"] = "WEBSITE" adset = await marketing_post(db, conn, account_id, "adsets", adset_body) story = _story_spec(page_id, destination, message, headline, link, cta, page_link) creative = await marketing_post( db, conn, account_id, "adcreatives", {"name": f"{name} creative", "object_story_spec": story}, ) await marketing_post( db, conn, account_id, "ads", { "name": f"{name} ad", "adset_id": adset.get("id"), "creative": {"creative_id": creative.get("id")}, "status": "PAUSED", }, ) if activate and created_campaign: await ads_object_post(db, conn, created_campaign, {"status": "ACTIVE"}) await ads_object_post(db, conn, str(adset.get("id")), {"status": "ACTIVE"}) except MetaApiError as exc: detail = exc.message if created_campaign: detail = f"{exc.message} A paused campaign named {name} may already exist in the ad account." audit( db, user, "campaign.publish", asset_type="campaign", asset_label=name, asset_id=created_campaign, status="failed", error_message=detail, graph_code=exc.graph_code, ) raise MetaApiError(exc.status_code, exc.code, detail, exc.permission, exc.graph_code) from exc audit( db, user, "campaign.publish", asset_type="campaign", asset_label=name, asset_id=created_campaign, detail="Published active" if activate else "Created paused", ) return {"name": name, "status": status} def _story_spec(page_id: str, destination: str, message: str, headline: str, link: str, cta: str, page_link: str) -> dict[str, Any]: target = link or page_link if destination == "whatsapp": link_data: dict[str, Any] = { "message": message, "link": "https://api.whatsapp.com/send", "call_to_action": {"type": "WHATSAPP_MESSAGE"}, } else: link_data = { "message": message, "link": target, "call_to_action": {"type": cta, "value": {"link": target}}, } if headline: link_data["name"] = headline return {"page_id": page_id, "link_data": link_data} async def list_ads(db: Session, user: UserAccount) -> dict[str, Any]: conn, account_id = await _selected_account(db, user, manage=False) data = await marketing_get( db, conn, account_id, "ads", { "fields": "id,name,status,effective_status,creative{title,body,image_url,thumbnail_url,call_to_action_type,object_story_spec},campaign{name},adset{name}", "limit": "50", }, ) ads = [] for row in data.get("data") or []: if isinstance(row, dict) and row.get("id"): ads.append(_ad_summary(row)) return {"account_name": conn.selected_ad_account_name, "ads": ads, "can_manage": has_scope(conn, "ads_management")} def _ad_summary(row: dict[str, Any]) -> dict[str, Any]: creative = row.get("creative") if isinstance(row.get("creative"), dict) else {} story = creative.get("object_story_spec") if isinstance(creative.get("object_story_spec"), dict) else {} link_data = story.get("link_data") if isinstance(story.get("link_data"), dict) else {} video = story.get("video_data") if isinstance(story.get("video_data"), dict) else {} cta = link_data.get("call_to_action") if isinstance(link_data.get("call_to_action"), dict) else {} campaign = row.get("campaign") if isinstance(row.get("campaign"), dict) else {} adset = row.get("adset") if isinstance(row.get("adset"), dict) else {} return { "id": str(row.get("id")), "name": row.get("name") or "Ad", "campaign_name": campaign.get("name") or "", "adset_name": adset.get("name") or "", "status": row.get("effective_status") or row.get("status") or "", "headline": link_data.get("name") or video.get("title") or creative.get("title") or "", "message": link_data.get("message") or video.get("message") or creative.get("body") or "", "call_to_action": cta.get("type") or creative.get("call_to_action_type") or "", "image_url": creative.get("image_url") or creative.get("thumbnail_url") or link_data.get("picture") or "", "link": link_data.get("link") or "", "editable": bool(story.get("page_id")), } async def update_ad(db: Session, user: UserAccount, ad_id: str, payload: dict[str, Any]) -> dict[str, Any]: conn, account_id = await _selected_account(db, user, manage=True) current = await _account_object( db, conn, account_id, "ads", ad_id, "id,name,creative{object_story_spec}", ) label = str(payload.get("name") or current.get("name") or "Ad") try: if payload.get("name"): await ads_object_post(db, conn, ad_id, {"name": str(payload["name"])[:120]}) creative_fields = ("message", "headline", "call_to_action", "link") if any(payload.get(field) for field in creative_fields): creative = current.get("creative") if isinstance(current.get("creative"), dict) else {} story = creative.get("object_story_spec") if isinstance(creative.get("object_story_spec"), dict) else {} page_id = str(story.get("page_id") or "") if not page_id: raise MetaApiError(422, "feature_unavailable", "This ad's creative is not a Page link ad Urmi can edit.") await _authorized_page(db, conn, page_id) cta = str(payload.get("call_to_action") or "LEARN_MORE") if cta not in CTA_TYPES: raise MetaApiError(422, "validation", "Choose a supported call to action.") link = str(payload.get("link") or "") if link and not link.startswith(("http://", "https://")): raise MetaApiError(422, "validation", "Creative links need to start with http or https.") replacement = await marketing_post( db, conn, account_id, "adcreatives", body={ "name": f"{label} creative", "object_story_spec": _story_spec( page_id, "whatsapp" if cta == "WHATSAPP_MESSAGE" else "website", str(payload.get("message") or ""), str(payload.get("headline") or ""), link, cta, "", ), }, ) await ads_object_post(db, conn, ad_id, {"creative": {"creative_id": replacement.get("id")}}) except MetaApiError as exc: audit(db, user, "ad.edit", asset_type="ad", asset_label=label, asset_id=ad_id, status="failed", error_message=exc.message, graph_code=exc.graph_code) raise audit(db, user, "ad.edit", asset_type="ad", asset_label=label, asset_id=ad_id, detail="Ad details updated") return {"name": label} async def set_ad_status(db: Session, user: UserAccount, ad_id: str, status: str) -> dict[str, Any]: if status not in {"ACTIVE", "PAUSED"}: raise MetaApiError(422, "validation", "Choose pause or resume.") conn, account_id = await _selected_account(db, user, manage=True) current = await _account_object(db, conn, account_id, "ads", ad_id, "id,name") name = str(current.get("name") or "Ad") action = "ad.resume" if status == "ACTIVE" else "ad.pause" try: await ads_object_post(db, conn, ad_id, {"status": status}) except MetaApiError as exc: audit(db, user, action, asset_type="ad", asset_label=name, asset_id=ad_id, status="failed", error_message=exc.message, graph_code=exc.graph_code) raise audit(db, user, action, asset_type="ad", asset_label=name, asset_id=ad_id, detail=f"Status set to {status}") return {"name": name, "status": status} async def analytics(db: Session, user: UserAccount, date_preset: str, breakdowns: bool = True) -> dict[str, Any]: if date_preset not in {"last_7d", "last_14d", "last_30d", "last_90d", "maximum"}: raise MetaApiError(422, "validation", "Choose a supported date range.") conn, account_id = await _selected_account(db, user, manage=False) currency = await _account_currency(db, conn, account_id) summary = await marketing_get( db, conn, account_id, "insights", {"fields": "spend,impressions,reach,clicks,ctr,cpc,cpm", "date_preset": date_preset}, ) daily = await marketing_get( db, conn, account_id, "insights", { "fields": "spend,impressions,clicks,date_start", "date_preset": date_preset, "time_increment": "1", "limit": "100", }, ) campaign_rows: list[dict[str, Any]] = [] adset_rows: list[dict[str, Any]] = [] ad_rows: list[dict[str, Any]] = [] if breakdowns: campaign_rows = await _insights_rows( db, conn, account_id, date_preset, "campaign", "campaign_id,campaign_name,spend,impressions,clicks,ctr,reach", ) adset_rows = await _insights_rows( db, conn, account_id, date_preset, "adset", "adset_id,adset_name,spend,impressions,clicks,ctr", ) ad_rows = await _insights_rows( db, conn, account_id, date_preset, "ad", "ad_id,ad_name,spend,impressions,clicks,ctr", ) row = (summary.get("data") or [{}])[0] if isinstance(summary.get("data"), list) else {} if not isinstance(row, dict): row = {} series = [] for item in daily.get("data") or []: if not isinstance(item, dict): continue series.append( { "date": item.get("date_start"), "spend": _float(item.get("spend")) or 0, "impressions": _int(item.get("impressions")) or 0, "clicks": _int(item.get("clicks")) or 0, } ) spend = _float(row.get("spend")) return { "account_name": conn.selected_ad_account_name, "currency": currency, "date_preset": date_preset, "has_delivery": bool(summary.get("data")), "totals": { "spend": money(spend, currency) if row else None, "impressions": _int(row.get("impressions")), "reach": _int(row.get("reach")), "clicks": _int(row.get("clicks")), "ctr": _float(row.get("ctr")), "cpc": money(_float(row.get("cpc")), currency) if row.get("cpc") is not None else None, "cpm": money(_float(row.get("cpm")), currency) if row.get("cpm") is not None else None, }, "series": series, "campaigns": [_insight_line(item, "campaign", currency) for item in campaign_rows], "adsets": [_insight_line(item, "adset", currency) for item in adset_rows], "ads": [_insight_line(item, "ad", currency) for item in ad_rows], } async def _insights_rows( db: Session, conn: MetaConnection, account_id: str, date_preset: str, level: str, fields: str, ) -> list[dict[str, Any]]: try: payload = await marketing_get( db, conn, account_id, "insights", {"fields": fields, "date_preset": date_preset, "level": level, "limit": "50"}, ) except MetaApiError: return [] return [row for row in payload.get("data") or [] if isinstance(row, dict)] def _insight_line(row: dict[str, Any], level: str, currency: str) -> dict[str, Any]: name = row.get(f"{level}_name") or row.get("campaign_name") or "Untitled" spend = _float(row.get("spend")) return { "name": name, "spend": money(spend, currency), "impressions": _int(row.get("impressions")), "clicks": _int(row.get("clicks")), "ctr": _float(row.get("ctr")), "reach": _int(row.get("reach")), } async def overview(db: Session, user: UserAccount) -> dict[str, Any]: conn = load_connection(db, user) whatsapp = [] for account in list_accounts(db, user): phone = account.get("primary_phone") if isinstance(account.get("primary_phone"), dict) else {} whatsapp.append( { "name": account.get("name") or "WhatsApp Business", "phone": phone.get("display_phone") or "", "status": account.get("status") or "", "webhooks": account.get("webhooks_status") or "", "templates": account.get("template_count") or 0, } ) result: dict[str, Any] = { "connection": public_connection(conn if conn and conn.status != "disconnected" else None), "whatsapp": whatsapp, "businesses": _section_empty(), "pages": _section_empty(), "ad_accounts": _section_empty(), "performance": _section_empty(), "activity": recent_audit(db, user, limit=6), } if not settings.meta_oauth_configured: result["connection"]["configured"] = False return result if not conn or conn.status != "connected": return result try: require_connection(db, user) except MetaApiError as exc: result["connection"] = public_connection(conn) result["connection"]["status"] = "expired" if exc.code == "token_expired" else conn.status result["connection"]["last_error"] = exc.message return result result["businesses"] = await _section(lambda: list_businesses(db, user), "businesses") result["pages"] = await _section(lambda: list_pages(db, user), "pages") if has_scope(conn, "ads_read") or has_scope(conn, "ads_management"): result["ad_accounts"] = await _section(lambda: list_ad_accounts(db, user), "accounts") else: result["ad_accounts"] = { "state": "permission_denied", "message": "Meta has not granted Ads reporting for this connection.", "permission": "ads_read", "items": [], } if has_scope(conn, "ads_read") and conn.selected_ad_account_id: try: report = await analytics(db, user, "last_30d", breakdowns=False) result["performance"] = {"state": "ok", "message": "", "report": report} except MetaApiError as exc: result["performance"] = {"state": exc.code, "message": exc.message, "permission": exc.permission, "report": None} elif has_scope(conn, "ads_read"): result["performance"] = {"state": "no_ad_account", "message": "Select an ad account to load performance.", "report": None} else: result["performance"] = { "state": "permission_denied", "message": "Meta has not granted Ads reporting for this connection.", "permission": "ads_read", "report": None, } result["connection"] = public_connection(load_connection(db, user)) result["activity"] = recent_audit(db, user, limit=6) return result def _section_empty() -> dict[str, Any]: return {"state": "not_connected", "message": "Connect Meta to load this.", "items": []} async def _section(loader, key: str) -> dict[str, Any]: try: payload = await loader() items = payload.get(key) or [] return {"state": "ok" if items else "empty", "message": "", "items": items} except MetaApiError as exc: return {"state": exc.code, "message": exc.message, "permission": exc.permission, "items": []} def recent_audit(db: Session, user: UserAccount, limit: int = 20) -> list[dict[str, Any]]: rows = db.scalars( select(MetaAuditEvent) .where(MetaAuditEvent.user_id == user.id) .order_by(MetaAuditEvent.created_at.desc()) .limit(limit) ).all() return [ { "action": row.action, "asset_type": row.asset_type, "asset_label": row.asset_label, "detail": row.detail, "status": row.status, "error_message": row.error_message, "created_at": row.created_at, } for row in rows ] def settings_view(db: Session, user: UserAccount) -> dict[str, Any]: conn = load_connection(db, user) whatsapp = [] for account in list_accounts(db, user): whatsapp.append( { "name": account.get("name") or "WhatsApp Business", "status": account.get("status") or "", "webhooks": account.get("webhooks_status") or "unavailable", } ) debug = { "app_configured": settings.meta_oauth_configured, "redirect_configured": bool(settings.meta_oauth_redirect_uri_resolved), "oauth_redirect_uri": settings.meta_oauth_redirect_uri_resolved, "login_config_set": bool(settings.meta_login_config_id), "granted_scopes": sorted(scopes_of(conn)) if conn else [], "last_error": conn.last_error if conn else "", "last_graph_code": conn.last_graph_code if conn else None, "meta_user_ref": mask_identifier(conn.meta_user_id) if conn else "", "page_ref": mask_identifier(conn.selected_page_id) if conn else "", "ad_account_ref": mask_identifier(conn.selected_ad_account_id) if conn else "", } return { "connection": public_connection(conn if conn and conn.status != "disconnected" else None), "whatsapp": whatsapp, "ads_webhooks": "not_subscribed", "audit": recent_audit(db, user, limit=30), "debug": debug, }