From f38abd7ab431532aa8d30812280b17ed74e824ec Mon Sep 17 00:00:00 2001 From: lev-goryachev Date: Mon, 16 Mar 2026 12:18:15 +0100 Subject: [PATCH 1/2] refactor(meta): split Meta integrations by use case (one file per API use case) Replace fi_meta.py, fi_instagram.py, and facebook/fi_facebook.py with flat per-use-case files matching Meta developer console use cases: fi_meta_marketing_manage.py - Create & manage ads (campaigns, adsets, ads, creatives, audiences, pixels, targeting, rules) fi_meta_marketing_metrics.py - Measure ad performance (all insights endpoints) fi_meta_pages.py - Manage Pages and ad accounts fi_meta_ad_leads.py - Capture & manage ad leads (stub) fi_meta_app_ads.py - App ads (stub) fi_meta_threads.py - Threads API (stub) fi_meta_whatsapp.py - WhatsApp Business (stub) fi_meta_catalog.py - Catalog API (stub) fi_meta_messenger.py - Messenger (stub) fi_meta_instagram.py - Instagram messaging & content (stub) facebook/ subpackage is kept as internal implementation library used by the three real integration files above. --- .../integrations/facebook/fi_facebook.py | 265 ------------------ .../integrations/fi_meta_ad_leads.py | 21 ++ .../integrations/fi_meta_app_ads.py | 20 ++ .../integrations/fi_meta_catalog.py | 20 ++ .../integrations/fi_meta_instagram.py | 20 ++ .../integrations/fi_meta_marketing_manage.py | 238 ++++++++++++++++ .../integrations/fi_meta_marketing_metrics.py | 98 +++++++ .../integrations/fi_meta_messenger.py | 20 ++ .../integrations/fi_meta_pages.py | 96 +++++++ .../integrations/fi_meta_threads.py | 20 ++ .../integrations/fi_meta_whatsapp.py | 20 ++ 11 files changed, 573 insertions(+), 265 deletions(-) delete mode 100644 flexus_client_kit/integrations/facebook/fi_facebook.py create mode 100644 flexus_client_kit/integrations/fi_meta_ad_leads.py create mode 100644 flexus_client_kit/integrations/fi_meta_app_ads.py create mode 100644 flexus_client_kit/integrations/fi_meta_catalog.py create mode 100644 flexus_client_kit/integrations/fi_meta_instagram.py create mode 100644 flexus_client_kit/integrations/fi_meta_marketing_manage.py create mode 100644 flexus_client_kit/integrations/fi_meta_marketing_metrics.py create mode 100644 flexus_client_kit/integrations/fi_meta_messenger.py create mode 100644 flexus_client_kit/integrations/fi_meta_pages.py create mode 100644 flexus_client_kit/integrations/fi_meta_threads.py create mode 100644 flexus_client_kit/integrations/fi_meta_whatsapp.py diff --git a/flexus_client_kit/integrations/facebook/fi_facebook.py b/flexus_client_kit/integrations/facebook/fi_facebook.py deleted file mode 100644 index e1dfc58e..00000000 --- a/flexus_client_kit/integrations/facebook/fi_facebook.py +++ /dev/null @@ -1,265 +0,0 @@ -from __future__ import annotations -import logging -from typing import Any, Dict, Optional, TYPE_CHECKING - -from flexus_client_kit import ckit_cloudtool -from flexus_client_kit.integrations.facebook.client import FacebookAdsClient -from flexus_client_kit.integrations.facebook.exceptions import ( - FacebookAPIError, - FacebookAuthError, - FacebookValidationError, -) -from flexus_client_kit.integrations.facebook.accounts import list_ad_accounts, get_ad_account_info, update_spending_limit -from flexus_client_kit.integrations.facebook.campaigns import list_campaigns, create_campaign, update_campaign, duplicate_campaign, archive_campaign, bulk_update_campaigns, get_insights -from flexus_client_kit.integrations.facebook.adsets import list_adsets, create_adset, update_adset, validate_targeting -from flexus_client_kit.integrations.facebook.ads import upload_image, create_creative, create_ad, preview_ad - -if TYPE_CHECKING: - from flexus_client_kit import ckit_client, ckit_bot_exec - -logger = logging.getLogger("facebook") - -FACEBOOK_TOOL = ckit_cloudtool.CloudTool( - strict=False, - name="facebook", - description="Interact with Facebook/Instagram Marketing API. Call with op=\"help\" for usage.", - parameters={ - "type": "object", - "properties": { - "op": { - "type": "string", - "description": "Operation name (e.g., 'status', 'list_campaigns', 'create_campaign')" - }, - "args": { - "type": "object", - "description": "Arguments for the operation" - }, - }, - "required": ["op"] - }, -) - -HELP = """Help: -**Connection:** -facebook(op="connect") - Generate OAuth link to connect your Facebook account. -**Account Operations:** -facebook(op="list_ad_accounts") - Lists all accessible ad accounts. -facebook(op="get_ad_account_info", args={"ad_account_id": "act_123"}) - Get detailed info about an ad account. -facebook(op="status", args={"ad_account_id": "act_123"}) - Shows current ad account status and active campaigns. -**Campaign Operations:** -facebook(op="list_campaigns", args={"ad_account_id": "act_123", "status": "ACTIVE"}) - Lists campaigns. Optional filters: status (ACTIVE, PAUSED, ARCHIVED). -facebook(op="create_campaign", args={ - "ad_account_id": "act_123", - "name": "Summer Sale 2025", - "objective": "OUTCOME_TRAFFIC", - "daily_budget": 5000, - "status": "PAUSED" -}) - Creates a new campaign. Budget is in cents (5000 = $50.00). -facebook(op="update_campaign", args={"campaign_id": "123", "status": "ACTIVE"}) - Update campaign settings. -facebook(op="get_insights", args={"campaign_id": "123456", "days": 30}) - Gets performance metrics. -**Ad Set Operations:** -facebook(op="list_adsets", args={"campaign_id": "123"}) - Lists ad sets for a campaign. -facebook(op="create_adset", args={ - "ad_account_id": "act_123", - "campaign_id": "123456", - "name": "US 18-35", - "targeting": {"geo_locations": {"countries": ["US"]}} -}) - Creates an ad set with targeting. -**Creative & Ads Operations:** -facebook(op="upload_image", args={"image_url": "https://..."}) - Upload image for ad creative. -facebook(op="create_creative", args={ - "name": "Product Creative", - "page_id": "123", - "image_hash": "abc123", - "link": "https://..." -}) - Create ad creative. -facebook(op="create_ad", args={ - "adset_id": "456", - "creative_id": "789", - "name": "Product Ad" -}) - Create ad from creative. -""" - - -_OPERATION_HANDLERS = { - "list_ad_accounts": lambda client, args: list_ad_accounts(client), - "get_ad_account_info": lambda client, args: get_ad_account_info(client, args.get("ad_account_id", "")), - "update_spending_limit": lambda client, args: update_spending_limit(client, args.get("ad_account_id", ""), args.get("spending_limit", 0)), - "list_campaigns": lambda client, args: list_campaigns(client, args.get("ad_account_id"), args.get("status")), - "create_campaign": lambda client, args: create_campaign( - client, - args.get("ad_account_id", ""), - args.get("name", ""), - args.get("objective", "OUTCOME_TRAFFIC"), - args.get("status", "PAUSED"), - args.get("daily_budget"), - args.get("lifetime_budget"), - args.get("special_ad_categories"), - ), - "update_campaign": lambda client, args: update_campaign( - client, - args.get("campaign_id", ""), - args.get("name"), - args.get("status"), - args.get("daily_budget"), - args.get("lifetime_budget"), - ), - "duplicate_campaign": lambda client, args: duplicate_campaign(client, args.get("campaign_id", ""), args.get("new_name", ""), args.get("ad_account_id")), - "archive_campaign": lambda client, args: archive_campaign(client, args.get("campaign_id", "")), - "bulk_update_campaigns": lambda client, args: bulk_update_campaigns(client, args.get("campaigns", [])), - "get_insights": lambda client, args: get_insights(client, args.get("campaign_id", ""), int(args.get("days", 30))), - "list_adsets": lambda client, args: list_adsets(client, args.get("campaign_id", "")), - "create_adset": lambda client, args: create_adset( - client, - args.get("ad_account_id", ""), - args.get("campaign_id", ""), - args.get("name", ""), - args.get("targeting", {}), - args.get("optimization_goal", "LINK_CLICKS"), - args.get("billing_event", "IMPRESSIONS"), - args.get("bid_strategy", "LOWEST_COST_WITHOUT_CAP"), - args.get("status", "PAUSED"), - args.get("daily_budget"), - args.get("lifetime_budget"), - args.get("bid_amount"), - args.get("start_time"), - args.get("end_time"), - args.get("promoted_object"), - ), - "update_adset": lambda client, args: update_adset( - client, - args.get("adset_id", ""), - args.get("name"), - args.get("status"), - args.get("daily_budget"), - args.get("bid_amount"), - ), - "validate_targeting": lambda client, args: validate_targeting(client, args.get("targeting_spec", args.get("targeting", {})), args.get("ad_account_id")), - "upload_image": lambda client, args: upload_image(client, args.get("image_path"), args.get("image_url"), args.get("ad_account_id")), - "create_creative": lambda client, args: create_creative( - client, - args.get("name", ""), - args.get("page_id", ""), - args.get("image_hash", ""), - args.get("link", ""), - args.get("message"), - args.get("headline"), - args.get("description"), - args.get("call_to_action_type", "LEARN_MORE"), - args.get("ad_account_id"), - ), - "create_ad": lambda client, args: create_ad( - client, - args.get("name", ""), - args.get("adset_id", ""), - args.get("creative_id", ""), - args.get("status", "PAUSED"), - args.get("ad_account_id"), - ), - "preview_ad": lambda client, args: preview_ad(client, args.get("ad_id", ""), args.get("ad_format", "DESKTOP_FEED_STANDARD")), -} - - -class IntegrationFacebook: - def __init__( - self, - fclient: "ckit_client.FlexusClient", - rcx: "ckit_bot_exec.RobotContext", - ad_account_id: str = "", - pdoc_integration: Optional[Any] = None, - ): - self.client = FacebookAdsClient(fclient, rcx, ad_account_id) - self.fclient = fclient - self.rcx = rcx - self.pdoc_integration = pdoc_integration - - async def _ensure_ad_account_id(self, toolcall: ckit_cloudtool.FCloudtoolCall) -> None: - """Load ad_account_id from /company/ad-ops-config if not set.""" - if self.client.ad_account_id or not self.pdoc_integration: - return - try: - config = await self.pdoc_integration.pdoc_cat("/company/ad-ops-config", fcall_untrusted_key=toolcall.fcall_untrusted_key) - ad_account_id = config.pdoc_content.get("facebook_ad_account_id", "") - if ad_account_id: - self.client.ad_account_id = ad_account_id - logger.info(f"Loaded ad_account_id from pdoc: {ad_account_id}") - except Exception as e: - logger.debug(f"Could not load ad_account_id from pdoc: {e}") - - async def called_by_model( - self, - toolcall: ckit_cloudtool.FCloudtoolCall, - model_produced_args: Optional[Dict[str, Any]], - ) -> str: - if not model_produced_args: - return HELP - op = model_produced_args.get("op", "") - args = model_produced_args.get("args", {}) - for key in ["ad_account_id", "campaign_id", "adset_id", "creative_id", "ad_id"]: - if key in model_produced_args and key not in args: - args[key] = model_produced_args[key] - if not op or "help" in op.lower(): - return HELP - # Auto-load ad_account_id from pdoc before operations that need it - if op not in ["connect", "list_ad_accounts", "help"]: - await self._ensure_ad_account_id(toolcall) - if op == "connect": - return await self._handle_connect() - if op == "status": - return await self._handle_status(args) - handler = _OPERATION_HANDLERS.get(op) - if not handler: - return f"Unknown operation '{op}'. Try op=\"help\" for available operations." - try: - return await handler(self.client, args) - except FacebookAuthError as e: - return e.message - except FacebookAPIError as e: - logger.info(f"Facebook API error: {e}") - return e.format_for_user() - except FacebookValidationError as e: - return f"ERROR: {e.message}" - except Exception as e: - logger.warning(f"Unexpected error in {op}: {e}", exc_info=e) - return f"ERROR: {str(e)}" - - async def _handle_connect(self) -> str: - return """Click this link to connect your Facebook account in workspace settings. - -After authorizing, return here and try your request again. - -Requirements: -- Facebook Business Manager account -- Access to an Ad Account (starts with act_...)""" - - async def _handle_status(self, args: Dict[str, Any]) -> str: - ad_account_id = args.get("ad_account_id", "") or self.client.ad_account_id - if ad_account_id: - self.client.ad_account_id = ad_account_id - if not self.client.ad_account_id: - return "ERROR: ad_account_id parameter required for status" - if self.client.is_test_mode: - return f"""Facebook Ads Account: {self.client.ad_account_id} -Active Campaigns (2): - Test Campaign 1 (ID: 123456789) - Status: ACTIVE, Objective: OUTCOME_TRAFFIC, Daily Budget: $50.00 - Test Campaign 2 (ID: 987654321) - Status: ACTIVE, Objective: OUTCOME_SALES, Daily Budget: $100.00 -""" - result = f"Facebook Ads Account: {self.client.ad_account_id}\n" - campaigns_result = await list_campaigns(self.client, self.client.ad_account_id, "ACTIVE") - result += campaigns_result - return result diff --git a/flexus_client_kit/integrations/fi_meta_ad_leads.py b/flexus_client_kit/integrations/fi_meta_ad_leads.py new file mode 100644 index 00000000..b35d5b8e --- /dev/null +++ b/flexus_client_kit/integrations/fi_meta_ad_leads.py @@ -0,0 +1,21 @@ +import json +from typing import Any, Dict +from flexus_client_kit import ckit_cloudtool + +# Use case: "Capture & manage ad leads with Marketing API" +PROVIDER_NAME = "meta_ad_leads" +METHOD_IDS: list[str] = [] + + +class IntegrationMetaAdLeads: + async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return f"provider={PROVIDER_NAME}\nop=help | status | list_methods | call\nstatus: not yet implemented" + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "not_implemented", "method_count": 0}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + method_id = str((args.get("args") or {}).get("method_id", "")).strip() + return json.dumps({"ok": False, "error_code": "INTEGRATION_UNAVAILABLE", "provider": PROVIDER_NAME, "method_id": method_id, "message": "Not yet implemented."}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_meta_app_ads.py b/flexus_client_kit/integrations/fi_meta_app_ads.py new file mode 100644 index 00000000..130ffc73 --- /dev/null +++ b/flexus_client_kit/integrations/fi_meta_app_ads.py @@ -0,0 +1,20 @@ +import json +from typing import Any, Dict +from flexus_client_kit import ckit_cloudtool + +PROVIDER_NAME = "meta_app_ads" +METHOD_IDS: list[str] = [] + + +class IntegrationMetaAppAds: + async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return f"provider={PROVIDER_NAME}\nop=help | status | list_methods | call\nstatus: not yet implemented" + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "not_implemented", "method_count": 0}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + method_id = str((args.get("args") or {}).get("method_id", "")).strip() + return json.dumps({"ok": False, "error_code": "INTEGRATION_UNAVAILABLE", "provider": PROVIDER_NAME, "method_id": method_id, "message": "Not yet implemented."}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_meta_catalog.py b/flexus_client_kit/integrations/fi_meta_catalog.py new file mode 100644 index 00000000..d70a3596 --- /dev/null +++ b/flexus_client_kit/integrations/fi_meta_catalog.py @@ -0,0 +1,20 @@ +import json +from typing import Any, Dict +from flexus_client_kit import ckit_cloudtool + +PROVIDER_NAME = "meta_catalog" +METHOD_IDS: list[str] = [] + + +class IntegrationMetaCatalog: + async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return f"provider={PROVIDER_NAME}\nop=help | status | list_methods | call\nstatus: not yet implemented" + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "not_implemented", "method_count": 0}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + method_id = str((args.get("args") or {}).get("method_id", "")).strip() + return json.dumps({"ok": False, "error_code": "INTEGRATION_UNAVAILABLE", "provider": PROVIDER_NAME, "method_id": method_id, "message": "Not yet implemented."}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_meta_instagram.py b/flexus_client_kit/integrations/fi_meta_instagram.py new file mode 100644 index 00000000..9772805a --- /dev/null +++ b/flexus_client_kit/integrations/fi_meta_instagram.py @@ -0,0 +1,20 @@ +import json +from typing import Any, Dict +from flexus_client_kit import ckit_cloudtool + +PROVIDER_NAME = "meta_instagram" +METHOD_IDS: list[str] = [] + + +class IntegrationMetaInstagram: + async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return f"provider={PROVIDER_NAME}\nop=help | status | list_methods | call\nstatus: not yet implemented" + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "not_implemented", "method_count": 0}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + method_id = str((args.get("args") or {}).get("method_id", "")).strip() + return json.dumps({"ok": False, "error_code": "INTEGRATION_UNAVAILABLE", "provider": PROVIDER_NAME, "method_id": method_id, "message": "Not yet implemented."}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_meta_marketing_manage.py b/flexus_client_kit/integrations/fi_meta_marketing_manage.py new file mode 100644 index 00000000..c961d90f --- /dev/null +++ b/flexus_client_kit/integrations/fi_meta_marketing_manage.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import logging +from typing import Any, Dict, Optional, TYPE_CHECKING + +from flexus_client_kit import ckit_cloudtool +from flexus_client_kit.integrations.facebook.client import FacebookAdsClient +from flexus_client_kit.integrations.facebook.exceptions import ( + FacebookAPIError, + FacebookAuthError, + FacebookValidationError, +) +from flexus_client_kit.integrations.facebook.campaigns import ( + list_campaigns, create_campaign, update_campaign, duplicate_campaign, + archive_campaign, bulk_update_campaigns, get_campaign, delete_campaign, +) +from flexus_client_kit.integrations.facebook.adsets import ( + list_adsets, create_adset, update_adset, validate_targeting, + get_adset, delete_adset, list_adsets_for_account, +) +from flexus_client_kit.integrations.facebook.ads import ( + upload_image, create_creative, create_ad, preview_ad, + list_ads, get_ad, update_ad, delete_ad, + list_creatives, get_creative, update_creative, delete_creative, preview_creative, + upload_video, list_videos, +) +from flexus_client_kit.integrations.facebook.audiences import ( + list_custom_audiences, create_custom_audience, create_lookalike_audience, + get_custom_audience, update_custom_audience, delete_custom_audience, add_users_to_audience, +) +from flexus_client_kit.integrations.facebook.pixels import list_pixels, create_pixel, get_pixel_stats +from flexus_client_kit.integrations.facebook.targeting import ( + search_interests, search_behaviors, get_reach_estimate, get_delivery_estimate, +) +from flexus_client_kit.integrations.facebook.rules import ( + list_ad_rules, create_ad_rule, update_ad_rule, delete_ad_rule, execute_ad_rule, +) + +if TYPE_CHECKING: + from flexus_client_kit import ckit_client, ckit_bot_exec + +logger = logging.getLogger("meta_marketing_manage") + +# Use case: "Create & manage ads with Marketing API" +# Covers campaigns, ad sets, ads, creatives, audiences, pixels, targeting, rules. +PROVIDER_NAME = "meta_marketing_manage" + +_HELP = """meta_marketing_manage: Create & manage ads with Meta Marketing API. +op=help | status | list_methods | call(args={method_id, ...}) + +Campaign management: + list_campaigns(ad_account_id, status?) + get_campaign(campaign_id) + create_campaign(ad_account_id, name, objective, daily_budget?, lifetime_budget?, status?) + update_campaign(campaign_id, name?, status?, daily_budget?, lifetime_budget?) + delete_campaign(campaign_id) + duplicate_campaign(campaign_id, new_name, ad_account_id?) + archive_campaign(campaign_id) + bulk_update_campaigns(campaigns) + +Ad set management: + list_adsets(campaign_id) + list_adsets_for_account(ad_account_id, status_filter?) + get_adset(adset_id) + create_adset(ad_account_id, campaign_id, name, targeting, optimization_goal?, billing_event?, status?) + update_adset(adset_id, name?, status?, daily_budget?, targeting?) + delete_adset(adset_id) + validate_targeting(targeting_spec, ad_account_id?) + +Ad & creative management: + list_ads(ad_account_id?, adset_id?, status_filter?) + get_ad(ad_id) + create_ad(ad_account_id, adset_id, creative_id, name?, status?) + update_ad(ad_id, name?, status?) + delete_ad(ad_id) + preview_ad(ad_id, ad_format?) + list_creatives(ad_account_id) + get_creative(creative_id) + create_creative(ad_account_id, name, page_id, message?, link?, image_hash?, video_id?, call_to_action_type?) + update_creative(creative_id, name?) + delete_creative(creative_id) + preview_creative(creative_id, ad_format?) + upload_image(image_path?, image_url?, ad_account_id?) + upload_video(video_url, ad_account_id?, title?, description?) + list_videos(ad_account_id) + +Audiences: + list_custom_audiences(ad_account_id) + get_custom_audience(audience_id) + create_custom_audience(ad_account_id, name, subtype?, description?, customer_file_source?) + create_lookalike_audience(ad_account_id, origin_audience_id, country, ratio?, name?) + update_custom_audience(audience_id, name?, description?) + delete_custom_audience(audience_id) + add_users_to_audience(audience_id, emails, phones?) + +Pixels: + list_pixels(ad_account_id) + create_pixel(ad_account_id, name) + get_pixel_stats(pixel_id, start_time?, end_time?, aggregation?) + +Targeting research: + search_interests(q, limit?) + search_behaviors(q, limit?) + get_reach_estimate(ad_account_id, targeting, optimization_goal?) + get_delivery_estimate(ad_account_id, targeting, optimization_goal?, bid_amount?) + +Automation rules: + list_ad_rules(ad_account_id) + create_ad_rule(ad_account_id, name, evaluation_spec, execution_spec, schedule_spec?, status?) + update_ad_rule(rule_id, name?, status?) + delete_ad_rule(rule_id) + execute_ad_rule(rule_id) +""" + +# Maps op string -> lambda(client, args) for the generic dispatch table. +_HANDLERS: Dict[str, Any] = { + "list_campaigns": lambda c, a: list_campaigns(c, a.get("ad_account_id"), a.get("status")), + "get_campaign": lambda c, a: get_campaign(c, a.get("campaign_id", "")), + "create_campaign": lambda c, a: create_campaign( + c, a.get("ad_account_id", ""), a.get("name", ""), + a.get("objective", "OUTCOME_AWARENESS"), + a.get("daily_budget"), a.get("lifetime_budget"), + a.get("status", "PAUSED"), + ), + "update_campaign": lambda c, a: update_campaign( + c, a.get("campaign_id", ""), a.get("name"), a.get("status"), + a.get("daily_budget"), a.get("lifetime_budget"), + ), + "delete_campaign": lambda c, a: delete_campaign(c, a.get("campaign_id", "")), + "duplicate_campaign": lambda c, a: duplicate_campaign(c, a.get("campaign_id", ""), a.get("new_name", ""), a.get("ad_account_id")), + "archive_campaign": lambda c, a: archive_campaign(c, a.get("campaign_id", "")), + "bulk_update_campaigns": lambda c, a: bulk_update_campaigns(c, a.get("campaigns", [])), + "list_adsets": lambda c, a: list_adsets(c, a.get("campaign_id", "")), + "list_adsets_for_account": lambda c, a: list_adsets_for_account(c, a.get("ad_account_id", ""), a.get("status_filter")), + "get_adset": lambda c, a: get_adset(c, a.get("adset_id", "")), + "create_adset": lambda c, a: create_adset( + c, a.get("ad_account_id", ""), a.get("campaign_id", ""), + a.get("name", ""), a.get("targeting", {}), + a.get("optimization_goal"), a.get("billing_event"), + a.get("daily_budget"), a.get("lifetime_budget"), + a.get("status", "PAUSED"), + ), + "update_adset": lambda c, a: update_adset(c, a.get("adset_id", ""), a.get("name"), a.get("status"), a.get("daily_budget"), a.get("targeting")), + "delete_adset": lambda c, a: delete_adset(c, a.get("adset_id", "")), + "validate_targeting": lambda c, a: validate_targeting(c, a.get("targeting_spec", a.get("targeting", {})), a.get("ad_account_id")), + "list_ads": lambda c, a: list_ads(c, a.get("ad_account_id"), a.get("adset_id"), a.get("status_filter")), + "get_ad": lambda c, a: get_ad(c, a.get("ad_id", "")), + "create_ad": lambda c, a: create_ad(c, a.get("ad_account_id", ""), a.get("adset_id", ""), a.get("creative_id", ""), a.get("name"), a.get("status", "PAUSED")), + "update_ad": lambda c, a: update_ad(c, a.get("ad_id", ""), a.get("name"), a.get("status")), + "delete_ad": lambda c, a: delete_ad(c, a.get("ad_id", "")), + "preview_ad": lambda c, a: preview_ad(c, a.get("ad_id", ""), a.get("ad_format", "DESKTOP_FEED_STANDARD")), + "list_creatives": lambda c, a: list_creatives(c, a.get("ad_account_id", "")), + "get_creative": lambda c, a: get_creative(c, a.get("creative_id", "")), + "create_creative": lambda c, a: create_creative( + c, a.get("ad_account_id", ""), a.get("name", ""), + a.get("page_id", ""), a.get("message"), a.get("link"), + a.get("image_hash"), a.get("video_id"), a.get("call_to_action_type"), + ), + "update_creative": lambda c, a: update_creative(c, a.get("creative_id", ""), a.get("name")), + "delete_creative": lambda c, a: delete_creative(c, a.get("creative_id", "")), + "preview_creative": lambda c, a: preview_creative(c, a.get("creative_id", ""), a.get("ad_format", "DESKTOP_FEED_STANDARD")), + "upload_image": lambda c, a: upload_image(c, a.get("image_path"), a.get("image_url"), a.get("ad_account_id")), + "upload_video": lambda c, a: upload_video(c, a.get("video_url", ""), a.get("ad_account_id"), a.get("title"), a.get("description")), + "list_videos": lambda c, a: list_videos(c, a.get("ad_account_id", "")), + "list_custom_audiences": lambda c, a: list_custom_audiences(c, a.get("ad_account_id", "")), + "get_custom_audience": lambda c, a: get_custom_audience(c, a.get("audience_id", "")), + "create_custom_audience": lambda c, a: create_custom_audience(c, a.get("ad_account_id", ""), a.get("name", ""), a.get("subtype", "CUSTOM"), a.get("description"), a.get("customer_file_source")), + "create_lookalike_audience": lambda c, a: create_lookalike_audience(c, a.get("ad_account_id", ""), a.get("origin_audience_id", ""), a.get("country", ""), float(a.get("ratio", 0.01)), a.get("name")), + "update_custom_audience": lambda c, a: update_custom_audience(c, a.get("audience_id", ""), a.get("name"), a.get("description")), + "delete_custom_audience": lambda c, a: delete_custom_audience(c, a.get("audience_id", "")), + "add_users_to_audience": lambda c, a: add_users_to_audience(c, a.get("audience_id", ""), a.get("emails", []), a.get("phones")), + "list_pixels": lambda c, a: list_pixels(c, a.get("ad_account_id", "")), + "create_pixel": lambda c, a: create_pixel(c, a.get("ad_account_id", ""), a.get("name", "")), + "get_pixel_stats": lambda c, a: get_pixel_stats(c, a.get("pixel_id", ""), a.get("start_time"), a.get("end_time"), a.get("aggregation", "day")), + "search_interests": lambda c, a: search_interests(c, a.get("q", ""), int(a.get("limit", 20))), + "search_behaviors": lambda c, a: search_behaviors(c, a.get("q", ""), int(a.get("limit", 20))), + "get_reach_estimate": lambda c, a: get_reach_estimate(c, a.get("ad_account_id", ""), a.get("targeting", {}), a.get("optimization_goal", "LINK_CLICKS")), + "get_delivery_estimate": lambda c, a: get_delivery_estimate(c, a.get("ad_account_id", ""), a.get("targeting", {}), a.get("optimization_goal", "LINK_CLICKS"), a.get("bid_amount")), + "list_ad_rules": lambda c, a: list_ad_rules(c, a.get("ad_account_id", "")), + "create_ad_rule": lambda c, a: create_ad_rule(c, a.get("ad_account_id", ""), a.get("name", ""), a.get("evaluation_spec", {}), a.get("execution_spec", {}), a.get("schedule_spec"), a.get("status", "ENABLED")), + "update_ad_rule": lambda c, a: update_ad_rule(c, a.get("rule_id", ""), a.get("name"), a.get("status")), + "delete_ad_rule": lambda c, a: delete_ad_rule(c, a.get("rule_id", "")), + "execute_ad_rule": lambda c, a: execute_ad_rule(c, a.get("rule_id", "")), +} + + +class IntegrationMetaMarketingManage: + # Wraps FacebookAdsClient and delegates all manage operations through _HANDLERS. + # Uses OAuth token from rcx (Flexus stores the Meta access token per persona). + def __init__(self, rcx: "ckit_bot_exec.RobotContext"): + self.client = FacebookAdsClient(rcx.fclient, rcx) + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + try: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return _HELP + if op == "status": + return await self._status() + if op == "list_methods": + return "\n".join(sorted(_HANDLERS.keys())) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + handler = _HANDLERS.get(method_id) + if handler is None: + return f"Error: unknown method_id={method_id!r}. Use op=list_methods to see available methods." + return await handler(self.client, call_args) + except FacebookAuthError as e: + return e.message + except FacebookAPIError as e: + logger.info("meta_marketing_manage api error: %s", e) + return e.format_for_user() + except FacebookValidationError as e: + return f"Error: {e.message}" + except Exception as e: + logger.error("Unexpected error in meta_marketing_manage op=%s", (model_produced_args or {}).get("op"), exc_info=e) + return f"Error: {e}" + + async def _status(self) -> str: + try: + auth_error = await self.client.ensure_auth() + if auth_error: + return auth_error + return "meta_marketing_manage: connected. Use op=help to see available operations." + except (FacebookAuthError, FacebookAPIError, FacebookValidationError) as e: + return e.message + except Exception as e: + logger.error("Unexpected error in meta_marketing_manage status", exc_info=e) + return f"Error: {e}" diff --git a/flexus_client_kit/integrations/fi_meta_marketing_metrics.py b/flexus_client_kit/integrations/fi_meta_marketing_metrics.py new file mode 100644 index 00000000..840500ed --- /dev/null +++ b/flexus_client_kit/integrations/fi_meta_marketing_metrics.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import logging +from typing import Any, Dict, TYPE_CHECKING + +from flexus_client_kit import ckit_cloudtool +from flexus_client_kit.integrations.facebook.client import FacebookAdsClient +from flexus_client_kit.integrations.facebook.exceptions import ( + FacebookAPIError, + FacebookAuthError, + FacebookValidationError, +) +from flexus_client_kit.integrations.facebook.insights import ( + get_account_insights, get_campaign_insights, get_adset_insights, get_ad_insights, + create_async_report, get_async_report_status, +) + +if TYPE_CHECKING: + from flexus_client_kit import ckit_bot_exec + +logger = logging.getLogger("meta_marketing_metrics") + +# Use case: "Measure ad performance data with Marketing API" +# Covers all insights endpoints: account, campaign, ad set, ad level, async reports. +PROVIDER_NAME = "meta_marketing_metrics" + +_HELP = """meta_marketing_metrics: Measure ad performance with Meta Marketing API. +op=help | status | list_methods | call(args={method_id, ...}) + + get_account_insights(ad_account_id, days?, breakdowns?, metrics?, date_preset?) + get_campaign_insights(campaign_id, days?, breakdowns?, metrics?, date_preset?) + get_adset_insights(adset_id, days?, breakdowns?, metrics?, date_preset?) + get_ad_insights(ad_id, days?, breakdowns?, metrics?, date_preset?) + create_async_report(ad_account_id, level?, fields?, date_preset?, breakdowns?) + get_async_report_status(report_run_id) +""" + +_HANDLERS: Dict[str, Any] = { + "get_account_insights": lambda c, a: get_account_insights(c, a.get("ad_account_id", ""), int(a.get("days", 30)), a.get("breakdowns"), a.get("metrics"), a.get("date_preset")), + "get_campaign_insights": lambda c, a: get_campaign_insights(c, a.get("campaign_id", ""), int(a.get("days", 30)), a.get("breakdowns"), a.get("metrics"), a.get("date_preset")), + "get_adset_insights": lambda c, a: get_adset_insights(c, a.get("adset_id", ""), int(a.get("days", 30)), a.get("breakdowns"), a.get("metrics"), a.get("date_preset")), + "get_ad_insights": lambda c, a: get_ad_insights(c, a.get("ad_id", ""), int(a.get("days", 30)), a.get("breakdowns"), a.get("metrics"), a.get("date_preset")), + "create_async_report": lambda c, a: create_async_report(c, a.get("ad_account_id", ""), a.get("level", "campaign"), a.get("fields"), a.get("date_preset", "last_30d"), a.get("breakdowns")), + "get_async_report_status": lambda c, a: get_async_report_status(c, a.get("report_run_id", "")), +} + + +class IntegrationMetaMarketingMetrics: + # Wraps FacebookAdsClient and delegates all insights/metrics operations. + def __init__(self, rcx: "ckit_bot_exec.RobotContext"): + self.client = FacebookAdsClient(rcx.fclient, rcx) + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + try: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return _HELP + if op == "status": + return await self._status() + if op == "list_methods": + return "\n".join(sorted(_HANDLERS.keys())) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + handler = _HANDLERS.get(method_id) + if handler is None: + return f"Error: unknown method_id={method_id!r}. Use op=list_methods to see available methods." + return await handler(self.client, call_args) + except FacebookAuthError as e: + return e.message + except FacebookAPIError as e: + logger.info("meta_marketing_metrics api error: %s", e) + return e.format_for_user() + except FacebookValidationError as e: + return f"Error: {e.message}" + except Exception as e: + logger.error("Unexpected error in meta_marketing_metrics op=%s", (model_produced_args or {}).get("op"), exc_info=e) + return f"Error: {e}" + + async def _status(self) -> str: + try: + auth_error = await self.client.ensure_auth() + if auth_error: + return auth_error + return "meta_marketing_metrics: connected. Use op=help to see available operations." + except (FacebookAuthError, FacebookAPIError, FacebookValidationError) as e: + return e.message + except Exception as e: + logger.error("Unexpected error in meta_marketing_metrics status", exc_info=e) + return f"Error: {e}" diff --git a/flexus_client_kit/integrations/fi_meta_messenger.py b/flexus_client_kit/integrations/fi_meta_messenger.py new file mode 100644 index 00000000..0c4fca33 --- /dev/null +++ b/flexus_client_kit/integrations/fi_meta_messenger.py @@ -0,0 +1,20 @@ +import json +from typing import Any, Dict +from flexus_client_kit import ckit_cloudtool + +PROVIDER_NAME = "meta_messenger" +METHOD_IDS: list[str] = [] + + +class IntegrationMetaMessenger: + async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return f"provider={PROVIDER_NAME}\nop=help | status | list_methods | call\nstatus: not yet implemented" + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "not_implemented", "method_count": 0}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + method_id = str((args.get("args") or {}).get("method_id", "")).strip() + return json.dumps({"ok": False, "error_code": "INTEGRATION_UNAVAILABLE", "provider": PROVIDER_NAME, "method_id": method_id, "message": "Not yet implemented."}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_meta_pages.py b/flexus_client_kit/integrations/fi_meta_pages.py new file mode 100644 index 00000000..903e1e0b --- /dev/null +++ b/flexus_client_kit/integrations/fi_meta_pages.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import logging +from typing import Any, Dict, TYPE_CHECKING + +from flexus_client_kit import ckit_cloudtool +from flexus_client_kit.integrations.facebook.client import FacebookAdsClient +from flexus_client_kit.integrations.facebook.exceptions import ( + FacebookAPIError, + FacebookAuthError, + FacebookValidationError, +) +from flexus_client_kit.integrations.facebook.accounts import ( + list_ad_accounts, get_ad_account_info, update_spending_limit, + list_account_users, list_pages, +) + +if TYPE_CHECKING: + from flexus_client_kit import ckit_bot_exec + +logger = logging.getLogger("meta_pages") + +# Use case: "Manage everything on your Page" +# Covers Facebook Pages, ad accounts, users — the account-level layer above campaigns. +PROVIDER_NAME = "meta_pages" + +_HELP = """meta_pages: Manage Facebook Pages and ad accounts. +op=help | status | list_methods | call(args={method_id, ...}) + + list_pages() -- Facebook Pages you manage (needed for ad creatives) + list_ad_accounts() -- All ad accounts accessible with your token + get_ad_account_info(ad_account_id) + update_spending_limit(ad_account_id, spending_limit) + list_account_users(ad_account_id) +""" + +_HANDLERS: Dict[str, Any] = { + "list_pages": lambda c, a: list_pages(c), + "list_ad_accounts": lambda c, a: list_ad_accounts(c), + "get_ad_account_info": lambda c, a: get_ad_account_info(c, a.get("ad_account_id", "")), + "update_spending_limit": lambda c, a: update_spending_limit(c, a.get("ad_account_id", ""), a.get("spending_limit", 0)), + "list_account_users": lambda c, a: list_account_users(c, a.get("ad_account_id", "")), +} + + +class IntegrationMetaPages: + # Wraps FacebookAdsClient for page/account-level operations. + def __init__(self, rcx: "ckit_bot_exec.RobotContext"): + self.client = FacebookAdsClient(rcx.fclient, rcx) + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + try: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return _HELP + if op == "status": + return await self._status() + if op == "list_methods": + return "\n".join(sorted(_HANDLERS.keys())) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + handler = _HANDLERS.get(method_id) + if handler is None: + return f"Error: unknown method_id={method_id!r}. Use op=list_methods to see available methods." + return await handler(self.client, call_args) + except FacebookAuthError as e: + return e.message + except FacebookAPIError as e: + logger.info("meta_pages api error: %s", e) + return e.format_for_user() + except FacebookValidationError as e: + return f"Error: {e.message}" + except Exception as e: + logger.error("Unexpected error in meta_pages op=%s", (model_produced_args or {}).get("op"), exc_info=e) + return f"Error: {e}" + + async def _status(self) -> str: + try: + auth_error = await self.client.ensure_auth() + if auth_error: + return auth_error + return "meta_pages: connected. Use op=help to see available operations." + except (FacebookAuthError, FacebookAPIError, FacebookValidationError) as e: + return e.message + except Exception as e: + logger.error("Unexpected error in meta_pages status", exc_info=e) + return f"Error: {e}" diff --git a/flexus_client_kit/integrations/fi_meta_threads.py b/flexus_client_kit/integrations/fi_meta_threads.py new file mode 100644 index 00000000..4774aa4b --- /dev/null +++ b/flexus_client_kit/integrations/fi_meta_threads.py @@ -0,0 +1,20 @@ +import json +from typing import Any, Dict +from flexus_client_kit import ckit_cloudtool + +PROVIDER_NAME = "meta_threads" +METHOD_IDS: list[str] = [] + + +class IntegrationMetaThreads: + async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return f"provider={PROVIDER_NAME}\nop=help | status | list_methods | call\nstatus: not yet implemented" + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "not_implemented", "method_count": 0}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + method_id = str((args.get("args") or {}).get("method_id", "")).strip() + return json.dumps({"ok": False, "error_code": "INTEGRATION_UNAVAILABLE", "provider": PROVIDER_NAME, "method_id": method_id, "message": "Not yet implemented."}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_meta_whatsapp.py b/flexus_client_kit/integrations/fi_meta_whatsapp.py new file mode 100644 index 00000000..aded17d7 --- /dev/null +++ b/flexus_client_kit/integrations/fi_meta_whatsapp.py @@ -0,0 +1,20 @@ +import json +from typing import Any, Dict +from flexus_client_kit import ckit_cloudtool + +PROVIDER_NAME = "meta_whatsapp" +METHOD_IDS: list[str] = [] + + +class IntegrationMetaWhatsapp: + async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return f"provider={PROVIDER_NAME}\nop=help | status | list_methods | call\nstatus: not yet implemented" + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "not_implemented", "method_count": 0}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + method_id = str((args.get("args") or {}).get("method_id", "")).strip() + return json.dumps({"ok": False, "error_code": "INTEGRATION_UNAVAILABLE", "provider": PROVIDER_NAME, "method_id": method_id, "message": "Not yet implemented."}, indent=2, ensure_ascii=False) From a3888f1a5507844fa24756846e11de75ba2a01cd Mon Sep 17 00:00:00 2001 From: lev-goryachev Date: Mon, 16 Mar 2026 13:35:20 +0100 Subject: [PATCH 2/2] refactor(meta): flatten facebook/ subpackage into root integrations - Merge client.py + exceptions.py + models.py + utils.py into _fi_meta_helpers.py - Inline campaigns/adsets/ads/audiences/pixels/targeting/rules into fi_meta_marketing_manage.py - Inline insights.py into fi_meta_marketing_metrics.py - Inline accounts.py into fi_meta_pages.py - Delete facebook/ subpackage entirely All fi_meta_*.py files now self-contained with single helper dep. --- .../integrations/_fi_meta_helpers.py | 567 +++++++++ .../integrations/facebook/__init__.py | 0 .../integrations/facebook/accounts.py | 161 --- .../integrations/facebook/ads.py | 246 ---- .../integrations/facebook/adsets.py | 263 ---- .../integrations/facebook/campaigns.py | 317 ----- .../integrations/facebook/client.py | 186 --- .../integrations/facebook/exceptions.py | 121 -- .../integrations/facebook/models.py | 259 ---- .../integrations/facebook/utils.py | 140 -- .../integrations/fi_meta_marketing_manage.py | 1123 ++++++++++++++--- .../integrations/fi_meta_marketing_metrics.py | 157 ++- .../integrations/fi_meta_pages.py | 163 ++- 13 files changed, 1798 insertions(+), 1905 deletions(-) create mode 100644 flexus_client_kit/integrations/_fi_meta_helpers.py delete mode 100644 flexus_client_kit/integrations/facebook/__init__.py delete mode 100644 flexus_client_kit/integrations/facebook/accounts.py delete mode 100644 flexus_client_kit/integrations/facebook/ads.py delete mode 100644 flexus_client_kit/integrations/facebook/adsets.py delete mode 100644 flexus_client_kit/integrations/facebook/campaigns.py delete mode 100644 flexus_client_kit/integrations/facebook/client.py delete mode 100644 flexus_client_kit/integrations/facebook/exceptions.py delete mode 100644 flexus_client_kit/integrations/facebook/models.py delete mode 100644 flexus_client_kit/integrations/facebook/utils.py diff --git a/flexus_client_kit/integrations/_fi_meta_helpers.py b/flexus_client_kit/integrations/_fi_meta_helpers.py new file mode 100644 index 00000000..e1b59f01 --- /dev/null +++ b/flexus_client_kit/integrations/_fi_meta_helpers.py @@ -0,0 +1,567 @@ +from __future__ import annotations + +import asyncio +import hashlib +import logging +import time +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING + +import httpx +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +if TYPE_CHECKING: + from flexus_client_kit import ckit_client, ckit_bot_exec + +# ── Exceptions ──────────────────────────────────────────────────────────────── + +logger = logging.getLogger("meta") + + +class FacebookError(Exception): + def __init__(self, message: str, details: Optional[str] = None): + self.message = message + self.details = details + super().__init__(message) + + def __str__(self) -> str: + if self.details: + return f"{self.message}\n{self.details}" + return self.message + + +class FacebookAPIError(FacebookError): + CODE_INVALID_PARAMS = 100 + CODE_AUTH_EXPIRED = 190 + CODE_RATE_LIMIT_1 = 4 + CODE_RATE_LIMIT_2 = 17 + CODE_RATE_LIMIT_3 = 32 + CODE_INSUFFICIENT_PERMISSIONS = 80004 + CODE_AD_ACCOUNT_DISABLED = 2635 + CODE_BUDGET_TOO_LOW = 1487387 + RATE_LIMIT_CODES = {CODE_RATE_LIMIT_1, CODE_RATE_LIMIT_2, CODE_RATE_LIMIT_3} + + def __init__( + self, + code: int, + message: str, + error_type: str = "", + user_title: Optional[str] = None, + user_msg: Optional[str] = None, + fbtrace_id: Optional[str] = None, + ): + self.code = code + self.error_type = error_type + self.user_title = user_title + self.user_msg = user_msg + self.fbtrace_id = fbtrace_id + details_parts = [] + if user_title: + details_parts.append(f"**{user_title}**") + if user_msg: + details_parts.append(user_msg) + if not details_parts: + details_parts.append(message) + super().__init__(message, "\n".join(details_parts)) + + @property + def is_rate_limit(self) -> bool: + return self.code in self.RATE_LIMIT_CODES + + @property + def is_auth_error(self) -> bool: + return self.code == self.CODE_AUTH_EXPIRED + + def format_for_user(self) -> str: + if self.code == self.CODE_AUTH_EXPIRED: + return f"Authentication failed. Please reconnect Facebook.\n{self.details}" + elif self.is_rate_limit: + return f"Rate limit reached. Please try again in a few minutes.\n{self.details}" + elif self.code == self.CODE_INVALID_PARAMS: + return f"Invalid parameters (code {self.code}):\n{self.details}" + elif self.code == self.CODE_AD_ACCOUNT_DISABLED: + return f"Ad account is disabled.\n{self.details}" + elif self.code == self.CODE_BUDGET_TOO_LOW: + return f"Budget too low:\n{self.details}" + elif self.code == self.CODE_INSUFFICIENT_PERMISSIONS: + return f"Insufficient permissions.\n{self.details}" + else: + return f"Facebook API Error ({self.code}):\n{self.details}" + + +class FacebookAuthError(FacebookError): + def __init__(self, message: str = "Facebook authentication required"): + super().__init__(message) + + +class FacebookValidationError(FacebookError): + def __init__(self, field: str, message: str): + self.field = field + super().__init__(f"Validation error for '{field}': {message}") + + +class FacebookTimeoutError(FacebookError): + def __init__(self, timeout: float): + super().__init__(f"Request timed out after {timeout} seconds") + + +async def parse_api_error(response: httpx.Response) -> FacebookAPIError: + try: + error_data = response.json() + if "error" in error_data: + err = error_data["error"] + return FacebookAPIError( + code=err.get("code", response.status_code), + message=err.get("message", "Unknown error"), + error_type=err.get("type", ""), + user_title=err.get("error_user_title"), + user_msg=err.get("error_user_msg"), + fbtrace_id=err.get("fbtrace_id"), + ) + return FacebookAPIError(code=response.status_code, message=f"HTTP {response.status_code}: {response.text[:500]}") + except (KeyError, ValueError) as e: + logger.warning("Error parsing FB API error response", exc_info=e) + return FacebookAPIError(code=response.status_code, message=f"HTTP {response.status_code}: {response.text[:500]}") + + +# ── Models ──────────────────────────────────────────────────────────────────── + +class CustomAudienceSubtype(str, Enum): + CUSTOM = "CUSTOM" + WEBSITE = "WEBSITE" + APP = "APP" + ENGAGEMENT = "ENGAGEMENT" + LOOKALIKE = "LOOKALIKE" + VIDEO = "VIDEO" + LEAD_GENERATION = "LEAD_GENERATION" + ON_SITE_LEAD = "ON_SITE_LEAD" + + +class InsightsBreakdown(str, Enum): + AGE = "age" + GENDER = "gender" + COUNTRY = "country" + REGION = "region" + PLACEMENT = "publisher_platform" + DEVICE = "device_platform" + IMPRESSION_DEVICE = "impression_device" + PLATFORM_POSITION = "platform_position" + + +class InsightsDatePreset(str, Enum): + TODAY = "today" + YESTERDAY = "yesterday" + LAST_7D = "last_7d" + LAST_14D = "last_14d" + LAST_28D = "last_28d" + LAST_30D = "last_30d" + LAST_90D = "last_90d" + THIS_MONTH = "this_month" + LAST_MONTH = "last_month" + MAXIMUM = "maximum" + + +class AdRuleStatus(str, Enum): + ENABLED = "ENABLED" + DISABLED = "DISABLED" + DELETED = "DELETED" + HAS_ISSUES = "HAS_ISSUES" + + +class CampaignObjective(str, Enum): + TRAFFIC = "OUTCOME_TRAFFIC" + SALES = "OUTCOME_SALES" + ENGAGEMENT = "OUTCOME_ENGAGEMENT" + AWARENESS = "OUTCOME_AWARENESS" + LEADS = "OUTCOME_LEADS" + APP_PROMOTION = "OUTCOME_APP_PROMOTION" + + +class CampaignStatus(str, Enum): + ACTIVE = "ACTIVE" + PAUSED = "PAUSED" + ARCHIVED = "ARCHIVED" + + +class AccountStatus(int, Enum): + ACTIVE = 1 + DISABLED = 2 + UNSETTLED = 3 + PENDING_RISK_REVIEW = 7 + PENDING_SETTLEMENT = 8 + IN_GRACE_PERIOD = 9 + PENDING_CLOSURE = 100 + CLOSED = 101 + TEMPORARILY_UNAVAILABLE = 201 + + +class OptimizationGoal(str, Enum): + LINK_CLICKS = "LINK_CLICKS" + LANDING_PAGE_VIEWS = "LANDING_PAGE_VIEWS" + IMPRESSIONS = "IMPRESSIONS" + REACH = "REACH" + CONVERSIONS = "CONVERSIONS" + VALUE = "VALUE" + LEAD_GENERATION = "LEAD_GENERATION" + APP_INSTALLS = "APP_INSTALLS" + OFFSITE_CONVERSIONS = "OFFSITE_CONVERSIONS" + POST_ENGAGEMENT = "POST_ENGAGEMENT" + VIDEO_VIEWS = "VIDEO_VIEWS" + THRUPLAY = "THRUPLAY" + + +class BillingEvent(str, Enum): + IMPRESSIONS = "IMPRESSIONS" + LINK_CLICKS = "LINK_CLICKS" + APP_INSTALLS = "APP_INSTALLS" + THRUPLAY = "THRUPLAY" + + +class BidStrategy(str, Enum): + LOWEST_COST_WITHOUT_CAP = "LOWEST_COST_WITHOUT_CAP" + LOWEST_COST_WITH_BID_CAP = "LOWEST_COST_WITH_BID_CAP" + COST_CAP = "COST_CAP" + + +class CallToActionType(str, Enum): + LEARN_MORE = "LEARN_MORE" + SHOP_NOW = "SHOP_NOW" + SIGN_UP = "SIGN_UP" + BOOK_NOW = "BOOK_NOW" + DOWNLOAD = "DOWNLOAD" + GET_OFFER = "GET_OFFER" + GET_QUOTE = "GET_QUOTE" + CONTACT_US = "CONTACT_US" + SUBSCRIBE = "SUBSCRIBE" + APPLY_NOW = "APPLY_NOW" + BUY_NOW = "BUY_NOW" + WATCH_MORE = "WATCH_MORE" + + +class AdFormat(str, Enum): + DESKTOP_FEED_STANDARD = "DESKTOP_FEED_STANDARD" + MOBILE_FEED_STANDARD = "MOBILE_FEED_STANDARD" + INSTAGRAM_STANDARD = "INSTAGRAM_STANDARD" + INSTAGRAM_STORY = "INSTAGRAM_STORY" + MOBILE_BANNER = "MOBILE_BANNER" + MOBILE_INTERSTITIAL = "MOBILE_INTERSTITIAL" + MOBILE_NATIVE = "MOBILE_NATIVE" + RIGHT_COLUMN_STANDARD = "RIGHT_COLUMN_STANDARD" + + +class GeoLocation(BaseModel): + countries: List[str] = Field(default_factory=list) + regions: List[Dict[str, Any]] = Field(default_factory=list) + cities: List[Dict[str, Any]] = Field(default_factory=list) + zips: List[Dict[str, Any]] = Field(default_factory=list) + location_types: List[str] = Field(default_factory=lambda: ["home", "recent"]) + model_config = ConfigDict(extra="allow") + + +class TargetingSpec(BaseModel): + geo_locations: GeoLocation + age_min: int = Field(default=18, ge=13, le=65) + age_max: int = Field(default=65, ge=13, le=65) + genders: List[int] = Field(default_factory=list) + interests: List[Dict[str, Any]] = Field(default_factory=list) + behaviors: List[Dict[str, Any]] = Field(default_factory=list) + custom_audiences: List[Dict[str, Any]] = Field(default_factory=list) + excluded_custom_audiences: List[Dict[str, Any]] = Field(default_factory=list) + locales: List[int] = Field(default_factory=list) + publisher_platforms: List[str] = Field(default_factory=list) + device_platforms: List[str] = Field(default_factory=list) + model_config = ConfigDict(extra="allow") + + @model_validator(mode="after") + def validate_geo_locations(self) -> "TargetingSpec": + geo = self.geo_locations + if not geo.countries and not geo.regions and not geo.cities: + raise ValueError("At least one geo_location (country, region, or city) is required") + return self + + @model_validator(mode="after") + def validate_age_range(self) -> "TargetingSpec": + if self.age_min > self.age_max: + raise ValueError("age_min cannot be greater than age_max") + return self + + +class ActionBreakdown(BaseModel): + action_type: str + value: int + + @field_validator("value", mode="before") + @classmethod + def coerce_value(cls, v: Any) -> int: + return int(v) + + +class Insights(BaseModel): + impressions: int = 0 + clicks: int = 0 + spend: float = 0.0 + reach: int = 0 + frequency: float = 0.0 + ctr: float = 0.0 + cpc: float = 0.0 + cpm: float = 0.0 + actions: List[ActionBreakdown] = Field(default_factory=list) + date_start: Optional[str] = None + date_stop: Optional[str] = None + model_config = ConfigDict(extra="allow") + + @field_validator("impressions", "clicks", "reach", mode="before") + @classmethod + def coerce_int(cls, v: Any) -> int: + return int(v) if v else 0 + + @field_validator("spend", "frequency", "ctr", "cpc", "cpm", mode="before") + @classmethod + def coerce_float(cls, v: Any) -> float: + return float(v) if v else 0.0 + + +# ── Utils ───────────────────────────────────────────────────────────────────── + +def validate_ad_account_id(ad_account_id: str) -> str: + if not ad_account_id: + raise FacebookValidationError("ad_account_id", "is required") + ad_account_id = str(ad_account_id).strip() + if not ad_account_id: + raise FacebookValidationError("ad_account_id", "cannot be empty") + if not ad_account_id.startswith("act_"): + return f"act_{ad_account_id}" + return ad_account_id + + +def validate_budget(budget: int, min_budget: int = 100, currency: str = "USD") -> int: + if not isinstance(budget, int): + try: + budget = int(budget) + except (TypeError, ValueError): + raise FacebookValidationError("budget", "must be an integer (cents)") + if budget < min_budget: + raise FacebookValidationError("budget", f"must be at least {format_currency(min_budget, currency)}") + return budget + + +def validate_targeting_spec(spec: Dict[str, Any]) -> Tuple[bool, str]: + try: + if not spec: + return False, "Targeting spec cannot be empty" + if "geo_locations" not in spec: + return False, "geo_locations is required in targeting" + geo = spec["geo_locations"] + if not isinstance(geo, dict): + return False, "geo_locations must be a dictionary" + if not geo.get("countries") and not geo.get("regions") and not geo.get("cities"): + return False, "At least one geo_location (country, region, or city) is required" + if "age_min" in spec: + age_min = spec["age_min"] + if not isinstance(age_min, int) or age_min < 13 or age_min > 65: + return False, "age_min must be between 13 and 65" + if "age_max" in spec: + age_max = spec["age_max"] + if not isinstance(age_max, int) or age_max < 13 or age_max > 65: + return False, "age_max must be between 13 and 65" + if "age_min" in spec and "age_max" in spec: + if spec["age_min"] > spec["age_max"]: + return False, "age_min cannot be greater than age_max" + return True, "" + except (KeyError, TypeError, ValueError) as e: + return False, f"Validation error: {str(e)}" + + +def format_currency(cents: int, currency: str = "USD") -> str: + return f"{cents / 100:.2f} {currency}" + + +def format_account_status(status_code: int) -> str: + status_map = { + 1: "Active", 2: "Disabled", 3: "Unsettled", 7: "Pending Risk Review", + 8: "Pending Settlement", 9: "In Grace Period", 100: "Pending Closure", + 101: "Closed", 201: "Temporarily Unavailable", + } + return status_map.get(status_code, f"Unknown ({status_code})") + + +def normalize_insights_data(raw_data: Dict[str, Any]) -> Insights: + try: + impressions = int(raw_data.get("impressions", 0)) + clicks = int(raw_data.get("clicks", 0)) + spend = float(raw_data.get("spend", 0.0)) + reach = int(raw_data.get("reach", 0)) + frequency = float(raw_data.get("frequency", 0.0)) + ctr = raw_data.get("ctr") + ctr = float(ctr) if ctr else ((clicks / impressions) * 100 if impressions > 0 else 0.0) + cpc = raw_data.get("cpc") + cpc = float(cpc) if cpc else (spend / clicks if clicks > 0 else 0.0) + cpm = raw_data.get("cpm") + cpm = float(cpm) if cpm else ((spend / impressions) * 1000 if impressions > 0 else 0.0) + actions = [] + for action in raw_data.get("actions", []): + if isinstance(action, dict): + actions.append({"action_type": action.get("action_type", "unknown"), "value": int(action.get("value", 0))}) + return Insights( + impressions=impressions, clicks=clicks, spend=spend, reach=reach, + frequency=frequency, ctr=ctr, cpc=cpc, cpm=cpm, actions=actions, + date_start=raw_data.get("date_start"), date_stop=raw_data.get("date_stop"), + ) + except (KeyError, TypeError, ValueError) as e: + logger.warning("Error normalizing insights data", exc_info=e) + return Insights() + + +def hash_for_audience(value: str, field_type: str) -> str: + value = value.strip().lower() + if field_type == "PHONE": + value = ''.join(c for c in value if c.isdigit()) + elif field_type in ["FN", "LN", "CT", "ST"]: + value = value.replace(" ", "") + return hashlib.sha256(value.encode()).hexdigest() + + +# ── HTTP Client ─────────────────────────────────────────────────────────────── + +API_BASE = "https://graph.facebook.com" +API_VERSION = "v19.0" +DEFAULT_TIMEOUT = 30.0 +MAX_RETRIES = 3 +INITIAL_RETRY_DELAY = 1.0 + + +class FacebookAdsClient: + # Handles OAuth token retrieval, HTTP request execution, and retry-with-backoff. + # All fi_meta_*.py integration classes use this as their sole HTTP layer. + def __init__( + self, + fclient: "ckit_client.FlexusClient", + rcx: "ckit_bot_exec.RobotContext", + ad_account_id: str = "", + ): + self.fclient = fclient + self.rcx = rcx + self._ad_account_id = "" + if ad_account_id: + self._ad_account_id = validate_ad_account_id(ad_account_id) + self._access_token: str = "" + self._headers: Dict[str, str] = {} + + @property + def ad_account_id(self) -> str: + return self._ad_account_id + + @ad_account_id.setter + def ad_account_id(self, value: str) -> None: + self._ad_account_id = validate_ad_account_id(value) if value else "" + + @property + def is_test_mode(self) -> bool: + return self.rcx.running_test_scenario + + @property + def access_token(self) -> str: + return self._access_token + + async def ensure_auth(self) -> Optional[str]: + try: + if self.is_test_mode: + return None + if not self._access_token: + self._access_token = await self._fetch_token() + self._headers = { + "Authorization": f"Bearer {self._access_token}", + "Content-Type": "application/json", + } + return None + except (AttributeError, KeyError, ValueError) as e: + logger.info("Failed to get Facebook token", exc_info=e) + return await self._prompt_oauth_connection() + + async def request( + self, + method: str, + endpoint: str, + params: Optional[Dict[str, Any]] = None, + data: Optional[Dict[str, Any]] = None, + form_data: Optional[Dict[str, Any]] = None, + timeout: float = DEFAULT_TIMEOUT, + ) -> Dict[str, Any]: + auth_error = await self.ensure_auth() + if auth_error: + raise FacebookAuthError(auth_error) + url = f"{API_BASE}/{API_VERSION}/{endpoint}" + + async def _make() -> Dict[str, Any]: + async with httpx.AsyncClient() as client: + if method == "GET": + response = await client.get(url, params=params, headers=self._headers, timeout=timeout) + elif method == "POST": + if form_data: + response = await client.post(url, data=form_data, timeout=timeout) + else: + response = await client.post(url, json=data, headers=self._headers, timeout=timeout) + elif method == "DELETE": + response = await client.delete(url, json=data, headers=self._headers, timeout=timeout) + else: + raise ValueError(f"Unsupported method: {method}") + return response.json() + + return await self._retry_with_backoff(_make) + + async def _retry_with_backoff(self, func, max_retries: int = MAX_RETRIES, initial_delay: float = INITIAL_RETRY_DELAY) -> Dict[str, Any]: + last_exception = None + for attempt in range(max_retries): + try: + return await func() + except (httpx.HTTPError, httpx.TimeoutException) as e: + last_exception = e + if attempt == max_retries - 1: + raise FacebookTimeoutError(DEFAULT_TIMEOUT) + delay = initial_delay * (2 ** attempt) + logger.warning("Retry %s/%s after %.1fs due to: %s", attempt + 1, max_retries, delay, e) + await asyncio.sleep(delay) + except FacebookAPIError as e: + if e.is_rate_limit: + last_exception = e + if attempt == max_retries - 1: + raise + delay = initial_delay * (2 ** attempt) * 2 + logger.warning("Rate limit hit, retry %s/%s after %.1fs", attempt + 1, max_retries, delay) + await asyncio.sleep(delay) + else: + raise + if last_exception: + raise last_exception + raise FacebookAPIError(500, "Unexpected retry loop exit") + + async def _fetch_token(self) -> str: + facebook_auth = self.rcx.external_auth.get("facebook") or {} + token_obj = facebook_auth.get("token") or {} + access_token = token_obj.get("access_token", "") + if not access_token: + raise ValueError("No Facebook OAuth connection found") + logger.info("Facebook token retrieved for %s", self.rcx.persona.owner_fuser_id) + return access_token + + async def _prompt_oauth_connection(self) -> str: + from flexus_client_kit import ckit_client + http = await self.fclient.use_http() + async with http as h: + result = await h.execute( + ckit_client.gql.gql(""" + query GetFacebookToken($fuser_id: String!, $ws_id: String!, $provider: String!, $scopes: [String!]!) { + external_auth_token(fuser_id: $fuser_id ws_id: $ws_id provider: $provider scopes: $scopes) + } + """), + variable_values={ + "fuser_id": self.rcx.persona.owner_fuser_id, + "ws_id": self.rcx.persona.ws_id, + "provider": "facebook", + "scopes": ["ads_management", "ads_read", "business_management", "pages_manage_ads"], + }, + ), + auth_url = result.get("external_auth_token", "") + return f"Facebook authorization required.\n\nClick this link to connect:\n{auth_url}\n\nAfter authorizing, return here and try again." diff --git a/flexus_client_kit/integrations/facebook/__init__.py b/flexus_client_kit/integrations/facebook/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/flexus_client_kit/integrations/facebook/accounts.py b/flexus_client_kit/integrations/facebook/accounts.py deleted file mode 100644 index 4da4cbd6..00000000 --- a/flexus_client_kit/integrations/facebook/accounts.py +++ /dev/null @@ -1,161 +0,0 @@ -from __future__ import annotations -import logging -from typing import Any, Dict, List, TYPE_CHECKING -from flexus_client_kit.integrations.facebook.utils import format_currency, format_account_status, validate_ad_account_id -from flexus_client_kit.integrations.facebook.exceptions import FacebookValidationError - -if TYPE_CHECKING: - from flexus_client_kit.integrations.facebook.client import FacebookAdsClient - -AD_ACCOUNT_FIELDS = ( - "id,account_id,name,currency,timezone_name,account_status," - "balance,amount_spent,spend_cap,business{id,name}" -) -AD_ACCOUNT_DETAIL_FIELDS = ( - "id,account_id,name,currency,timezone_name,account_status," - "balance,amount_spent,spend_cap,business,funding_source_details," - "min_daily_budget,created_time" -) - - -async def list_ad_accounts(client: "FacebookAdsClient") -> str: - if client.is_test_mode: - return _mock_list_ad_accounts() - data = await client.request("GET", "me/adaccounts", params={"fields": AD_ACCOUNT_FIELDS, "limit": 50}) - accounts = data.get("data", []) - if not accounts: - return ( - "No ad accounts found. You may need to:\n" - "1. Create an ad account in Facebook Business Manager\n" - "2. Ensure you have proper permissions" - ) - business_accounts: Dict[str, List[Any]] = {} - personal_accounts: List[Any] = [] - for acc in accounts: - business = acc.get("business") - if business: - biz_name = business.get("name", f"Business {business.get('id', 'Unknown')}") - if biz_name not in business_accounts: - business_accounts[biz_name] = [] - business_accounts[biz_name].append(acc) - else: - personal_accounts.append(acc) - result = f"Found {len(accounts)} ad account{'s' if len(accounts) != 1 else ''}:\n\n" - for biz_name, biz_accounts in business_accounts.items(): - count = len(biz_accounts) - result += f"**Business Portfolio: {biz_name}** ({count} account{'s' if count != 1 else ''})\n\n" - for acc in biz_accounts: - result += _format_account_summary(acc) - if personal_accounts: - count = len(personal_accounts) - result += f"**Personal Account** ({count} account{'s' if count != 1 else ''})\n\n" - for acc in personal_accounts: - result += _format_account_summary(acc) - return result - - -async def get_ad_account_info(client: "FacebookAdsClient", ad_account_id: str) -> str: - if not ad_account_id: - return "ERROR: ad_account_id parameter is required" - try: - ad_account_id = validate_ad_account_id(ad_account_id) - except FacebookValidationError as e: - return f"ERROR: {e.message}" - if client.is_test_mode: - return _mock_get_ad_account_info(ad_account_id) - acc = await client.request("GET", ad_account_id, params={"fields": AD_ACCOUNT_DETAIL_FIELDS}) - account_status = acc.get("account_status", 1) - status_text = format_account_status(account_status) - currency = acc.get("currency", "USD") - result = "Ad Account Details:\n\n" - result += f"**{acc.get('name', 'Unnamed')}**\n" - result += f" ID: {acc['id']}\n" - result += f" Account ID: {acc.get('account_id', 'N/A')}\n" - result += f" Currency: {currency}\n" - result += f" Timezone: {acc.get('timezone_name', 'N/A')}\n" - result += f" Status: {status_text}\n" - result += f" Created: {acc.get('created_time', 'N/A')}\n" - result += "\n**Financial Info:**\n" - balance = int(acc.get('balance', 0)) - amount_spent = int(acc.get('amount_spent', 0)) - spend_cap = int(acc.get('spend_cap', 0)) - result += f" Balance: {format_currency(balance, currency)}\n" - result += f" Total Spent: {format_currency(amount_spent, currency)}\n" - if spend_cap > 0: - result += f" Spend Cap: {format_currency(spend_cap, currency)}\n" - remaining = spend_cap - amount_spent - result += f" Remaining: {format_currency(remaining, currency)}\n" - percent_used = (amount_spent / spend_cap) * 100 if spend_cap > 0 else 0 - if percent_used > 90: - result += f" Warning: {percent_used:.1f}% of spend cap used!\n" - if 'min_daily_budget' in acc: - result += f" Min Daily Budget: {format_currency(int(acc['min_daily_budget']), currency)}\n" - if 'business' in acc: - business = acc['business'] - result += f"\n**Business:** {business.get('name', 'N/A')} (ID: {business.get('id', 'N/A')})\n" - return result - - -async def update_spending_limit(client: "FacebookAdsClient", ad_account_id: str, spending_limit: int) -> str: - if not ad_account_id: - return "ERROR: ad_account_id parameter is required" - try: - ad_account_id = validate_ad_account_id(ad_account_id) - except FacebookValidationError as e: - return f"ERROR: {e.message}" - spending_limit = int(spending_limit) - if spending_limit < 0: - return "ERROR: spending_limit must be a positive number" - if client.is_test_mode: - return f"Spending limit updated to {format_currency(spending_limit)} for account {ad_account_id}\n\n(Note: This is a test/mock operation)" - result = await client.request("POST", ad_account_id, data={"spend_cap": spending_limit}) - if result.get("success"): - return f"Spending limit updated to {format_currency(spending_limit)} for account {ad_account_id}" - else: - return f"Failed to update spending limit. Response: {result}" - - -def _format_account_summary(acc: Dict[str, Any]) -> str: - account_status = acc.get("account_status", 1) - status_text = format_account_status(account_status) - currency = acc.get("currency", "USD") - result = f" **{acc.get('name', 'Unnamed')}**\n" - result += f" ID: {acc['id']}\n" - result += f" Currency: {currency}\n" - result += f" Timezone: {acc.get('timezone_name', 'N/A')}\n" - result += f" Status: {status_text}\n" - if 'balance' in acc: - result += f" Balance: {format_currency(int(acc['balance']), currency)}\n" - if 'amount_spent' in acc: - result += f" Total Spent: {format_currency(int(acc['amount_spent']), currency)}\n" - if 'spend_cap' in acc and int(acc.get('spend_cap', 0)) > 0: - result += f" Spend Cap: {format_currency(int(acc['spend_cap']), currency)}\n" - result += "\n" - return result - - -def _mock_list_ad_accounts() -> str: - return """Found 1 ad account: -**Test Ad Account** - ID: act_MOCK_TEST_000 - Currency: USD - Status: Active - Balance: 500.00 USD - Spend Cap: 10000.00 USD -""" - - -def _mock_get_ad_account_info(ad_account_id: str) -> str: - return f"""Ad Account Details: -**Test Ad Account** - ID: {ad_account_id} - Account ID: MOCK_TEST_000 - Currency: USD - Timezone: America/Los_Angeles - Status: Active -**Financial Info:** - Balance: 500.00 USD - Total Spent: 1234.56 USD - Spend Cap: 10000.00 USD - Remaining: 8765.44 USD -""" diff --git a/flexus_client_kit/integrations/facebook/ads.py b/flexus_client_kit/integrations/facebook/ads.py deleted file mode 100644 index 4de121c9..00000000 --- a/flexus_client_kit/integrations/facebook/ads.py +++ /dev/null @@ -1,246 +0,0 @@ -from __future__ import annotations -import logging -from pathlib import Path -from typing import Any, Dict, Optional, TYPE_CHECKING -import httpx -from flexus_client_kit.integrations.facebook.models import AdFormat, CallToActionType -from flexus_client_kit.integrations.facebook.utils import validate_ad_account_id -from flexus_client_kit.integrations.facebook.exceptions import FacebookValidationError -if TYPE_CHECKING: - from flexus_client_kit.integrations.facebook.client import FacebookAdsClient -logger = logging.getLogger("facebook.operations.ads") - - -async def upload_image( - client: "FacebookAdsClient", - image_path: Optional[str] = None, - image_url: Optional[str] = None, - ad_account_id: Optional[str] = None, -) -> str: - if not image_path and not image_url: - return "ERROR: Either image_path or image_url is required" - account_id = ad_account_id or client.ad_account_id - if not account_id: - try: - account_id = validate_ad_account_id(account_id) - except FacebookValidationError as e: - return f"ERROR: {e.message}" - if client.is_test_mode: - return f"""Image uploaded successfully! -Image Hash: abc123def456 -Ad Account: {account_id} -You can now use this image hash in create_creative() -""" - endpoint = f"{account_id}/adimages" - if image_url: - form_data = { - "url": image_url, - "access_token": client.access_token, - } - logger.info(f"Uploading image from URL to {endpoint}") - result = await client.request("POST", endpoint, form_data=form_data) - elif image_path: - image_file = Path(image_path) - if not image_file.exists(): - return f"ERROR: Image file not found: {image_path}" - await client.ensure_auth() - with open(image_file, 'rb') as f: - image_bytes = f.read() - from flexus_client_kit.integrations.facebook.client import API_BASE, API_VERSION - files = {"filename": (image_file.name, image_bytes, "image/jpeg")} - form_data = {"access_token": client.access_token} - url = f"{API_BASE}/{API_VERSION}/{endpoint}" - logger.info(f"Uploading image file to {url}") - async with httpx.AsyncClient() as http_client: - response = await http_client.post(url, data=form_data, files=files, timeout=60.0) - if response.status_code != 200: - return f"ERROR: Failed to upload image: {response.text}" - result = response.json() - images = result.get("images", {}) - if images: - image_hash = list(images.values())[0].get("hash", "unknown") - return f"""Image uploaded successfully! -Image Hash: {image_hash} -Ad Account: {account_id} -Use this hash in create_creative(): - facebook(op="create_creative", args={{ - "image_hash": "{image_hash}", - ... - }}) -""" - else: - return f"Failed to upload image. Response: {result}" - - -async def create_creative( - client: "FacebookAdsClient", - name: str, - page_id: str, - image_hash: str, - link: str, - message: Optional[str] = None, - headline: Optional[str] = None, - description: Optional[str] = None, - call_to_action_type: str = "LEARN_MORE", - ad_account_id: Optional[str] = None, -) -> str: - if not name: - return "ERROR: name is required" - if not page_id: - return "ERROR: page_id is required" - if not image_hash: - return "ERROR: image_hash is required (use upload_image first)" - if not link: - return "ERROR: link is required" - try: - CallToActionType(call_to_action_type) - except ValueError: - valid = [c.value for c in CallToActionType] - return f"ERROR: Invalid call_to_action_type. Must be one of: {', '.join(valid)}" - account_id = ad_account_id or client.ad_account_id - if not account_id: - return "ERROR: ad_account_id is required" - if client.is_test_mode: - return f"""Creative created successfully! -Creative ID: 987654321 -Name: {name} -Page ID: {page_id} -Image Hash: {image_hash} -Link: {link} -CTA: {call_to_action_type} -Now create an ad using this creative: - facebook(op="create_ad", args={{ - "adset_id": "...", - "creative_id": "987654321", - ... - }}) -""" - link_data: Dict[str, Any] = { - "image_hash": image_hash, - "link": link, - "call_to_action": {"type": call_to_action_type} - } - if message: - link_data["message"] = message - if headline: - link_data["name"] = headline - if description: - link_data["description"] = description - data = { - "name": name, - "object_story_spec": { - "page_id": page_id, - "link_data": link_data - } - } - result = await client.request("POST", f"{account_id}/adcreatives", data=data) - creative_id = result.get("id") - if not creative_id: - return f"Failed to create creative. Response: {result}" - return f"""Creative created successfully! -Creative ID: {creative_id} -Name: {name} -Page ID: {page_id} -Image Hash: {image_hash} -Link: {link} -CTA: {call_to_action_type} -Now create an ad using this creative: - facebook(op="create_ad", args={{ - "adset_id": "YOUR_ADSET_ID", - "creative_id": "{creative_id}", - "name": "Your Ad Name" - }}) -""" - - -async def create_ad( - client: "FacebookAdsClient", - name: str, - adset_id: str, - creative_id: str, - status: str = "PAUSED", - ad_account_id: Optional[str] = None, -) -> str: - if not name: - return "ERROR: name is required" - if not adset_id: - return "ERROR: adset_id is required" - if not creative_id: - return "ERROR: creative_id is required" - if status not in ["ACTIVE", "PAUSED"]: - return "ERROR: status must be 'ACTIVE' or 'PAUSED'" - account_id = ad_account_id or client.ad_account_id - if not account_id: - return "ERROR: ad_account_id is required" - if client.is_test_mode: - status_msg = ( - "Ad is paused. Activate it when ready to start delivery." - if status == "PAUSED" - else "Ad is active and will start delivery." - ) - return f"""Ad created successfully! -Ad ID: 111222333444555 -Name: {name} -Ad Set ID: {adset_id} -Creative ID: {creative_id} -Status: {status} -{status_msg} -Preview your ad: - facebook(op="preview_ad", args={{"ad_id": "111222333444555"}}) -""" - data = { - "name": name, - "adset_id": adset_id, - "creative": {"creative_id": creative_id}, - "status": status - } - result = await client.request("POST", f"{account_id}/ads", data=data) - ad_id = result.get("id") - if not ad_id: - return f"Failed to create ad. Response: {result}" - status_msg = ( - "Ad is paused. Activate it when ready to start delivery." - if status == "PAUSED" - else "Ad is active and will start delivery." - ) - return f"""Ad created successfully! -Ad ID: {ad_id} -Name: {name} -Ad Set ID: {adset_id} -Creative ID: {creative_id} -Status: {status} -{status_msg} -Preview your ad: - facebook(op="preview_ad", args={{"ad_id": "{ad_id}"}}) -""" - - -async def preview_ad(client: "FacebookAdsClient", ad_id: str, ad_format: str = "DESKTOP_FEED_STANDARD") -> str: - if not ad_id: - return "ERROR: ad_id is required" - try: - AdFormat(ad_format) - except ValueError: - valid = [f.value for f in AdFormat] - return f"ERROR: Invalid ad_format. Must be one of: {', '.join(valid)}" - if client.is_test_mode: - return f"""Ad Preview for {ad_id}: -Format: {ad_format} -Preview URL: https://facebook.com/ads/preview/mock_{ad_id} -Note: This is a mock preview. In production, Facebook will provide actual preview HTML/URL. -""" - data = await client.request("GET", f"{ad_id}/previews", params={"ad_format": ad_format}) - previews = data.get("data", []) - if not previews: - return "No preview available for this ad" - preview = previews[0] - body = preview.get("body", "") - if body: - return f"""Ad Preview for {ad_id}: -Format: {ad_format} -Preview HTML available (truncated): -{body[:500]}... -To view full preview, open the ad in Facebook Ads Manager. -""" - else: - return f"Preview generated but no body content available. Response: {preview}" diff --git a/flexus_client_kit/integrations/facebook/adsets.py b/flexus_client_kit/integrations/facebook/adsets.py deleted file mode 100644 index 6a74ecd2..00000000 --- a/flexus_client_kit/integrations/facebook/adsets.py +++ /dev/null @@ -1,263 +0,0 @@ -from __future__ import annotations -import json -import logging -from typing import Any, Dict, Optional, TYPE_CHECKING -from flexus_client_kit.integrations.facebook.utils import format_currency, validate_budget, validate_targeting_spec -from flexus_client_kit.integrations.facebook.exceptions import FacebookValidationError -if TYPE_CHECKING: - from flexus_client_kit.integrations.facebook.client import FacebookAdsClient -logger = logging.getLogger("facebook.operations.adsets") - -ADSET_FIELDS = "id,name,status,optimization_goal,billing_event,daily_budget,lifetime_budget,targeting" - - -async def list_adsets(client: "FacebookAdsClient", campaign_id: str) -> str: - if not campaign_id: - return "ERROR: campaign_id is required" - if client.is_test_mode: - return _mock_list_adsets(campaign_id) - data = await client.request("GET", f"{campaign_id}/adsets", params={"fields": ADSET_FIELDS, "limit": 50}) - adsets = data.get("data", []) - if not adsets: - return f"No ad sets found for campaign {campaign_id}" - result = f"Ad Sets for Campaign {campaign_id} (found {len(adsets)}):\n\n" - for adset in adsets: - result += f"**{adset['name']}**\n" - result += f" ID: {adset['id']}\n" - result += f" Status: {adset['status']}\n" - result += f" Optimization: {adset.get('optimization_goal', 'N/A')}\n" - result += f" Billing: {adset.get('billing_event', 'N/A')}\n" - if 'daily_budget' in adset: - result += f" Daily Budget: {format_currency(int(adset['daily_budget']))}\n" - elif 'lifetime_budget' in adset: - result += f" Lifetime Budget: {format_currency(int(adset['lifetime_budget']))}\n" - targeting = adset.get("targeting", {}) - if targeting: - geo = targeting.get("geo_locations", {}) - countries = geo.get("countries", []) - if countries: - result += f" Targeting: {', '.join(countries[:3])}" - if len(countries) > 3: - result += f" +{len(countries)-3} more" - result += "\n" - result += "\n" - return result - - -async def create_adset( - client: "FacebookAdsClient", - ad_account_id: str, - campaign_id: str, - name: str, - targeting: Dict[str, Any], - optimization_goal: str = "LINK_CLICKS", - billing_event: str = "IMPRESSIONS", - bid_strategy: str = "LOWEST_COST_WITHOUT_CAP", - status: str = "PAUSED", - daily_budget: Optional[int] = None, - lifetime_budget: Optional[int] = None, - bid_amount: Optional[int] = None, - start_time: Optional[str] = None, - end_time: Optional[str] = None, - promoted_object: Optional[Dict[str, Any]] = None, -) -> str: - if not ad_account_id: - return "ERROR: ad_account_id is required (e.g. act_123456)" - if not campaign_id: - return "ERROR: campaign_id is required" - if not name: - return "ERROR: name is required" - if not targeting: - return "ERROR: targeting is required" - targeting_valid, targeting_error = validate_targeting_spec(targeting) - if not targeting_valid: - return f"ERROR: Invalid targeting: {targeting_error}" - if daily_budget: - try: - daily_budget = validate_budget(daily_budget) - except FacebookValidationError as e: - return f"ERROR: {e.message}" - if lifetime_budget: - try: - lifetime_budget = validate_budget(lifetime_budget) - except FacebookValidationError as e: - return f"ERROR: {e.message}" - if client.is_test_mode: - return _mock_create_adset(campaign_id, name, optimization_goal, daily_budget, targeting) - form_data: Dict[str, Any] = { - "name": name, - "campaign_id": campaign_id, - "optimization_goal": optimization_goal, - "billing_event": billing_event, - "bid_strategy": bid_strategy, - "targeting": json.dumps(targeting), - "status": status, - "access_token": client.access_token, - } - if daily_budget: - form_data["daily_budget"] = str(daily_budget) - if lifetime_budget: - form_data["lifetime_budget"] = str(lifetime_budget) - if start_time: - form_data["start_time"] = start_time - if end_time: - form_data["end_time"] = end_time - if bid_amount: - form_data["bid_amount"] = str(bid_amount) - if bid_strategy == "LOWEST_COST_WITHOUT_CAP": - form_data["bid_strategy"] = "LOWEST_COST_WITH_BID_CAP" - if promoted_object: - form_data["promoted_object"] = json.dumps(promoted_object) - logger.info(f"Creating adset for campaign {campaign_id}") - result = await client.request("POST", f"{ad_account_id}/adsets", form_data=form_data) - adset_id = result.get("id") - if not adset_id: - return f"Failed to create ad set. Response: {result}" - output = f"""Ad Set created successfully! -ID: {adset_id} -Name: {name} -Campaign ID: {campaign_id} -Status: {status} -Optimization Goal: {optimization_goal} -Billing Event: {billing_event} -""" - if daily_budget: - output += f"Daily Budget: {format_currency(daily_budget)}\n" - if lifetime_budget: - output += f"Lifetime Budget: {format_currency(lifetime_budget)}\n" - geo = targeting.get("geo_locations", {}) - countries = geo.get("countries", []) - if countries: - output += f"\nTargeting:\n - Locations: {', '.join(countries)}\n" - if "age_min" in targeting or "age_max" in targeting: - age_min = targeting.get("age_min", 18) - age_max = targeting.get("age_max", 65) - output += f" - Age: {age_min}-{age_max}\n" - if status == "PAUSED": - output += "\nAd set is paused. Activate it when ready to start delivery." - return output - - -async def update_adset( - client: "FacebookAdsClient", - adset_id: str, - name: Optional[str] = None, - status: Optional[str] = None, - daily_budget: Optional[int] = None, - bid_amount: Optional[int] = None, -) -> str: - if not adset_id: - return "ERROR: adset_id is required" - if not any([name, status, daily_budget, bid_amount]): - return "ERROR: At least one field to update is required" - if status and status not in ["ACTIVE", "PAUSED"]: - return "ERROR: status must be 'ACTIVE' or 'PAUSED'" - if daily_budget: - try: - daily_budget = validate_budget(daily_budget) - except FacebookValidationError as e: - return f"ERROR: {e.message}" - updates = [] - if name: - updates.append(f"name -> {name}") - if status: - updates.append(f"status -> {status}") - if daily_budget: - updates.append(f"daily_budget -> {format_currency(daily_budget)}") - if bid_amount: - updates.append(f"bid_amount -> {bid_amount} cents") - if client.is_test_mode: - return f"Ad Set {adset_id} updated:\n" + "\n".join(f" - {u}" for u in updates) - data: Dict[str, Any] = {} - if name: - data["name"] = name - if status: - data["status"] = status - if daily_budget is not None: - data["daily_budget"] = daily_budget - if bid_amount is not None: - data["bid_amount"] = bid_amount - result = await client.request("POST", adset_id, data=data) - if result.get("success"): - return f"Ad Set {adset_id} updated:\n" + "\n".join(f" - {u}" for u in updates) - else: - return f"Failed to update ad set. Response: {result}" - - -async def validate_targeting( - client: "FacebookAdsClient", - targeting_spec: Dict[str, Any], - ad_account_id: Optional[str] = None, -) -> str: - if not targeting_spec: - return "ERROR: targeting_spec is required" - valid, error = validate_targeting_spec(targeting_spec) - if not valid: - return f"Invalid targeting: {error}" - geo = targeting_spec.get('geo_locations', {}) - countries = geo.get('countries', ['Not specified']) - age_min = targeting_spec.get('age_min', 18) - age_max = targeting_spec.get('age_max', 65) - if client.is_test_mode: - return f"""Targeting is valid! -Estimated Audience Size: ~1,000,000 - 1,500,000 people -Targeting Summary: - - Locations: {', '.join(countries)} - - Age: {age_min}-{age_max} - - Device Platforms: {', '.join(targeting_spec.get('device_platforms', ['all']))} -This is a test validation. In production, Facebook will provide actual estimated reach. -""" - account_id = ad_account_id or client.ad_account_id - if not account_id: - return "ERROR: ad_account_id required for validate_targeting" - result = await client.request( - "GET", f"{account_id}/targetingsentencelines", - params={"targeting_spec": json.dumps(targeting_spec)} - ) - output = "Targeting is valid!\n\n" - if "targetingsentencelines" in result: - output += "Targeting Summary:\n" - for line in result["targetingsentencelines"]: - output += f" - {line.get('content', '')}\n" - return output - - -def _mock_list_adsets(campaign_id: str) -> str: - return f"""Ad Sets for Campaign {campaign_id}: -**Test Ad Set** - ID: 234567890123456 - Status: ACTIVE - Optimization: LINK_CLICKS - Daily Budget: 20.00 USD - Targeting: US -""" - - -def _mock_create_adset( - campaign_id: str, - name: str, - optimization_goal: str, - daily_budget: Optional[int], - targeting: Dict[str, Any], -) -> str: - budget_line = "" - if daily_budget: - budget_line = f"Daily Budget: {format_currency(daily_budget)}\n" - else: - budget_line = "Budget: Using Campaign Budget (CBO)\n" - countries = targeting.get('geo_locations', {}).get('countries', ['Not specified']) - age_min = targeting.get('age_min', 18) - age_max = targeting.get('age_max', 65) - return f"""Ad Set created successfully! -ID: mock_adset_123456 -Name: {name} -Campaign ID: {campaign_id} -Status: PAUSED -Optimization Goal: {optimization_goal} -Billing Event: IMPRESSIONS -{budget_line} -Targeting: - - Locations: {', '.join(countries)} - - Age: {age_min}-{age_max} -Ad set is paused. Activate it when ready to start delivery. -""" diff --git a/flexus_client_kit/integrations/facebook/campaigns.py b/flexus_client_kit/integrations/facebook/campaigns.py deleted file mode 100644 index e077920b..00000000 --- a/flexus_client_kit/integrations/facebook/campaigns.py +++ /dev/null @@ -1,317 +0,0 @@ -from __future__ import annotations -import logging -from typing import Any, Dict, List, Optional, TYPE_CHECKING -from flexus_client_kit.integrations.facebook.models import CampaignObjective -from flexus_client_kit.integrations.facebook.utils import format_currency, validate_budget, normalize_insights_data -from flexus_client_kit.integrations.facebook.exceptions import FacebookAPIError, FacebookValidationError -if TYPE_CHECKING: - from flexus_client_kit.integrations.facebook.client import FacebookAdsClient -logger = logging.getLogger("facebook.operations.campaigns") - -CAMPAIGN_FIELDS = "id,name,status,objective,daily_budget,lifetime_budget" - - -async def list_campaigns( - client: "FacebookAdsClient", - ad_account_id: Optional[str] = None, - status_filter: Optional[str] = None, -) -> str: - account_id = ad_account_id or client.ad_account_id - if not account_id: - return "ERROR: ad_account_id parameter required for list_campaigns" - if client.is_test_mode: - return _mock_list_campaigns() - params: Dict[str, Any] = {"fields": CAMPAIGN_FIELDS, "limit": 50} - if status_filter: - params["effective_status"] = f"['{status_filter}']" - data = await client.request("GET", f"{account_id}/campaigns", params=params) - campaigns = data.get("data", []) - if not campaigns: - return "No campaigns found." - result = f"Found {len(campaigns)} campaign{'s' if len(campaigns) != 1 else ''}:\n" - for c in campaigns: - budget_str = "" - if c.get("daily_budget"): - budget_str = f", Daily: {format_currency(int(c['daily_budget']))}" - elif c.get("lifetime_budget"): - budget_str = f", Lifetime: {format_currency(int(c['lifetime_budget']))}" - result += f" {c['name']} (ID: {c['id']}) - {c['status']}{budget_str}\n" - return result - - -async def create_campaign( - client: "FacebookAdsClient", - ad_account_id: str, - name: str, - objective: str, - status: str = "PAUSED", - daily_budget: Optional[int] = None, - lifetime_budget: Optional[int] = None, - special_ad_categories: Optional[List[str]] = None, -) -> str: - if not ad_account_id: - return "ERROR: ad_account_id parameter required for create_campaign" - if not name: - return "ERROR: name parameter required for create_campaign" - try: - CampaignObjective(objective) - except ValueError: - valid = [o.value for o in CampaignObjective] - return f"ERROR: Invalid objective. Must be one of: {', '.join(valid)}" - if daily_budget: - try: - daily_budget = validate_budget(daily_budget) - except FacebookValidationError as e: - return f"ERROR: {e.message}" - if lifetime_budget: - try: - lifetime_budget = validate_budget(lifetime_budget) - except FacebookValidationError as e: - return f"ERROR: {e.message}" - if client.is_test_mode: - return _mock_create_campaign(name, objective, status, daily_budget) - payload: Dict[str, Any] = { - "name": name, - "objective": objective, - "status": status, - "special_ad_categories": special_ad_categories or [], - } - if daily_budget: - payload["daily_budget"] = daily_budget - if lifetime_budget: - payload["lifetime_budget"] = lifetime_budget - result = await client.request("POST", f"{ad_account_id}/campaigns", data=payload) - campaign_id = result.get("id") - if not campaign_id: - return f"Failed to create campaign. Response: {result}" - output = f"Campaign created: {name} (ID: {campaign_id})\n" - output += f" Status: {status}, Objective: {objective}\n" - if daily_budget: - output += f" Daily Budget: {format_currency(daily_budget)}\n" - if lifetime_budget: - output += f" Lifetime Budget: {format_currency(lifetime_budget)}\n" - return output - - -async def update_campaign( - client: "FacebookAdsClient", - campaign_id: str, - name: Optional[str] = None, - status: Optional[str] = None, - daily_budget: Optional[int] = None, - lifetime_budget: Optional[int] = None, -) -> str: - if not campaign_id: - return "ERROR: campaign_id parameter is required" - if not any([name, status, daily_budget, lifetime_budget]): - return "ERROR: At least one field to update is required (name, status, daily_budget, or lifetime_budget)" - if status and status not in ["ACTIVE", "PAUSED"]: - return "ERROR: status must be either 'ACTIVE' or 'PAUSED'" - if daily_budget: - try: - daily_budget = validate_budget(daily_budget) - except FacebookValidationError as e: - return f"ERROR: {e.message}" - if lifetime_budget: - try: - lifetime_budget = validate_budget(lifetime_budget) - except FacebookValidationError as e: - return f"ERROR: {e.message}" - updates = [] - if name: - updates.append(f"name -> {name}") - if status: - updates.append(f"status -> {status}") - if daily_budget: - updates.append(f"daily_budget -> {format_currency(daily_budget)}") - if lifetime_budget: - updates.append(f"lifetime_budget -> {format_currency(lifetime_budget)}") - if client.is_test_mode: - return f"Campaign {campaign_id} updated:\n" + "\n".join(f" - {u}" for u in updates) - data: Dict[str, Any] = {} - if name: - data["name"] = name - if status: - data["status"] = status - if daily_budget is not None: - data["daily_budget"] = daily_budget - if lifetime_budget is not None: - data["lifetime_budget"] = lifetime_budget - result = await client.request("POST", campaign_id, data=data) - if result.get("success"): - return f"Campaign {campaign_id} updated:\n" + "\n".join(f" - {u}" for u in updates) - else: - return f"Failed to update campaign. Response: {result}" - - -async def duplicate_campaign( - client: "FacebookAdsClient", - campaign_id: str, - new_name: str, - ad_account_id: Optional[str] = None, -) -> str: - if not campaign_id: - return "ERROR: campaign_id parameter is required" - if not new_name: - return "ERROR: new_name parameter is required" - if client.is_test_mode: - return _mock_duplicate_campaign(campaign_id, new_name) - original = await client.request( - "GET", campaign_id, - params={"fields": "name,objective,status,daily_budget,lifetime_budget,special_ad_categories"} - ) - account_id = ad_account_id or client.ad_account_id - if not account_id: - return "ERROR: ad_account_id required for duplicate_campaign" - create_data: Dict[str, Any] = { - "name": new_name, - "objective": original["objective"], - "status": "PAUSED", - "special_ad_categories": original.get("special_ad_categories", []), - } - if "daily_budget" in original: - create_data["daily_budget"] = original["daily_budget"] - if "lifetime_budget" in original: - create_data["lifetime_budget"] = original["lifetime_budget"] - new_campaign = await client.request("POST", f"{account_id}/campaigns", data=create_data) - return f"""Campaign duplicated successfully! -Original Campaign ID: {campaign_id} -New Campaign ID: {new_campaign.get('id')} -New Campaign Name: {new_name} -Status: PAUSED (activate when ready) -Note: Only the campaign was copied. To copy ad sets and ads, use the Facebook Ads Manager UI. -""" - - -async def archive_campaign(client: "FacebookAdsClient", campaign_id: str) -> str: - if not campaign_id: - return "ERROR: campaign_id parameter is required" - if client.is_test_mode: - return f"Campaign {campaign_id} archived successfully.\n\nThe campaign is now hidden from active views but can be restored if needed." - result = await client.request("POST", campaign_id, data={"status": "ARCHIVED"}) - if result.get("success"): - return f"Campaign {campaign_id} archived successfully.\n\nThe campaign is now hidden from active views but can be restored if needed." - else: - return f"Failed to archive campaign. Response: {result}" - - -async def bulk_update_campaigns(client: "FacebookAdsClient", campaigns: List[Dict[str, Any]]) -> str: - if not campaigns: - return "ERROR: campaigns parameter is required (list of {id, ...fields})" - if not isinstance(campaigns, list): - return "ERROR: campaigns must be a list" - if len(campaigns) > 50: - return "ERROR: Maximum 50 campaigns can be updated at once" - if client.is_test_mode: - results = [] - for camp in campaigns: - campaign_id = camp.get("id", "unknown") - status = camp.get("status", "unchanged") - results.append(f" {campaign_id} -> {status}") - return f"Bulk update completed for {len(campaigns)} campaigns:\n" + "\n".join(results) - results = [] - errors = [] - for camp in campaigns: - campaign_id = camp.get("id") - if not campaign_id: - errors.append("Missing campaign ID in one of the campaigns") - continue - data: Dict[str, Any] = {} - if "name" in camp: - data["name"] = camp["name"] - if "status" in camp: - if camp["status"] not in ["ACTIVE", "PAUSED", "ARCHIVED"]: - errors.append(f"{campaign_id}: Invalid status") - continue - data["status"] = camp["status"] - if "daily_budget" in camp: - try: - data["daily_budget"] = validate_budget(camp["daily_budget"]) - except FacebookValidationError as e: - errors.append(f"{campaign_id}: {e.message}") - continue - if not data: - errors.append(f"{campaign_id}: No fields to update") - continue - try: - result = await client.request("POST", campaign_id, data=data) - if result.get("success"): - updates = ", ".join([f"{k}={v}" for k, v in data.items()]) - results.append(f" {campaign_id}: {updates}") - else: - errors.append(f"{campaign_id}: Update failed") - except FacebookAPIError as e: - errors.append(f"{campaign_id}: {e.message}") - except Exception as e: - errors.append(f"{campaign_id}: {str(e)}") - output = f"Bulk update completed:\n\n" - output += f"Success: {len(results)}\n" - output += f"Errors: {len(errors)}\n\n" - if results: - output += "Successful updates:\n" + "\n".join(results) + "\n\n" - if errors: - output += "Errors:\n" + "\n".join(f" {e}" for e in errors) - return output - - -async def get_insights(client: "FacebookAdsClient", campaign_id: str, days: int = 30) -> str: - if not campaign_id: - return "ERROR: campaign_id required" - if client.is_test_mode: - return _mock_get_insights(campaign_id, days) - params = { - "fields": "impressions,clicks,spend,cpc,ctr,reach,frequency", - "date_preset": "last_30d" if days == 30 else "maximum", - } - data = await client.request("GET", f"{campaign_id}/insights", params=params) - if not data.get("data"): - return f"No insights data found for campaign {campaign_id}" - raw = data["data"][0] - insights = normalize_insights_data(raw) - return f"""Insights for Campaign {campaign_id} (Last {days} days): - Impressions: {insights.impressions:,} - Clicks: {insights.clicks:,} - Spend: ${insights.spend:.2f} - CTR: {insights.ctr:.2f}% - CPC: ${insights.cpc:.2f} - Reach: {insights.reach:,} - Frequency: {insights.frequency:.2f} -""" - - -def _mock_list_campaigns() -> str: - return """Found 2 campaigns: - Test Campaign 1 (ID: 123456789) - ACTIVE, Daily: 50.00 USD - Test Campaign 2 (ID: 987654321) - PAUSED, Daily: 100.00 USD -""" - - -def _mock_create_campaign(name: str, objective: str, status: str, daily_budget: Optional[int]) -> str: - budget_str = "" - if daily_budget: - budget_str = f"\n Daily Budget: {format_currency(daily_budget)}" - return f"""Campaign created: {name} (ID: mock_123456789) - Status: {status}, Objective: {objective}{budget_str} -""" - - -def _mock_duplicate_campaign(campaign_id: str, new_name: str) -> str: - return f"""Campaign duplicated successfully! -Original Campaign ID: {campaign_id} -New Campaign ID: {campaign_id}_copy -New Campaign Name: {new_name} -Status: PAUSED (activate when ready) -Note: Only the campaign was copied. To copy ad sets and ads, use the Facebook Ads Manager UI. -""" - - -def _mock_get_insights(campaign_id: str, days: int) -> str: - return f"""Insights for Campaign {campaign_id} (Last {days} days): - Impressions: 125,000 - Clicks: 3,450 - Spend: $500.00 - CTR: 2.76% - CPC: $0.14 - Reach: 95,000 - Frequency: 1.32 -""" diff --git a/flexus_client_kit/integrations/facebook/client.py b/flexus_client_kit/integrations/facebook/client.py deleted file mode 100644 index 8f5f5bbb..00000000 --- a/flexus_client_kit/integrations/facebook/client.py +++ /dev/null @@ -1,186 +0,0 @@ -from __future__ import annotations -import asyncio -import logging -import os -import time -from typing import Any, Dict, Optional, TYPE_CHECKING - -import httpx - -from flexus_client_kit.integrations.facebook.exceptions import ( - FacebookAPIError, - FacebookAuthError, - FacebookTimeoutError, - parse_api_error, -) -from flexus_client_kit.integrations.facebook.utils import validate_ad_account_id - -if TYPE_CHECKING: - from flexus_client_kit import ckit_client, ckit_bot_exec - -logger = logging.getLogger("facebook.client") - -API_BASE = "https://graph.facebook.com" -API_VERSION = "v19.0" -DEFAULT_TIMEOUT = 30.0 -MAX_RETRIES = 3 -INITIAL_RETRY_DELAY = 1.0 - - -class FacebookAdsClient: - def __init__( - self, - fclient: "ckit_client.FlexusClient", - rcx: "ckit_bot_exec.RobotContext", - ad_account_id: str = "", - ): - self.fclient = fclient - self.rcx = rcx - self._ad_account_id = "" - if ad_account_id: - self._ad_account_id = validate_ad_account_id(ad_account_id) - self._access_token: str = "" - self._headers: Dict[str, str] = {} - - @property - def ad_account_id(self) -> str: - return self._ad_account_id - - @ad_account_id.setter - def ad_account_id(self, value: str) -> None: - if value: - self._ad_account_id = validate_ad_account_id(value) - else: - self._ad_account_id = "" - - @property - def is_test_mode(self) -> bool: - return self.rcx.running_test_scenario - - @property - def access_token(self) -> str: - return self._access_token - - async def ensure_auth(self) -> Optional[str]: - try: - if self.is_test_mode: - return None - if not self._access_token: - self._access_token = await self._fetch_token() - self._headers = { - "Authorization": f"Bearer {self._access_token}", - "Content-Type": "application/json", - } - return None - except Exception as e: - logger.info(f"Failed to get Facebook token: {e}") - return await self._prompt_oauth_connection() - - async def request( - self, - method: str, - endpoint: str, - params: Optional[Dict[str, Any]] = None, - data: Optional[Dict[str, Any]] = None, - form_data: Optional[Dict[str, Any]] = None, - timeout: float = DEFAULT_TIMEOUT, - ) -> Dict[str, Any]: - auth_error = await self.ensure_auth() - if auth_error: - raise FacebookAuthError(auth_error) - url = f"{API_BASE}/{API_VERSION}/{endpoint}" - - async def make_request() -> Dict[str, Any]: - async with httpx.AsyncClient() as client: - if method == "GET": - response = await client.get(url, params=params, headers=self._headers, timeout=timeout) - elif method == "POST": - if form_data: - response = await client.post(url, data=form_data, timeout=timeout) - else: - response = await client.post(url, json=data, headers=self._headers, timeout=timeout) - elif method == "DELETE": - response = await client.delete(url, json=data, headers=self._headers, timeout=timeout) - else: - raise ValueError(f"Unsupported method: {method}") - return response.json() - - return await self._retry_with_backoff(make_request) - - async def _retry_with_backoff( - self, - func, - max_retries: int = MAX_RETRIES, - initial_delay: float = INITIAL_RETRY_DELAY, - ) -> Dict[str, Any]: - last_exception = None - for attempt in range(max_retries): - try: - return await func() - except (httpx.HTTPError, httpx.TimeoutException) as e: - last_exception = e - if attempt == max_retries - 1: - raise FacebookTimeoutError(DEFAULT_TIMEOUT) - delay = initial_delay * (2 ** attempt) - logger.warning(f"Retry {attempt + 1}/{max_retries} after {delay}s due to: {e}") - await asyncio.sleep(delay) - except FacebookAPIError as e: - if e.is_rate_limit: - last_exception = e - if attempt == max_retries - 1: - raise - delay = initial_delay * (2 ** attempt) * 2 - logger.warning(f"Rate limit hit, retry {attempt + 1}/{max_retries} after {delay}s") - await asyncio.sleep(delay) - else: - raise - if last_exception: - raise last_exception - raise FacebookAPIError(500, "Unexpected retry loop exit") - - - async def _fetch_token(self) -> str: - facebook_auth = self.rcx.external_auth.get("facebook") or {} - token_obj = facebook_auth.get("token") or {} - access_token = token_obj.get("access_token", "") - if not access_token: - raise ValueError("No Facebook OAuth connection found") - logger.info("Facebook token retrieved for %s", self.rcx.persona.owner_fuser_id) - return access_token - - - async def _prompt_oauth_connection(self) -> str: - from flexus_client_kit import ckit_client - http = await self.fclient.use_http() - async with http as h: - result = await h.execute( - ckit_client.gql.gql(""" - query GetFacebookToken($fuser_id: String!, $ws_id: String!, $provider: String!, $scopes: [String!]!) { - external_auth_token( - fuser_id: $fuser_id - ws_id: $ws_id - provider: $provider - scopes: $scopes - ) - } - """), - variable_values={ - "fuser_id": self.rcx.persona.owner_fuser_id, - "ws_id": self.rcx.persona.ws_id, - "provider": "facebook", - "scopes": ["ads_management", "ads_read", "business_management", "pages_manage_ads"], - }, - ), - auth_url = result.get("external_auth_token", "") - return f"""Facebook authorization required. - -Click this link to connect your Facebook account: - -{auth_url} - -After authorizing, return here and try your request again. - -Requirements: -- Facebook Business Manager account -- Access to an Ad Account (starts with act_...) -""" diff --git a/flexus_client_kit/integrations/facebook/exceptions.py b/flexus_client_kit/integrations/facebook/exceptions.py deleted file mode 100644 index e8ba2c62..00000000 --- a/flexus_client_kit/integrations/facebook/exceptions.py +++ /dev/null @@ -1,121 +0,0 @@ -from __future__ import annotations -import logging -from typing import Optional - -import httpx - -logger = logging.getLogger("facebook.exceptions") - - -class FacebookError(Exception): - def __init__(self, message: str, details: Optional[str] = None): - self.message = message - self.details = details - super().__init__(message) - - def __str__(self) -> str: - if self.details: - return f"{self.message}\n{self.details}" - return self.message - - -class FacebookAPIError(FacebookError): - CODE_INVALID_PARAMS = 100 - CODE_AUTH_EXPIRED = 190 - CODE_RATE_LIMIT_1 = 4 - CODE_RATE_LIMIT_2 = 17 - CODE_RATE_LIMIT_3 = 32 - CODE_INSUFFICIENT_PERMISSIONS = 80004 - CODE_AD_ACCOUNT_DISABLED = 2635 - CODE_BUDGET_TOO_LOW = 1487387 - RATE_LIMIT_CODES = {CODE_RATE_LIMIT_1, CODE_RATE_LIMIT_2, CODE_RATE_LIMIT_3} - - def __init__( - self, - code: int, - message: str, - error_type: str = "", - user_title: Optional[str] = None, - user_msg: Optional[str] = None, - fbtrace_id: Optional[str] = None, - ): - self.code = code - self.error_type = error_type - self.user_title = user_title - self.user_msg = user_msg - self.fbtrace_id = fbtrace_id - details_parts = [] - if user_title: - details_parts.append(f"**{user_title}**") - if user_msg: - details_parts.append(user_msg) - if not details_parts: - details_parts.append(message) - details = "\n".join(details_parts) - super().__init__(message, details) - - @property - def is_rate_limit(self) -> bool: - return self.code in self.RATE_LIMIT_CODES - - @property - def is_auth_error(self) -> bool: - return self.code == self.CODE_AUTH_EXPIRED - - def format_for_user(self) -> str: - if self.code == self.CODE_AUTH_EXPIRED: - return f"Authentication failed. Please reconnect Facebook.\n{self.details}" - elif self.is_rate_limit: - return f"Rate limit reached. Please try again in a few minutes.\n{self.details}" - elif self.code == self.CODE_INVALID_PARAMS: - return f"Invalid parameters (code {self.code}):\n{self.details}" - elif self.code == self.CODE_AD_ACCOUNT_DISABLED: - return f"Ad account is disabled.\n{self.details}" - elif self.code == self.CODE_BUDGET_TOO_LOW: - return f"Budget too low:\n{self.details}" - elif self.code == self.CODE_INSUFFICIENT_PERMISSIONS: - return f"Insufficient permissions.\n{self.details}" - else: - return f"Facebook API Error ({self.code}):\n{self.details}" - - -class FacebookAuthError(FacebookError): - def __init__(self, message: str = "Facebook authentication required"): - super().__init__(message) - - -class FacebookValidationError(FacebookError): - def __init__(self, field: str, message: str): - self.field = field - super().__init__(f"Validation error for '{field}': {message}") - - -class FacebookTimeoutError(FacebookError): - def __init__(self, timeout: float): - super().__init__(f"Request timed out after {timeout} seconds") - - -async def parse_api_error(response: httpx.Response) -> FacebookAPIError: - try: - error_data = response.json() - if "error" in error_data: - err = error_data["error"] - return FacebookAPIError( - code=err.get("code", response.status_code), - message=err.get("message", "Unknown error"), - error_type=err.get("type", ""), - user_title=err.get("error_user_title"), - user_msg=err.get("error_user_msg"), - fbtrace_id=err.get("fbtrace_id"), - ) - else: - return FacebookAPIError( - code=response.status_code, - message=f"HTTP {response.status_code}: {response.text[:500]}", - ) - except Exception as e: - logger.warning(f"Error parsing FB API error response: {e}") - return FacebookAPIError( - code=response.status_code, - message=f"HTTP {response.status_code}: {response.text[:500]}", - ) diff --git a/flexus_client_kit/integrations/facebook/models.py b/flexus_client_kit/integrations/facebook/models.py deleted file mode 100644 index 5b883fed..00000000 --- a/flexus_client_kit/integrations/facebook/models.py +++ /dev/null @@ -1,259 +0,0 @@ -from __future__ import annotations -from datetime import datetime -from enum import Enum -from typing import Any, Dict, List, Optional - -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator - - -class CampaignObjective(str, Enum): - TRAFFIC = "OUTCOME_TRAFFIC" - SALES = "OUTCOME_SALES" - ENGAGEMENT = "OUTCOME_ENGAGEMENT" - AWARENESS = "OUTCOME_AWARENESS" - LEADS = "OUTCOME_LEADS" - APP_PROMOTION = "OUTCOME_APP_PROMOTION" - - -class CampaignStatus(str, Enum): - ACTIVE = "ACTIVE" - PAUSED = "PAUSED" - ARCHIVED = "ARCHIVED" - - -class AccountStatus(int, Enum): - ACTIVE = 1 - DISABLED = 2 - UNSETTLED = 3 - PENDING_RISK_REVIEW = 7 - PENDING_SETTLEMENT = 8 - IN_GRACE_PERIOD = 9 - PENDING_CLOSURE = 100 - CLOSED = 101 - TEMPORARILY_UNAVAILABLE = 201 - - -class OptimizationGoal(str, Enum): - LINK_CLICKS = "LINK_CLICKS" - LANDING_PAGE_VIEWS = "LANDING_PAGE_VIEWS" - IMPRESSIONS = "IMPRESSIONS" - REACH = "REACH" - CONVERSIONS = "CONVERSIONS" - VALUE = "VALUE" - LEAD_GENERATION = "LEAD_GENERATION" - APP_INSTALLS = "APP_INSTALLS" - OFFSITE_CONVERSIONS = "OFFSITE_CONVERSIONS" - POST_ENGAGEMENT = "POST_ENGAGEMENT" - VIDEO_VIEWS = "VIDEO_VIEWS" - THRUPLAY = "THRUPLAY" - - -class BillingEvent(str, Enum): - IMPRESSIONS = "IMPRESSIONS" - LINK_CLICKS = "LINK_CLICKS" - APP_INSTALLS = "APP_INSTALLS" - THRUPLAY = "THRUPLAY" - - -class BidStrategy(str, Enum): - LOWEST_COST_WITHOUT_CAP = "LOWEST_COST_WITHOUT_CAP" - LOWEST_COST_WITH_BID_CAP = "LOWEST_COST_WITH_BID_CAP" - COST_CAP = "COST_CAP" - - -class CallToActionType(str, Enum): - LEARN_MORE = "LEARN_MORE" - SHOP_NOW = "SHOP_NOW" - SIGN_UP = "SIGN_UP" - BOOK_NOW = "BOOK_NOW" - DOWNLOAD = "DOWNLOAD" - GET_OFFER = "GET_OFFER" - GET_QUOTE = "GET_QUOTE" - CONTACT_US = "CONTACT_US" - SUBSCRIBE = "SUBSCRIBE" - APPLY_NOW = "APPLY_NOW" - BUY_NOW = "BUY_NOW" - WATCH_MORE = "WATCH_MORE" - - -class AdFormat(str, Enum): - DESKTOP_FEED_STANDARD = "DESKTOP_FEED_STANDARD" - MOBILE_FEED_STANDARD = "MOBILE_FEED_STANDARD" - INSTAGRAM_STANDARD = "INSTAGRAM_STANDARD" - INSTAGRAM_STORY = "INSTAGRAM_STORY" - MOBILE_BANNER = "MOBILE_BANNER" - MOBILE_INTERSTITIAL = "MOBILE_INTERSTITIAL" - MOBILE_NATIVE = "MOBILE_NATIVE" - RIGHT_COLUMN_STANDARD = "RIGHT_COLUMN_STANDARD" - - -class GeoLocation(BaseModel): - countries: List[str] = Field(default_factory=list) - regions: List[Dict[str, Any]] = Field(default_factory=list) - cities: List[Dict[str, Any]] = Field(default_factory=list) - zips: List[Dict[str, Any]] = Field(default_factory=list) - location_types: List[str] = Field(default_factory=lambda: ["home", "recent"]) - model_config = ConfigDict(extra="allow") - - -class TargetingSpec(BaseModel): - geo_locations: GeoLocation - age_min: int = Field(default=18, ge=13, le=65) - age_max: int = Field(default=65, ge=13, le=65) - genders: List[int] = Field(default_factory=list) - interests: List[Dict[str, Any]] = Field(default_factory=list) - behaviors: List[Dict[str, Any]] = Field(default_factory=list) - custom_audiences: List[Dict[str, Any]] = Field(default_factory=list) - excluded_custom_audiences: List[Dict[str, Any]] = Field(default_factory=list) - locales: List[int] = Field(default_factory=list) - publisher_platforms: List[str] = Field(default_factory=list) - device_platforms: List[str] = Field(default_factory=list) - model_config = ConfigDict(extra="allow") - - @model_validator(mode="after") - def validate_geo_locations(self) -> "TargetingSpec": - geo = self.geo_locations - if not geo.countries and not geo.regions and not geo.cities: - raise ValueError("At least one geo_location (country, region, or city) is required") - return self - - @model_validator(mode="after") - def validate_age_range(self) -> "TargetingSpec": - if self.age_min > self.age_max: - raise ValueError("age_min cannot be greater than age_max") - return self - - -class AdAccount(BaseModel): - id: str - account_id: Optional[str] = None - name: str - currency: str = "USD" - timezone_name: str = "America/Los_Angeles" - account_status: AccountStatus = AccountStatus.ACTIVE - balance: int = 0 - amount_spent: int = 0 - spend_cap: int = 0 - min_daily_budget: Optional[int] = None - business: Optional[Dict[str, Any]] = None - created_time: Optional[datetime] = None - model_config = ConfigDict(extra="allow") - - @property - def is_active(self) -> bool: - return self.account_status == AccountStatus.ACTIVE - - @property - def remaining_budget(self) -> int: - if self.spend_cap <= 0: - return 0 - return max(0, self.spend_cap - self.amount_spent) - - -class Campaign(BaseModel): - id: Optional[str] = None - name: str = Field(..., min_length=1, max_length=400) - objective: CampaignObjective - status: CampaignStatus = CampaignStatus.PAUSED - daily_budget: Optional[int] = Field(None, ge=100) - lifetime_budget: Optional[int] = Field(None, ge=100) - special_ad_categories: List[str] = Field(default_factory=list) - created_time: Optional[datetime] = None - updated_time: Optional[datetime] = None - model_config = ConfigDict(extra="allow") - - @field_validator("daily_budget", "lifetime_budget", mode="before") - @classmethod - def coerce_budget(cls, v): - if v is None: - return None - return int(v) - - -class AdSet(BaseModel): - id: Optional[str] = None - campaign_id: str - name: str = Field(..., min_length=1, max_length=400) - status: CampaignStatus = CampaignStatus.PAUSED - optimization_goal: OptimizationGoal = OptimizationGoal.LINK_CLICKS - billing_event: BillingEvent = BillingEvent.IMPRESSIONS - bid_strategy: BidStrategy = BidStrategy.LOWEST_COST_WITHOUT_CAP - bid_amount: Optional[int] = None - daily_budget: Optional[int] = Field(None, ge=100) - lifetime_budget: Optional[int] = Field(None, ge=100) - targeting: TargetingSpec - start_time: Optional[datetime] = None - end_time: Optional[datetime] = None - promoted_object: Optional[Dict[str, Any]] = None - model_config = ConfigDict(extra="allow") - - @field_validator("daily_budget", "lifetime_budget", "bid_amount", mode="before") - @classmethod - def coerce_budget(cls, v): - if v is None: - return None - return int(v) - - -class Creative(BaseModel): - id: Optional[str] = None - name: str = Field(..., min_length=1, max_length=400) - page_id: str - image_hash: Optional[str] = None - image_url: Optional[str] = None - link: str - message: Optional[str] = None - headline: Optional[str] = None - description: Optional[str] = None - call_to_action_type: CallToActionType = CallToActionType.LEARN_MORE - model_config = ConfigDict(extra="allow") - - @model_validator(mode="after") - def validate_image(self) -> "Creative": - if not self.image_hash and not self.image_url: - raise ValueError("Either image_hash or image_url is required") - return self - - -class Ad(BaseModel): - id: Optional[str] = None - name: str = Field(..., min_length=1, max_length=400) - adset_id: str - creative_id: str - status: CampaignStatus = CampaignStatus.PAUSED - model_config = ConfigDict(extra="allow") - - -class ActionBreakdown(BaseModel): - action_type: str - value: int - - @field_validator("value", mode="before") - @classmethod - def coerce_value(cls, v): - return int(v) - - -class Insights(BaseModel): - impressions: int = 0 - clicks: int = 0 - spend: float = 0.0 - reach: int = 0 - frequency: float = 0.0 - ctr: float = 0.0 - cpc: float = 0.0 - cpm: float = 0.0 - actions: List[ActionBreakdown] = Field(default_factory=list) - date_start: Optional[str] = None - date_stop: Optional[str] = None - model_config = ConfigDict(extra="allow") - - @field_validator("impressions", "clicks", "reach", mode="before") - @classmethod - def coerce_int(cls, v): - return int(v) if v else 0 - - @field_validator("spend", "frequency", "ctr", "cpc", "cpm", mode="before") - @classmethod - def coerce_float(cls, v): - return float(v) if v else 0.0 diff --git a/flexus_client_kit/integrations/facebook/utils.py b/flexus_client_kit/integrations/facebook/utils.py deleted file mode 100644 index fa9c3e54..00000000 --- a/flexus_client_kit/integrations/facebook/utils.py +++ /dev/null @@ -1,140 +0,0 @@ -from __future__ import annotations -import hashlib -import logging -from typing import Any, Dict, List, Optional, Tuple - -from flexus_client_kit.integrations.facebook.exceptions import FacebookValidationError -from flexus_client_kit.integrations.facebook.models import Insights - -logger = logging.getLogger("facebook.utils") - - -def validate_ad_account_id(ad_account_id: str) -> str: - if not ad_account_id: - raise FacebookValidationError("ad_account_id", "is required") - ad_account_id = str(ad_account_id).strip() - if not ad_account_id: - raise FacebookValidationError("ad_account_id", "cannot be empty") - if not ad_account_id.startswith("act_"): - return f"act_{ad_account_id}" - return ad_account_id - - -def validate_budget(budget: int, min_budget: int = 100, currency: str = "USD") -> int: - if not isinstance(budget, int): - try: - budget = int(budget) - except (TypeError, ValueError): - raise FacebookValidationError("budget", "must be an integer (cents)") - if budget < min_budget: - raise FacebookValidationError("budget", f"must be at least {format_currency(min_budget, currency)}") - return budget - - -def validate_targeting_spec(spec: Dict[str, Any]) -> Tuple[bool, str]: - try: - if not spec: - return False, "Targeting spec cannot be empty" - if "geo_locations" not in spec: - return False, "geo_locations is required in targeting" - geo = spec["geo_locations"] - if not isinstance(geo, dict): - return False, "geo_locations must be a dictionary" - if not geo.get("countries") and not geo.get("regions") and not geo.get("cities"): - return False, "At least one geo_location (country, region, or city) is required" - if "age_min" in spec: - age_min = spec["age_min"] - if not isinstance(age_min, int) or age_min < 13 or age_min > 65: - return False, "age_min must be between 13 and 65" - if "age_max" in spec: - age_max = spec["age_max"] - if not isinstance(age_max, int) or age_max < 13 or age_max > 65: - return False, "age_max must be between 13 and 65" - if "age_min" in spec and "age_max" in spec: - if spec["age_min"] > spec["age_max"]: - return False, "age_min cannot be greater than age_max" - return True, "" - except Exception as e: - return False, f"Validation error: {str(e)}" - - -def format_currency(cents: int, currency: str = "USD") -> str: - return f"{cents / 100:.2f} {currency}" - - -def format_account_status(status_code: int) -> str: - status_map = { - 1: "Active", - 2: "Disabled", - 3: "Unsettled", - 7: "Pending Risk Review", - 8: "Pending Settlement", - 9: "In Grace Period", - 100: "Pending Closure", - 101: "Closed", - 201: "Temporarily Unavailable", - } - return status_map.get(status_code, f"Unknown ({status_code})") - - -def normalize_insights_data(raw_data: Dict[str, Any]) -> Insights: - try: - impressions = int(raw_data.get("impressions", 0)) - clicks = int(raw_data.get("clicks", 0)) - spend = float(raw_data.get("spend", 0.0)) - reach = int(raw_data.get("reach", 0)) - frequency = float(raw_data.get("frequency", 0.0)) - ctr = raw_data.get("ctr") - if ctr: - ctr = float(ctr) - elif impressions > 0: - ctr = (clicks / impressions) * 100 - else: - ctr = 0.0 - cpc = raw_data.get("cpc") - if cpc: - cpc = float(cpc) - elif clicks > 0: - cpc = spend / clicks - else: - cpc = 0.0 - cpm = raw_data.get("cpm") - if cpm: - cpm = float(cpm) - elif impressions > 0: - cpm = (spend / impressions) * 1000 - else: - cpm = 0.0 - actions = [] - raw_actions = raw_data.get("actions", []) - if isinstance(raw_actions, list): - for action in raw_actions: - actions.append({ - "action_type": action.get("action_type", "unknown"), - "value": int(action.get("value", 0)), - }) - return Insights( - impressions=impressions, - clicks=clicks, - spend=spend, - reach=reach, - frequency=frequency, - ctr=ctr, - cpc=cpc, - cpm=cpm, - actions=actions, - date_start=raw_data.get("date_start"), - date_stop=raw_data.get("date_stop"), - ) - except Exception as e: - logger.warning(f"Error normalizing insights data: {e}", exc_info=e) - return Insights() - - -def hash_for_audience(value: str, field_type: str) -> str: - value = value.strip().lower() - if field_type == "PHONE": - value = ''.join(c for c in value if c.isdigit()) - elif field_type in ["FN", "LN", "CT", "ST"]: - value = value.replace(" ", "") - return hashlib.sha256(value.encode()).hexdigest() diff --git a/flexus_client_kit/integrations/fi_meta_marketing_manage.py b/flexus_client_kit/integrations/fi_meta_marketing_manage.py index c961d90f..694f9878 100644 --- a/flexus_client_kit/integrations/fi_meta_marketing_manage.py +++ b/flexus_client_kit/integrations/fi_meta_marketing_manage.py @@ -1,200 +1,987 @@ from __future__ import annotations +import json import logging -from typing import Any, Dict, Optional, TYPE_CHECKING +from pathlib import Path +from typing import Any, Dict, List, Optional, TYPE_CHECKING + +import httpx from flexus_client_kit import ckit_cloudtool -from flexus_client_kit.integrations.facebook.client import FacebookAdsClient -from flexus_client_kit.integrations.facebook.exceptions import ( +from flexus_client_kit.integrations._fi_meta_helpers import ( + FacebookAdsClient, FacebookAPIError, FacebookAuthError, FacebookValidationError, -) -from flexus_client_kit.integrations.facebook.campaigns import ( - list_campaigns, create_campaign, update_campaign, duplicate_campaign, - archive_campaign, bulk_update_campaigns, get_campaign, delete_campaign, -) -from flexus_client_kit.integrations.facebook.adsets import ( - list_adsets, create_adset, update_adset, validate_targeting, - get_adset, delete_adset, list_adsets_for_account, -) -from flexus_client_kit.integrations.facebook.ads import ( - upload_image, create_creative, create_ad, preview_ad, - list_ads, get_ad, update_ad, delete_ad, - list_creatives, get_creative, update_creative, delete_creative, preview_creative, - upload_video, list_videos, -) -from flexus_client_kit.integrations.facebook.audiences import ( - list_custom_audiences, create_custom_audience, create_lookalike_audience, - get_custom_audience, update_custom_audience, delete_custom_audience, add_users_to_audience, -) -from flexus_client_kit.integrations.facebook.pixels import list_pixels, create_pixel, get_pixel_stats -from flexus_client_kit.integrations.facebook.targeting import ( - search_interests, search_behaviors, get_reach_estimate, get_delivery_estimate, -) -from flexus_client_kit.integrations.facebook.rules import ( - list_ad_rules, create_ad_rule, update_ad_rule, delete_ad_rule, execute_ad_rule, + FacebookTimeoutError, + CampaignObjective, + CallToActionType, + AdFormat, + CustomAudienceSubtype, + InsightsDatePreset, + API_BASE, + API_VERSION, + format_currency, + validate_budget, + validate_targeting_spec, + validate_ad_account_id, ) if TYPE_CHECKING: - from flexus_client_kit import ckit_client, ckit_bot_exec + from flexus_client_kit import ckit_bot_exec logger = logging.getLogger("meta_marketing_manage") # Use case: "Create & manage ads with Marketing API" -# Covers campaigns, ad sets, ads, creatives, audiences, pixels, targeting, rules. PROVIDER_NAME = "meta_marketing_manage" _HELP = """meta_marketing_manage: Create & manage ads with Meta Marketing API. op=help | status | list_methods | call(args={method_id, ...}) -Campaign management: - list_campaigns(ad_account_id, status?) - get_campaign(campaign_id) - create_campaign(ad_account_id, name, objective, daily_budget?, lifetime_budget?, status?) - update_campaign(campaign_id, name?, status?, daily_budget?, lifetime_budget?) - delete_campaign(campaign_id) - duplicate_campaign(campaign_id, new_name, ad_account_id?) - archive_campaign(campaign_id) - bulk_update_campaigns(campaigns) - -Ad set management: - list_adsets(campaign_id) - list_adsets_for_account(ad_account_id, status_filter?) - get_adset(adset_id) - create_adset(ad_account_id, campaign_id, name, targeting, optimization_goal?, billing_event?, status?) - update_adset(adset_id, name?, status?, daily_budget?, targeting?) - delete_adset(adset_id) - validate_targeting(targeting_spec, ad_account_id?) - -Ad & creative management: - list_ads(ad_account_id?, adset_id?, status_filter?) - get_ad(ad_id) - create_ad(ad_account_id, adset_id, creative_id, name?, status?) - update_ad(ad_id, name?, status?) - delete_ad(ad_id) - preview_ad(ad_id, ad_format?) - list_creatives(ad_account_id) - get_creative(creative_id) - create_creative(ad_account_id, name, page_id, message?, link?, image_hash?, video_id?, call_to_action_type?) - update_creative(creative_id, name?) - delete_creative(creative_id) - preview_creative(creative_id, ad_format?) - upload_image(image_path?, image_url?, ad_account_id?) - upload_video(video_url, ad_account_id?, title?, description?) - list_videos(ad_account_id) - -Audiences: - list_custom_audiences(ad_account_id) - get_custom_audience(audience_id) - create_custom_audience(ad_account_id, name, subtype?, description?, customer_file_source?) - create_lookalike_audience(ad_account_id, origin_audience_id, country, ratio?, name?) - update_custom_audience(audience_id, name?, description?) - delete_custom_audience(audience_id) - add_users_to_audience(audience_id, emails, phones?) - -Pixels: - list_pixels(ad_account_id) - create_pixel(ad_account_id, name) - get_pixel_stats(pixel_id, start_time?, end_time?, aggregation?) - -Targeting research: - search_interests(q, limit?) - search_behaviors(q, limit?) - get_reach_estimate(ad_account_id, targeting, optimization_goal?) - get_delivery_estimate(ad_account_id, targeting, optimization_goal?, bid_amount?) - -Automation rules: - list_ad_rules(ad_account_id) - create_ad_rule(ad_account_id, name, evaluation_spec, execution_spec, schedule_spec?, status?) - update_ad_rule(rule_id, name?, status?) - delete_ad_rule(rule_id) - execute_ad_rule(rule_id) +Campaigns: list_campaigns, get_campaign, create_campaign, update_campaign, + delete_campaign, duplicate_campaign, archive_campaign, bulk_update_campaigns +Ad Sets: list_adsets, list_adsets_for_account, get_adset, create_adset, + update_adset, delete_adset, validate_targeting +Ads: list_ads, get_ad, create_ad, update_ad, delete_ad, preview_ad +Creatives: list_creatives, get_creative, create_creative, update_creative, + delete_creative, preview_creative, upload_image, upload_video, list_videos +Audiences: list_custom_audiences, get_custom_audience, create_custom_audience, + create_lookalike_audience, update_custom_audience, delete_custom_audience, + add_users_to_audience +Pixels: list_pixels, create_pixel, get_pixel_stats +Targeting: search_interests, search_behaviors, get_reach_estimate, get_delivery_estimate +Rules: list_ad_rules, create_ad_rule, update_ad_rule, delete_ad_rule, execute_ad_rule """ -# Maps op string -> lambda(client, args) for the generic dispatch table. +# ── Campaigns ───────────────────────────────────────────────────────────────── + +_CAMPAIGN_FIELDS = "id,name,status,objective,daily_budget,lifetime_budget" + + +async def _list_campaigns(client: FacebookAdsClient, ad_account_id: Optional[str], status_filter: Optional[str]) -> str: + account_id = ad_account_id or client.ad_account_id + if not account_id: + return "ERROR: ad_account_id required for list_campaigns" + if client.is_test_mode: + return "Found 2 campaigns:\n Test Campaign 1 (ID: 123456789) - ACTIVE, Daily: 50.00 USD\n Test Campaign 2 (ID: 987654321) - PAUSED, Daily: 100.00 USD\n" + params: Dict[str, Any] = {"fields": _CAMPAIGN_FIELDS, "limit": 50} + if status_filter: + params["effective_status"] = f"['{status_filter}']" + data = await client.request("GET", f"{account_id}/campaigns", params=params) + campaigns = data.get("data", []) + if not campaigns: + return "No campaigns found." + result = f"Found {len(campaigns)} campaign{'s' if len(campaigns) != 1 else ''}:\n" + for c in campaigns: + budget_str = "" + if c.get("daily_budget"): + budget_str = f", Daily: {format_currency(int(c['daily_budget']))}" + elif c.get("lifetime_budget"): + budget_str = f", Lifetime: {format_currency(int(c['lifetime_budget']))}" + result += f" {c['name']} (ID: {c['id']}) - {c['status']}{budget_str}\n" + return result + + +async def _get_campaign(client: FacebookAdsClient, campaign_id: str) -> str: + if not campaign_id: + return "ERROR: campaign_id is required" + if client.is_test_mode: + return f"Campaign {campaign_id}:\n Name: Test Campaign\n Status: ACTIVE\n Objective: OUTCOME_TRAFFIC\n Daily Budget: 50.00 USD\n" + data = await client.request("GET", campaign_id, params={"fields": "id,name,status,objective,daily_budget,lifetime_budget,special_ad_categories,created_time,updated_time,start_time,stop_time,budget_remaining"}) + result = f"Campaign {campaign_id}:\n" + result += f" Name: {data.get('name', 'N/A')}\n" + result += f" Status: {data.get('status', 'N/A')}\n" + result += f" Objective: {data.get('objective', 'N/A')}\n" + if data.get("daily_budget"): + result += f" Daily Budget: {format_currency(int(data['daily_budget']))}\n" + if data.get("lifetime_budget"): + result += f" Lifetime Budget: {format_currency(int(data['lifetime_budget']))}\n" + if data.get("budget_remaining"): + result += f" Budget Remaining: {format_currency(int(data['budget_remaining']))}\n" + result += f" Created: {data.get('created_time', 'N/A')}\n" + return result + + +async def _create_campaign(client: FacebookAdsClient, ad_account_id: str, name: str, objective: str, daily_budget: Optional[int], lifetime_budget: Optional[int], status: str) -> str: + if not ad_account_id: + return "ERROR: ad_account_id required" + if not name: + return "ERROR: name required" + try: + CampaignObjective(objective) + except ValueError: + return f"ERROR: Invalid objective. Must be one of: {', '.join(o.value for o in CampaignObjective)}" + if daily_budget: + try: + daily_budget = validate_budget(daily_budget) + except FacebookValidationError as e: + return f"ERROR: {e.message}" + if lifetime_budget: + try: + lifetime_budget = validate_budget(lifetime_budget) + except FacebookValidationError as e: + return f"ERROR: {e.message}" + if client.is_test_mode: + budget_str = f"\n Daily Budget: {format_currency(daily_budget)}" if daily_budget else "" + return f"Campaign created: {name} (ID: mock_123456789)\n Status: {status}, Objective: {objective}{budget_str}\n" + payload: Dict[str, Any] = {"name": name, "objective": objective, "status": status, "special_ad_categories": []} + if daily_budget: + payload["daily_budget"] = daily_budget + if lifetime_budget: + payload["lifetime_budget"] = lifetime_budget + result = await client.request("POST", f"{ad_account_id}/campaigns", data=payload) + campaign_id = result.get("id") + if not campaign_id: + return f"Failed to create campaign. Response: {result}" + output = f"Campaign created: {name} (ID: {campaign_id})\n Status: {status}, Objective: {objective}\n" + if daily_budget: + output += f" Daily Budget: {format_currency(daily_budget)}\n" + return output + + +async def _update_campaign(client: FacebookAdsClient, campaign_id: str, name: Optional[str], status: Optional[str], daily_budget: Optional[int], lifetime_budget: Optional[int]) -> str: + if not campaign_id: + return "ERROR: campaign_id required" + if not any([name, status, daily_budget, lifetime_budget]): + return "ERROR: At least one field to update required" + if status and status not in ["ACTIVE", "PAUSED"]: + return "ERROR: status must be ACTIVE or PAUSED" + if daily_budget: + try: + daily_budget = validate_budget(daily_budget) + except FacebookValidationError as e: + return f"ERROR: {e.message}" + updates = [f"name -> {name}"] if name else [] + if status: + updates.append(f"status -> {status}") + if daily_budget: + updates.append(f"daily_budget -> {format_currency(daily_budget)}") + if client.is_test_mode: + return f"Campaign {campaign_id} updated:\n" + "\n".join(f" - {u}" for u in updates) + data: Dict[str, Any] = {} + if name: + data["name"] = name + if status: + data["status"] = status + if daily_budget is not None: + data["daily_budget"] = daily_budget + if lifetime_budget is not None: + data["lifetime_budget"] = lifetime_budget + result = await client.request("POST", campaign_id, data=data) + if result.get("success"): + return f"Campaign {campaign_id} updated:\n" + "\n".join(f" - {u}" for u in updates) + return f"Failed to update campaign. Response: {result}" + + +async def _delete_campaign(client: FacebookAdsClient, campaign_id: str) -> str: + if not campaign_id: + return "ERROR: campaign_id required" + if client.is_test_mode: + return f"Campaign {campaign_id} deleted successfully." + result = await client.request("DELETE", campaign_id) + return f"Campaign {campaign_id} deleted successfully." if result.get("success") else f"Failed to delete campaign. Response: {result}" + + +async def _duplicate_campaign(client: FacebookAdsClient, campaign_id: str, new_name: str, ad_account_id: Optional[str]) -> str: + if not campaign_id or not new_name: + return "ERROR: campaign_id and new_name required" + if client.is_test_mode: + return f"Campaign duplicated!\nOriginal: {campaign_id}\nNew ID: {campaign_id}_copy\nNew Name: {new_name}\nStatus: PAUSED\n" + original = await client.request("GET", campaign_id, params={"fields": "name,objective,status,daily_budget,lifetime_budget,special_ad_categories"}) + account_id = ad_account_id or client.ad_account_id + if not account_id: + return "ERROR: ad_account_id required for duplicate_campaign" + create_data: Dict[str, Any] = {"name": new_name, "objective": original["objective"], "status": "PAUSED", "special_ad_categories": original.get("special_ad_categories", [])} + if "daily_budget" in original: + create_data["daily_budget"] = original["daily_budget"] + new_campaign = await client.request("POST", f"{account_id}/campaigns", data=create_data) + return f"Campaign duplicated!\nOriginal: {campaign_id}\nNew ID: {new_campaign.get('id')}\nNew Name: {new_name}\nStatus: PAUSED\n" + + +async def _archive_campaign(client: FacebookAdsClient, campaign_id: str) -> str: + if not campaign_id: + return "ERROR: campaign_id required" + if client.is_test_mode: + return f"Campaign {campaign_id} archived successfully." + result = await client.request("POST", campaign_id, data={"status": "ARCHIVED"}) + return f"Campaign {campaign_id} archived successfully." if result.get("success") else f"Failed to archive campaign. Response: {result}" + + +async def _bulk_update_campaigns(client: FacebookAdsClient, campaigns: List[Dict[str, Any]]) -> str: + if not campaigns or not isinstance(campaigns, list): + return "ERROR: campaigns must be a non-empty list" + if len(campaigns) > 50: + return "ERROR: Maximum 50 campaigns at once" + if client.is_test_mode: + results = [f" {c.get('id', 'unknown')} -> {c.get('status', 'unchanged')}" for c in campaigns] + return f"Bulk update completed for {len(campaigns)} campaigns:\n" + "\n".join(results) + results, errors = [], [] + for camp in campaigns: + campaign_id = camp.get("id") + if not campaign_id: + errors.append("Missing campaign ID") + continue + data: Dict[str, Any] = {} + if "name" in camp: + data["name"] = camp["name"] + if "status" in camp: + if camp["status"] not in ["ACTIVE", "PAUSED", "ARCHIVED"]: + errors.append(f"{campaign_id}: Invalid status") + continue + data["status"] = camp["status"] + if "daily_budget" in camp: + try: + data["daily_budget"] = validate_budget(camp["daily_budget"]) + except FacebookValidationError as e: + errors.append(f"{campaign_id}: {e.message}") + continue + if not data: + errors.append(f"{campaign_id}: No fields to update") + continue + try: + result = await client.request("POST", campaign_id, data=data) + if result.get("success"): + results.append(f" {campaign_id}: {', '.join(f'{k}={v}' for k, v in data.items())}") + else: + errors.append(f"{campaign_id}: Update failed") + except (FacebookAPIError, ValueError) as e: + errors.append(f"{campaign_id}: {e}") + return f"Bulk update: {len(results)} ok, {len(errors)} errors\n" + ("\n".join(results) if results else "") + ("\nErrors:\n" + "\n".join(f" {e}" for e in errors) if errors else "") + + +# ── Ad Sets ─────────────────────────────────────────────────────────────────── + +_ADSET_FIELDS = "id,name,status,optimization_goal,billing_event,daily_budget,lifetime_budget,targeting" + + +async def _list_adsets(client: FacebookAdsClient, campaign_id: str) -> str: + if not campaign_id: + return "ERROR: campaign_id required" + if client.is_test_mode: + return f"Ad Sets for Campaign {campaign_id}:\n Test Ad Set (ID: 234567890) - ACTIVE, Daily: 20.00 USD\n" + data = await client.request("GET", f"{campaign_id}/adsets", params={"fields": _ADSET_FIELDS, "limit": 50}) + adsets = data.get("data", []) + if not adsets: + return f"No ad sets found for campaign {campaign_id}" + result = f"Ad Sets for Campaign {campaign_id} ({len(adsets)}):\n\n" + for a in adsets: + result += f" {a['name']} (ID: {a['id']}) - {a['status']}, Opt: {a.get('optimization_goal', 'N/A')}\n" + return result + + +async def _list_adsets_for_account(client: FacebookAdsClient, ad_account_id: str, status_filter: Optional[str]) -> str: + if not ad_account_id: + return "ERROR: ad_account_id required" + if client.is_test_mode: + return f"Ad Sets for {ad_account_id}:\n Test Ad Set (ID: 234567890) — ACTIVE\n" + params: Dict[str, Any] = {"fields": _ADSET_FIELDS + ",campaign_id", "limit": 100} + if status_filter: + params["filtering"] = json.dumps([{"field": "adset.delivery_info", "operator": "IN", "value": [status_filter.upper()]}]) + data = await client.request("GET", f"{ad_account_id}/adsets", params=params) + adsets = data.get("data", []) + if not adsets: + return f"No ad sets found for {ad_account_id}" + result = f"Ad Sets for {ad_account_id} ({len(adsets)}):\n\n" + for a in adsets: + result += f" {a.get('name', 'Unnamed')} (ID: {a['id']}) - {a.get('status', 'N/A')}\n" + return result + + +async def _get_adset(client: FacebookAdsClient, adset_id: str) -> str: + if not adset_id: + return "ERROR: adset_id required" + if client.is_test_mode: + return f"Ad Set {adset_id}:\n Name: Test Ad Set\n Status: ACTIVE\n Optimization: LINK_CLICKS\n" + data = await client.request("GET", adset_id, params={"fields": "id,name,status,optimization_goal,billing_event,bid_strategy,bid_amount,daily_budget,lifetime_budget,targeting,campaign_id,created_time"}) + result = f"Ad Set {adset_id}:\n" + result += f" Name: {data.get('name', 'N/A')}\n Status: {data.get('status', 'N/A')}\n Campaign: {data.get('campaign_id', 'N/A')}\n" + result += f" Optimization: {data.get('optimization_goal', 'N/A')}\n" + if data.get("daily_budget"): + result += f" Daily Budget: {format_currency(int(data['daily_budget']))}\n" + return result + + +async def _create_adset(client: FacebookAdsClient, ad_account_id: str, campaign_id: str, name: str, targeting: Dict[str, Any], optimization_goal: str, billing_event: str, daily_budget: Optional[int], lifetime_budget: Optional[int], status: str) -> str: + if not ad_account_id or not campaign_id or not name or not targeting: + return "ERROR: ad_account_id, campaign_id, name, targeting all required" + targeting_valid, targeting_error = validate_targeting_spec(targeting) + if not targeting_valid: + return f"ERROR: Invalid targeting: {targeting_error}" + if daily_budget: + try: + daily_budget = validate_budget(daily_budget) + except FacebookValidationError as e: + return f"ERROR: {e.message}" + if client.is_test_mode: + countries = targeting.get('geo_locations', {}).get('countries', ['Not specified']) + return f"Ad Set created!\nID: mock_adset_123\nName: {name}\nCampaign: {campaign_id}\nStatus: {status}\nOptimization: {optimization_goal}\nTargeting: {', '.join(countries)}\n" + form_data: Dict[str, Any] = { + "name": name, "campaign_id": campaign_id, + "optimization_goal": optimization_goal, "billing_event": billing_event, + "bid_strategy": "LOWEST_COST_WITHOUT_CAP", + "targeting": json.dumps(targeting), "status": status, + "access_token": client.access_token, + } + if daily_budget: + form_data["daily_budget"] = str(daily_budget) + if lifetime_budget: + form_data["lifetime_budget"] = str(lifetime_budget) + result = await client.request("POST", f"{ad_account_id}/adsets", form_data=form_data) + adset_id = result.get("id") + if not adset_id: + return f"Failed to create ad set. Response: {result}" + return f"Ad Set created!\nID: {adset_id}\nName: {name}\nCampaign: {campaign_id}\nStatus: {status}\n" + + +async def _update_adset(client: FacebookAdsClient, adset_id: str, name: Optional[str], status: Optional[str], daily_budget: Optional[int], targeting: Optional[Dict[str, Any]]) -> str: + if not adset_id: + return "ERROR: adset_id required" + if not any([name, status, daily_budget, targeting]): + return "ERROR: At least one field to update required" + if status and status not in ["ACTIVE", "PAUSED"]: + return "ERROR: status must be ACTIVE or PAUSED" + if daily_budget: + try: + daily_budget = validate_budget(daily_budget) + except FacebookValidationError as e: + return f"ERROR: {e.message}" + if client.is_test_mode: + return f"Ad Set {adset_id} updated." + data: Dict[str, Any] = {} + if name: + data["name"] = name + if status: + data["status"] = status + if daily_budget is not None: + data["daily_budget"] = daily_budget + result = await client.request("POST", adset_id, data=data) + return f"Ad Set {adset_id} updated." if result.get("success") else f"Failed to update. Response: {result}" + + +async def _delete_adset(client: FacebookAdsClient, adset_id: str) -> str: + if not adset_id: + return "ERROR: adset_id required" + if client.is_test_mode: + return f"Ad Set {adset_id} deleted." + result = await client.request("DELETE", adset_id) + return f"Ad Set {adset_id} deleted." if result.get("success") else f"Failed to delete. Response: {result}" + + +async def _validate_targeting(client: FacebookAdsClient, targeting_spec: Dict[str, Any], ad_account_id: Optional[str]) -> str: + if not targeting_spec: + return "ERROR: targeting_spec required" + valid, error = validate_targeting_spec(targeting_spec) + if not valid: + return f"Invalid targeting: {error}" + if client.is_test_mode: + countries = targeting_spec.get('geo_locations', {}).get('countries', ['N/A']) + return f"Targeting is valid!\nLocations: {', '.join(countries)}\nAge: {targeting_spec.get('age_min', 18)}-{targeting_spec.get('age_max', 65)}\n" + account_id = ad_account_id or client.ad_account_id + if not account_id: + return "ERROR: ad_account_id required for validate_targeting" + result = await client.request("GET", f"{account_id}/targetingsentencelines", params={"targeting_spec": json.dumps(targeting_spec)}) + output = "Targeting is valid!\n" + for line in result.get("targetingsentencelines", []): + output += f" - {line.get('content', '')}\n" + return output + + +# ── Ads ─────────────────────────────────────────────────────────────────────── + +async def _list_ads(client: FacebookAdsClient, ad_account_id: Optional[str], adset_id: Optional[str], status_filter: Optional[str]) -> str: + if not ad_account_id and not adset_id: + return "ERROR: Either ad_account_id or adset_id required" + parent = adset_id if adset_id else ad_account_id + if client.is_test_mode: + return f"Ads for {parent}:\n Test Ad (ID: 111222333) — PAUSED\n" + params: Dict[str, Any] = {"fields": "id,name,status,adset_id,creative{id},effective_status", "limit": 100} + if status_filter: + params["effective_status"] = f'["{status_filter.upper()}"]' + data = await client.request("GET", f"{parent}/ads", params=params) + ads = data.get("data", []) + if not ads: + return f"No ads found for {parent}" + result = f"Ads for {parent} ({len(ads)}):\n\n" + for ad in ads: + result += f" {ad.get('name', 'Unnamed')} (ID: {ad['id']}) - {ad.get('status', 'N/A')}\n" + return result + + +async def _get_ad(client: FacebookAdsClient, ad_id: str) -> str: + if not ad_id: + return "ERROR: ad_id required" + if client.is_test_mode: + return f"Ad {ad_id}:\n Name: Test Ad\n Status: PAUSED\n" + data = await client.request("GET", ad_id, params={"fields": "id,name,status,adset_id,creative,created_time,effective_status"}) + result = f"Ad {ad_id}:\n Name: {data.get('name', 'N/A')}\n Status: {data.get('status', 'N/A')}\n" + result += f" Adset: {data.get('adset_id', 'N/A')}\n" + if data.get("creative"): + result += f" Creative: {data['creative'].get('id', 'N/A')}\n" + return result + + +async def _create_ad(client: FacebookAdsClient, ad_account_id: str, adset_id: str, creative_id: str, name: Optional[str], status: str) -> str: + if not ad_account_id or not adset_id or not creative_id: + return "ERROR: ad_account_id, adset_id, creative_id all required" + if status not in ["ACTIVE", "PAUSED"]: + return "ERROR: status must be ACTIVE or PAUSED" + if client.is_test_mode: + return f"Ad created!\nID: mock_ad_123\nName: {name}\nAdset: {adset_id}\nStatus: {status}\n" + data = {"name": name or "Ad", "adset_id": adset_id, "creative": {"creative_id": creative_id}, "status": status} + result = await client.request("POST", f"{ad_account_id}/ads", data=data) + ad_id = result.get("id") + return f"Ad created!\nID: {ad_id}\nName: {name}\nAdset: {adset_id}\nStatus: {status}\n" if ad_id else f"Failed to create ad. Response: {result}" + + +async def _update_ad(client: FacebookAdsClient, ad_id: str, name: Optional[str], status: Optional[str]) -> str: + if not ad_id: + return "ERROR: ad_id required" + if not any([name, status]): + return "ERROR: name or status required" + if status and status not in ["ACTIVE", "PAUSED", "ARCHIVED", "DELETED"]: + return "ERROR: status must be ACTIVE, PAUSED, ARCHIVED or DELETED" + if client.is_test_mode: + return f"Ad {ad_id} updated." + data: Dict[str, Any] = {} + if name: + data["name"] = name + if status: + data["status"] = status + result = await client.request("POST", ad_id, data=data) + return f"Ad {ad_id} updated." if result.get("success") else f"Failed to update. Response: {result}" + + +async def _delete_ad(client: FacebookAdsClient, ad_id: str) -> str: + if not ad_id: + return "ERROR: ad_id required" + if client.is_test_mode: + return f"Ad {ad_id} deleted." + result = await client.request("DELETE", ad_id) + return f"Ad {ad_id} deleted." if result.get("success") else f"Failed to delete. Response: {result}" + + +async def _preview_ad(client: FacebookAdsClient, ad_id: str, ad_format: str) -> str: + if not ad_id: + return "ERROR: ad_id required" + try: + AdFormat(ad_format) + except ValueError: + return f"ERROR: Invalid ad_format. Must be one of: {', '.join(f.value for f in AdFormat)}" + if client.is_test_mode: + return f"Ad Preview for {ad_id} ({ad_format}):\n Preview URL: https://facebook.com/ads/preview/mock_{ad_id}\n" + data = await client.request("GET", f"{ad_id}/previews", params={"ad_format": ad_format}) + previews = data.get("data", []) + if not previews: + return "No preview available" + body = previews[0].get("body", "") + return f"Ad Preview for {ad_id} ({ad_format}):\n{body[:500]}...\n" if body else f"Preview available but no body. Response: {previews[0]}" + + +# ── Creatives ───────────────────────────────────────────────────────────────── + +async def _list_creatives(client: FacebookAdsClient, ad_account_id: str) -> str: + if not ad_account_id: + return "ERROR: ad_account_id required" + if client.is_test_mode: + return f"Creatives for {ad_account_id}:\n Test Creative (ID: 987654321)\n" + data = await client.request("GET", f"{ad_account_id}/adcreatives", params={"fields": "id,name,status,object_story_spec", "limit": 100}) + creatives = data.get("data", []) + if not creatives: + return f"No creatives found for {ad_account_id}" + result = f"Ad Creatives for {ad_account_id} ({len(creatives)}):\n\n" + for c in creatives: + result += f" {c.get('name', 'Unnamed')} (ID: {c['id']})\n" + return result + + +async def _get_creative(client: FacebookAdsClient, creative_id: str) -> str: + if not creative_id: + return "ERROR: creative_id required" + if client.is_test_mode: + return f"Creative {creative_id}:\n Name: Test Creative\n Status: ACTIVE\n" + data = await client.request("GET", creative_id, params={"fields": "id,name,status,object_story_spec,call_to_action_type,thumbnail_url,image_hash,video_id"}) + result = f"Creative {creative_id}:\n Name: {data.get('name', 'N/A')}\n Status: {data.get('status', 'N/A')}\n" + if data.get("call_to_action_type"): + result += f" CTA: {data['call_to_action_type']}\n" + return result + + +async def _create_creative(client: FacebookAdsClient, ad_account_id: str, name: str, page_id: str, message: Optional[str], link: Optional[str], image_hash: Optional[str], video_id: Optional[str], call_to_action_type: Optional[str]) -> str: + if not name or not page_id: + return "ERROR: name and page_id required" + if not image_hash and not video_id: + return "ERROR: image_hash or video_id required" + account_id = ad_account_id or client.ad_account_id + if not account_id: + return "ERROR: ad_account_id required" + cta = call_to_action_type or "LEARN_MORE" + try: + CallToActionType(cta) + except ValueError: + return f"ERROR: Invalid call_to_action_type. Must be one of: {', '.join(c.value for c in CallToActionType)}" + if client.is_test_mode: + return f"Creative created!\nID: mock_creative_123\nName: {name}\nPage: {page_id}\nCTA: {cta}\n" + link_data: Dict[str, Any] = {"call_to_action": {"type": cta}} + if image_hash: + link_data["image_hash"] = image_hash + if link: + link_data["link"] = link + if message: + link_data["message"] = message + data: Dict[str, Any] = {"name": name, "object_story_spec": {"page_id": page_id, "link_data": link_data}} + result = await client.request("POST", f"{account_id}/adcreatives", data=data) + creative_id = result.get("id") + return f"Creative created!\nID: {creative_id}\nName: {name}\nPage: {page_id}\nCTA: {cta}\n" if creative_id else f"Failed to create creative. Response: {result}" + + +async def _update_creative(client: FacebookAdsClient, creative_id: str, name: Optional[str]) -> str: + if not creative_id or not name: + return "ERROR: creative_id and name required" + if client.is_test_mode: + return f"Creative {creative_id} updated: name -> {name}" + result = await client.request("POST", creative_id, data={"name": name}) + return f"Creative {creative_id} updated." if result.get("success") else f"Failed. Response: {result}" + + +async def _delete_creative(client: FacebookAdsClient, creative_id: str) -> str: + if not creative_id: + return "ERROR: creative_id required" + if client.is_test_mode: + return f"Creative {creative_id} deleted." + result = await client.request("DELETE", creative_id) + return f"Creative {creative_id} deleted." if result.get("success") else f"Failed. Response: {result}" + + +async def _preview_creative(client: FacebookAdsClient, creative_id: str, ad_format: str) -> str: + if not creative_id: + return "ERROR: creative_id required" + try: + AdFormat(ad_format) + except ValueError: + return f"ERROR: Invalid ad_format. Must be one of: {', '.join(f.value for f in AdFormat)}" + if client.is_test_mode: + return f"Creative Preview for {creative_id} ({ad_format}):\n Preview URL: https://facebook.com/ads/preview/mock_{creative_id}\n" + data = await client.request("GET", f"{creative_id}/previews", params={"ad_format": ad_format}) + previews = data.get("data", []) + if not previews: + return "No preview available" + body = previews[0].get("body", "") + return f"Creative Preview for {creative_id} ({ad_format}):\n{body[:500]}...\n" if body else "Preview available but no body." + + +async def _upload_image(client: FacebookAdsClient, image_path: Optional[str], image_url: Optional[str], ad_account_id: Optional[str]) -> str: + if not image_path and not image_url: + return "ERROR: image_path or image_url required" + account_id = ad_account_id or client.ad_account_id + if not account_id: + return "ERROR: ad_account_id required" + if client.is_test_mode: + return f"Image uploaded!\nImage Hash: mock_abc123\nAccount: {account_id}\n" + endpoint = f"{account_id}/adimages" + if image_url: + result = await client.request("POST", endpoint, form_data={"url": image_url, "access_token": client.access_token}) + else: + image_file = Path(image_path) + if not image_file.exists(): + return f"ERROR: Image file not found: {image_path}" + await client.ensure_auth() + with open(image_file, 'rb') as f: + image_bytes = f.read() + url = f"{API_BASE}/{API_VERSION}/{endpoint}" + async with httpx.AsyncClient() as http_client: + response = await http_client.post(url, data={"access_token": client.access_token}, files={"filename": (image_file.name, image_bytes, "image/jpeg")}, timeout=60.0) + if response.status_code != 200: + return f"ERROR: Failed to upload image: {response.text}" + result = response.json() + images = result.get("images", {}) + if images: + image_hash = list(images.values())[0].get("hash", "unknown") + return f"Image uploaded!\nImage Hash: {image_hash}\nAccount: {account_id}\n" + return f"Failed to upload image. Response: {result}" + + +async def _upload_video(client: FacebookAdsClient, video_url: str, ad_account_id: Optional[str], title: Optional[str], description: Optional[str]) -> str: + if not video_url: + return "ERROR: video_url required" + account_id = ad_account_id or client.ad_account_id + if not account_id: + return "ERROR: ad_account_id required" + if client.is_test_mode: + return f"Video uploaded!\nVideo ID: mock_video_123\nAccount: {account_id}\n" + form_data: Dict[str, Any] = {"file_url": video_url, "access_token": client.access_token} + if title: + form_data["title"] = title + if description: + form_data["description"] = description + result = await client.request("POST", f"{account_id}/advideos", form_data=form_data) + video_id = result.get("id") + return f"Video uploaded!\nVideo ID: {video_id}\nAccount: {account_id}\n" if video_id else f"Failed to upload video. Response: {result}" + + +async def _list_videos(client: FacebookAdsClient, ad_account_id: str) -> str: + if not ad_account_id: + return "ERROR: ad_account_id required" + if client.is_test_mode: + return f"Videos for {ad_account_id}:\n Test Video (ID: mock_video_123) — READY\n" + data = await client.request("GET", f"{ad_account_id}/advideos", params={"fields": "id,title,description,length,status", "limit": 50}) + videos = data.get("data", []) + if not videos: + return f"No videos found for {ad_account_id}" + result = f"Ad Videos for {ad_account_id} ({len(videos)}):\n\n" + for v in videos: + result += f" {v.get('title', 'Untitled')} (ID: {v['id']}) — {v.get('status', 'N/A')}\n" + return result + + +# ── Audiences ───────────────────────────────────────────────────────────────── + +_AUDIENCE_FIELDS = "id,name,subtype,description,approximate_count,delivery_status,created_time" + + +async def _list_custom_audiences(client: FacebookAdsClient, ad_account_id: str) -> str: + if not ad_account_id: + return "ERROR: ad_account_id required" + if client.is_test_mode: + return f"Custom Audiences for {ad_account_id}:\n Test Audience (ID: 111222333) — CUSTOM — ~5,000 people\n" + data = await client.request("GET", f"{ad_account_id}/customaudiences", params={"fields": _AUDIENCE_FIELDS, "limit": 100}) + audiences = data.get("data", []) + if not audiences: + return f"No custom audiences found for {ad_account_id}" + result = f"Custom Audiences for {ad_account_id} ({len(audiences)}):\n\n" + for a in audiences: + result += f" {a.get('name', 'Unnamed')} (ID: {a['id']}) — {a.get('subtype', 'N/A')} — ~{a.get('approximate_count', 'Unknown')} people\n" + return result + + +async def _get_custom_audience(client: FacebookAdsClient, audience_id: str) -> str: + if not audience_id: + return "ERROR: audience_id required" + if client.is_test_mode: + return f"Audience {audience_id}:\n Name: Test Audience\n Subtype: CUSTOM\n Size: ~5,000 people\n" + data = await client.request("GET", audience_id, params={"fields": _AUDIENCE_FIELDS + ",rule,lookalike_spec,pixel_id"}) + return f"Audience {audience_id}:\n Name: {data.get('name', 'N/A')}\n Subtype: {data.get('subtype', 'N/A')}\n Size: ~{data.get('approximate_count', 'Unknown')} people\n" + + +async def _create_custom_audience(client: FacebookAdsClient, ad_account_id: str, name: str, subtype: str, description: Optional[str], customer_file_source: Optional[str]) -> str: + if not ad_account_id or not name: + return "ERROR: ad_account_id and name required" + try: + CustomAudienceSubtype(subtype) + except ValueError: + return f"ERROR: Invalid subtype. Must be one of: {', '.join(s.value for s in CustomAudienceSubtype)}" + if client.is_test_mode: + return f"Custom audience created:\n Name: {name}\n ID: mock_audience_123\n Subtype: {subtype}\n" + data: Dict[str, Any] = {"name": name, "subtype": subtype} + if description: + data["description"] = description + if customer_file_source: + data["customer_file_source"] = customer_file_source + result = await client.request("POST", f"{ad_account_id}/customaudiences", data=data) + audience_id = result.get("id") + return f"Audience created:\n Name: {name}\n ID: {audience_id}\n Subtype: {subtype}\n" if audience_id else f"Failed. Response: {result}" + + +async def _create_lookalike_audience(client: FacebookAdsClient, ad_account_id: str, origin_audience_id: str, country: str, ratio: float, name: Optional[str]) -> str: + if not ad_account_id or not origin_audience_id or not country: + return "ERROR: ad_account_id, origin_audience_id, country required" + if not 0.01 <= ratio <= 0.20: + return "ERROR: ratio must be between 0.01 (1%) and 0.20 (20%)" + audience_name = name or f"Lookalike ({country}, {int(ratio*100)}%) of {origin_audience_id}" + if client.is_test_mode: + return f"Lookalike audience created:\n Name: {audience_name}\n ID: mock_lookalike_456\n Country: {country}\n Ratio: {ratio*100:.0f}%\n" + data: Dict[str, Any] = {"name": audience_name, "subtype": "LOOKALIKE", "origin_audience_id": origin_audience_id, "lookalike_spec": {"country": country, "ratio": ratio, "type": "similarity"}} + result = await client.request("POST", f"{ad_account_id}/customaudiences", data=data) + audience_id = result.get("id") + return f"Lookalike created:\n Name: {audience_name}\n ID: {audience_id}\n Country: {country}\n Ratio: {ratio*100:.0f}%\n" if audience_id else f"Failed. Response: {result}" + + +async def _update_custom_audience(client: FacebookAdsClient, audience_id: str, name: Optional[str], description: Optional[str]) -> str: + if not audience_id or not any([name, description]): + return "ERROR: audience_id and at least one field (name, description) required" + if client.is_test_mode: + return f"Audience {audience_id} updated." + data: Dict[str, Any] = {} + if name: + data["name"] = name + if description: + data["description"] = description + result = await client.request("POST", audience_id, data=data) + return f"Audience {audience_id} updated." if result.get("success") else f"Failed. Response: {result}" + + +async def _delete_custom_audience(client: FacebookAdsClient, audience_id: str) -> str: + if not audience_id: + return "ERROR: audience_id required" + if client.is_test_mode: + return f"Audience {audience_id} deleted." + result = await client.request("DELETE", audience_id) + return f"Audience {audience_id} deleted." if result.get("success") else f"Failed. Response: {result}" + + +async def _add_users_to_audience(client: FacebookAdsClient, audience_id: str, emails: List[str], phones: Optional[List[str]]) -> str: + if not audience_id or not emails: + return "ERROR: audience_id and emails required" + import hashlib + hashed_emails = [hashlib.sha256(e.strip().lower().encode()).hexdigest() for e in emails] + schema = ["EMAIL"] + user_data = [[h] for h in hashed_emails] + if phones: + schema = ["EMAIL", "PHONE"] + hashed_phones = [hashlib.sha256(''.join(c for c in p if c.isdigit()).encode()).hexdigest() for p in phones] + user_data = [[e, p] for e, p in zip(hashed_emails, hashed_phones)] + if client.is_test_mode: + return f"Users added to audience {audience_id}:\n Emails: {len(emails)} (SHA-256 hashed)\n" + payload: Dict[str, Any] = {"payload": {"schema": schema, "data": user_data}} + result = await client.request("POST", f"{audience_id}/users", data=payload) + received = result.get("num_received", 0) + invalid = result.get("num_invalid_entries", 0) + return f"Users added to {audience_id}:\n Received: {received}\n Invalid: {invalid}\n Accepted: {received - invalid}\n" + + +# ── Pixels ──────────────────────────────────────────────────────────────────── + +async def _list_pixels(client: FacebookAdsClient, ad_account_id: str) -> str: + if not ad_account_id: + return "ERROR: ad_account_id required" + if client.is_test_mode: + return f"Pixels for {ad_account_id}:\n Test Pixel (ID: 111222333) — last fired: recently\n" + data = await client.request("GET", f"{ad_account_id}/adspixels", params={"fields": "id,name,creation_time,last_fired_time", "limit": 50}) + pixels = data.get("data", []) + if not pixels: + return f"No pixels found for {ad_account_id}" + result = f"Pixels for {ad_account_id} ({len(pixels)}):\n\n" + for p in pixels: + result += f" {p.get('name', 'Unnamed')} (ID: {p['id']}) — last fired: {p.get('last_fired_time', 'Never')}\n" + return result + + +async def _create_pixel(client: FacebookAdsClient, ad_account_id: str, name: str) -> str: + if not ad_account_id or not name: + return "ERROR: ad_account_id and name required" + if client.is_test_mode: + return f"Pixel created:\n Name: {name}\n ID: mock_pixel_789\n" + result = await client.request("POST", f"{ad_account_id}/adspixels", data={"name": name}) + pixel_id = result.get("id") + return f"Pixel created:\n Name: {name}\n ID: {pixel_id}\n" if pixel_id else f"Failed. Response: {result}" + + +async def _get_pixel_stats(client: FacebookAdsClient, pixel_id: str, start_time: Optional[str], end_time: Optional[str], aggregation: str) -> str: + if not pixel_id: + return "ERROR: pixel_id required" + if aggregation not in ["day", "hour", "week", "month"]: + return "ERROR: aggregation must be day, hour, week or month" + if client.is_test_mode: + return f"Pixel Stats for {pixel_id}:\n PageView: 3,450\n Purchase: 127\n Lead: 89\n" + params: Dict[str, Any] = {"aggregation": aggregation, "fields": "event_name,count", "limit": 200} + if start_time: + params["start_time"] = start_time + if end_time: + params["end_time"] = end_time + data = await client.request("GET", f"{pixel_id}/stats", params=params) + stats = data.get("data", []) + if not stats: + return f"No stats found for pixel {pixel_id}" + totals: Dict[str, int] = {} + for entry in stats: + event = entry.get("event_name", "Unknown") + totals[event] = totals.get(event, 0) + int(entry.get("count", 0)) + result = f"Pixel Stats for {pixel_id}:\n\n" + for event, count in sorted(totals.items(), key=lambda x: -x[1]): + result += f" {event}: {count:,} events\n" + return result + + +# ── Targeting ───────────────────────────────────────────────────────────────── + +async def _search_interests(client: FacebookAdsClient, q: str, limit: int) -> str: + if not q: + return "ERROR: q (search query) required" + if client.is_test_mode: + return f"Interests matching '{q}':\n Travel (ID: 6003263) — ~600M people\n" + data = await client.request("GET", "search", params={"type": "adinterest", "q": q, "limit": min(limit, 50), "locale": "en_US"}) + items = data.get("data", []) + if not items: + return f"No interests found matching '{q}'" + result = f"Interests matching '{q}' ({len(items)}):\n\n" + for item in items: + audience_size = item.get("audience_size", "Unknown") + result += f" {item.get('name', 'N/A')} (ID: {item.get('id', 'N/A')}) — ~{audience_size:,} people\n" if isinstance(audience_size, int) else f" {item.get('name', 'N/A')} (ID: {item.get('id', 'N/A')})\n" + return result + + +async def _search_behaviors(client: FacebookAdsClient, q: str, limit: int) -> str: + if not q: + return "ERROR: q (search query) required" + if client.is_test_mode: + return f"Behaviors matching '{q}':\n Frequent Travelers (ID: 6002714) — ~120M people\n" + data = await client.request("GET", "search", params={"type": "adbehavior", "q": q, "limit": min(limit, 50), "locale": "en_US"}) + items = data.get("data", []) + if not items: + return f"No behaviors found matching '{q}'" + result = f"Behaviors matching '{q}' ({len(items)}):\n\n" + for item in items: + result += f" {item.get('name', 'N/A')} (ID: {item.get('id', 'N/A')})\n" + return result + + +async def _get_reach_estimate(client: FacebookAdsClient, ad_account_id: str, targeting: Dict[str, Any], optimization_goal: str) -> str: + if not ad_account_id or not targeting: + return "ERROR: ad_account_id and targeting required" + if client.is_test_mode: + return f"Reach Estimate for {ad_account_id}:\n Estimated Audience: 1,200,000 — 1,800,000 people\n" + data = await client.request("GET", f"{ad_account_id}/reachestimate", params={"targeting_spec": json.dumps(targeting), "optimization_goal": optimization_goal}) + users = data.get("users", "Unknown") + return f"Reach Estimate:\n Audience: {users:,} people\n Goal: {optimization_goal}\n" if isinstance(users, int) else f"Reach Estimate:\n Audience: {users}\n Goal: {optimization_goal}\n" + + +async def _get_delivery_estimate(client: FacebookAdsClient, ad_account_id: str, targeting: Dict[str, Any], optimization_goal: str, bid_amount: Optional[int]) -> str: + if not ad_account_id or not targeting: + return "ERROR: ad_account_id and targeting required" + if client.is_test_mode: + return f"Delivery Estimate for {ad_account_id}:\n Daily Min Spend: $5.00\n Daily Max Spend: $50.00\n" + params: Dict[str, Any] = {"targeting_spec": json.dumps(targeting), "optimization_goal": optimization_goal} + if bid_amount: + params["bid_amount"] = bid_amount + data = await client.request("GET", f"{ad_account_id}/delivery_estimate", params=params) + estimates = data.get("data", []) + if not estimates: + return f"No delivery estimates found for {ad_account_id}" + result = f"Delivery Estimate for {ad_account_id}:\n\n" + for est in estimates: + daily = est.get("daily_outcomes_curve", []) + if daily: + first, last = daily[0], daily[-1] + result += f" Daily Spend: ${first.get('spend', 0)/100:.2f} — ${last.get('spend', 0)/100:.2f}\n" + result += f" Daily Reach: {first.get('reach', 0):,} — {last.get('reach', 0):,}\n" + return result + + +# ── Rules ───────────────────────────────────────────────────────────────────── + +async def _list_ad_rules(client: FacebookAdsClient, ad_account_id: str) -> str: + if not ad_account_id: + return "ERROR: ad_account_id required" + if client.is_test_mode: + return f"Ad Rules for {ad_account_id}:\n Pause on low CTR (ID: 111222) — ENABLED\n" + data = await client.request("GET", f"{ad_account_id}/adrules_library", params={"fields": "id,name,status,execution_spec", "limit": 50}) + rules = data.get("data", []) + if not rules: + return f"No ad rules found for {ad_account_id}" + result = f"Ad Rules for {ad_account_id} ({len(rules)}):\n\n" + for rule in rules: + result += f" {rule.get('name', 'Unnamed')} (ID: {rule['id']}) — {rule.get('status', 'N/A')}\n" + return result + + +async def _create_ad_rule(client: FacebookAdsClient, ad_account_id: str, name: str, evaluation_spec: Dict[str, Any], execution_spec: Dict[str, Any], schedule_spec: Optional[Dict[str, Any]], status: str) -> str: + if not ad_account_id or not name or not evaluation_spec or not execution_spec: + return "ERROR: ad_account_id, name, evaluation_spec, execution_spec all required" + if client.is_test_mode: + return f"Ad rule created:\n Name: {name}\n ID: mock_rule_123\n Status: {status}\n" + data: Dict[str, Any] = {"name": name, "evaluation_spec": evaluation_spec, "execution_spec": execution_spec, "status": status} + if schedule_spec: + data["schedule_spec"] = schedule_spec + result = await client.request("POST", f"{ad_account_id}/adrules_library", data=data) + rule_id = result.get("id") + return f"Ad rule created:\n Name: {name}\n ID: {rule_id}\n Status: {status}\n" if rule_id else f"Failed. Response: {result}" + + +async def _update_ad_rule(client: FacebookAdsClient, rule_id: str, name: Optional[str], status: Optional[str]) -> str: + if not rule_id or not any([name, status]): + return "ERROR: rule_id and at least one field (name, status) required" + if status and status not in ["ENABLED", "DISABLED", "DELETED"]: + return "ERROR: status must be ENABLED, DISABLED or DELETED" + if client.is_test_mode: + return f"Ad rule {rule_id} updated." + data: Dict[str, Any] = {} + if name: + data["name"] = name + if status: + data["status"] = status + result = await client.request("POST", rule_id, data=data) + return f"Ad rule {rule_id} updated." if result.get("success") else f"Failed. Response: {result}" + + +async def _delete_ad_rule(client: FacebookAdsClient, rule_id: str) -> str: + if not rule_id: + return "ERROR: rule_id required" + if client.is_test_mode: + return f"Ad rule {rule_id} deleted." + result = await client.request("DELETE", rule_id) + return f"Ad rule {rule_id} deleted." if result.get("success") else f"Failed. Response: {result}" + + +async def _execute_ad_rule(client: FacebookAdsClient, rule_id: str) -> str: + if not rule_id: + return "ERROR: rule_id required" + if client.is_test_mode: + return f"Ad rule {rule_id} executed." + result = await client.request("POST", f"{rule_id}/execute", data={}) + return f"Ad rule {rule_id} executed." if result.get("success") else f"Failed. Response: {result}" + + +# ── Dispatch table ──────────────────────────────────────────────────────────── + _HANDLERS: Dict[str, Any] = { - "list_campaigns": lambda c, a: list_campaigns(c, a.get("ad_account_id"), a.get("status")), - "get_campaign": lambda c, a: get_campaign(c, a.get("campaign_id", "")), - "create_campaign": lambda c, a: create_campaign( - c, a.get("ad_account_id", ""), a.get("name", ""), - a.get("objective", "OUTCOME_AWARENESS"), - a.get("daily_budget"), a.get("lifetime_budget"), - a.get("status", "PAUSED"), - ), - "update_campaign": lambda c, a: update_campaign( - c, a.get("campaign_id", ""), a.get("name"), a.get("status"), - a.get("daily_budget"), a.get("lifetime_budget"), - ), - "delete_campaign": lambda c, a: delete_campaign(c, a.get("campaign_id", "")), - "duplicate_campaign": lambda c, a: duplicate_campaign(c, a.get("campaign_id", ""), a.get("new_name", ""), a.get("ad_account_id")), - "archive_campaign": lambda c, a: archive_campaign(c, a.get("campaign_id", "")), - "bulk_update_campaigns": lambda c, a: bulk_update_campaigns(c, a.get("campaigns", [])), - "list_adsets": lambda c, a: list_adsets(c, a.get("campaign_id", "")), - "list_adsets_for_account": lambda c, a: list_adsets_for_account(c, a.get("ad_account_id", ""), a.get("status_filter")), - "get_adset": lambda c, a: get_adset(c, a.get("adset_id", "")), - "create_adset": lambda c, a: create_adset( - c, a.get("ad_account_id", ""), a.get("campaign_id", ""), - a.get("name", ""), a.get("targeting", {}), - a.get("optimization_goal"), a.get("billing_event"), - a.get("daily_budget"), a.get("lifetime_budget"), - a.get("status", "PAUSED"), - ), - "update_adset": lambda c, a: update_adset(c, a.get("adset_id", ""), a.get("name"), a.get("status"), a.get("daily_budget"), a.get("targeting")), - "delete_adset": lambda c, a: delete_adset(c, a.get("adset_id", "")), - "validate_targeting": lambda c, a: validate_targeting(c, a.get("targeting_spec", a.get("targeting", {})), a.get("ad_account_id")), - "list_ads": lambda c, a: list_ads(c, a.get("ad_account_id"), a.get("adset_id"), a.get("status_filter")), - "get_ad": lambda c, a: get_ad(c, a.get("ad_id", "")), - "create_ad": lambda c, a: create_ad(c, a.get("ad_account_id", ""), a.get("adset_id", ""), a.get("creative_id", ""), a.get("name"), a.get("status", "PAUSED")), - "update_ad": lambda c, a: update_ad(c, a.get("ad_id", ""), a.get("name"), a.get("status")), - "delete_ad": lambda c, a: delete_ad(c, a.get("ad_id", "")), - "preview_ad": lambda c, a: preview_ad(c, a.get("ad_id", ""), a.get("ad_format", "DESKTOP_FEED_STANDARD")), - "list_creatives": lambda c, a: list_creatives(c, a.get("ad_account_id", "")), - "get_creative": lambda c, a: get_creative(c, a.get("creative_id", "")), - "create_creative": lambda c, a: create_creative( - c, a.get("ad_account_id", ""), a.get("name", ""), - a.get("page_id", ""), a.get("message"), a.get("link"), - a.get("image_hash"), a.get("video_id"), a.get("call_to_action_type"), - ), - "update_creative": lambda c, a: update_creative(c, a.get("creative_id", ""), a.get("name")), - "delete_creative": lambda c, a: delete_creative(c, a.get("creative_id", "")), - "preview_creative": lambda c, a: preview_creative(c, a.get("creative_id", ""), a.get("ad_format", "DESKTOP_FEED_STANDARD")), - "upload_image": lambda c, a: upload_image(c, a.get("image_path"), a.get("image_url"), a.get("ad_account_id")), - "upload_video": lambda c, a: upload_video(c, a.get("video_url", ""), a.get("ad_account_id"), a.get("title"), a.get("description")), - "list_videos": lambda c, a: list_videos(c, a.get("ad_account_id", "")), - "list_custom_audiences": lambda c, a: list_custom_audiences(c, a.get("ad_account_id", "")), - "get_custom_audience": lambda c, a: get_custom_audience(c, a.get("audience_id", "")), - "create_custom_audience": lambda c, a: create_custom_audience(c, a.get("ad_account_id", ""), a.get("name", ""), a.get("subtype", "CUSTOM"), a.get("description"), a.get("customer_file_source")), - "create_lookalike_audience": lambda c, a: create_lookalike_audience(c, a.get("ad_account_id", ""), a.get("origin_audience_id", ""), a.get("country", ""), float(a.get("ratio", 0.01)), a.get("name")), - "update_custom_audience": lambda c, a: update_custom_audience(c, a.get("audience_id", ""), a.get("name"), a.get("description")), - "delete_custom_audience": lambda c, a: delete_custom_audience(c, a.get("audience_id", "")), - "add_users_to_audience": lambda c, a: add_users_to_audience(c, a.get("audience_id", ""), a.get("emails", []), a.get("phones")), - "list_pixels": lambda c, a: list_pixels(c, a.get("ad_account_id", "")), - "create_pixel": lambda c, a: create_pixel(c, a.get("ad_account_id", ""), a.get("name", "")), - "get_pixel_stats": lambda c, a: get_pixel_stats(c, a.get("pixel_id", ""), a.get("start_time"), a.get("end_time"), a.get("aggregation", "day")), - "search_interests": lambda c, a: search_interests(c, a.get("q", ""), int(a.get("limit", 20))), - "search_behaviors": lambda c, a: search_behaviors(c, a.get("q", ""), int(a.get("limit", 20))), - "get_reach_estimate": lambda c, a: get_reach_estimate(c, a.get("ad_account_id", ""), a.get("targeting", {}), a.get("optimization_goal", "LINK_CLICKS")), - "get_delivery_estimate": lambda c, a: get_delivery_estimate(c, a.get("ad_account_id", ""), a.get("targeting", {}), a.get("optimization_goal", "LINK_CLICKS"), a.get("bid_amount")), - "list_ad_rules": lambda c, a: list_ad_rules(c, a.get("ad_account_id", "")), - "create_ad_rule": lambda c, a: create_ad_rule(c, a.get("ad_account_id", ""), a.get("name", ""), a.get("evaluation_spec", {}), a.get("execution_spec", {}), a.get("schedule_spec"), a.get("status", "ENABLED")), - "update_ad_rule": lambda c, a: update_ad_rule(c, a.get("rule_id", ""), a.get("name"), a.get("status")), - "delete_ad_rule": lambda c, a: delete_ad_rule(c, a.get("rule_id", "")), - "execute_ad_rule": lambda c, a: execute_ad_rule(c, a.get("rule_id", "")), + "list_campaigns": lambda c, a: _list_campaigns(c, a.get("ad_account_id"), a.get("status")), + "get_campaign": lambda c, a: _get_campaign(c, a.get("campaign_id", "")), + "create_campaign": lambda c, a: _create_campaign(c, a.get("ad_account_id", ""), a.get("name", ""), a.get("objective", "OUTCOME_AWARENESS"), a.get("daily_budget"), a.get("lifetime_budget"), a.get("status", "PAUSED")), + "update_campaign": lambda c, a: _update_campaign(c, a.get("campaign_id", ""), a.get("name"), a.get("status"), a.get("daily_budget"), a.get("lifetime_budget")), + "delete_campaign": lambda c, a: _delete_campaign(c, a.get("campaign_id", "")), + "duplicate_campaign": lambda c, a: _duplicate_campaign(c, a.get("campaign_id", ""), a.get("new_name", ""), a.get("ad_account_id")), + "archive_campaign": lambda c, a: _archive_campaign(c, a.get("campaign_id", "")), + "bulk_update_campaigns": lambda c, a: _bulk_update_campaigns(c, a.get("campaigns", [])), + "list_adsets": lambda c, a: _list_adsets(c, a.get("campaign_id", "")), + "list_adsets_for_account": lambda c, a: _list_adsets_for_account(c, a.get("ad_account_id", ""), a.get("status_filter")), + "get_adset": lambda c, a: _get_adset(c, a.get("adset_id", "")), + "create_adset": lambda c, a: _create_adset(c, a.get("ad_account_id", ""), a.get("campaign_id", ""), a.get("name", ""), a.get("targeting", {}), a.get("optimization_goal", "LINK_CLICKS"), a.get("billing_event", "IMPRESSIONS"), a.get("daily_budget"), a.get("lifetime_budget"), a.get("status", "PAUSED")), + "update_adset": lambda c, a: _update_adset(c, a.get("adset_id", ""), a.get("name"), a.get("status"), a.get("daily_budget"), a.get("targeting")), + "delete_adset": lambda c, a: _delete_adset(c, a.get("adset_id", "")), + "validate_targeting": lambda c, a: _validate_targeting(c, a.get("targeting_spec", a.get("targeting", {})), a.get("ad_account_id")), + "list_ads": lambda c, a: _list_ads(c, a.get("ad_account_id"), a.get("adset_id"), a.get("status_filter")), + "get_ad": lambda c, a: _get_ad(c, a.get("ad_id", "")), + "create_ad": lambda c, a: _create_ad(c, a.get("ad_account_id", ""), a.get("adset_id", ""), a.get("creative_id", ""), a.get("name"), a.get("status", "PAUSED")), + "update_ad": lambda c, a: _update_ad(c, a.get("ad_id", ""), a.get("name"), a.get("status")), + "delete_ad": lambda c, a: _delete_ad(c, a.get("ad_id", "")), + "preview_ad": lambda c, a: _preview_ad(c, a.get("ad_id", ""), a.get("ad_format", "DESKTOP_FEED_STANDARD")), + "list_creatives": lambda c, a: _list_creatives(c, a.get("ad_account_id", "")), + "get_creative": lambda c, a: _get_creative(c, a.get("creative_id", "")), + "create_creative": lambda c, a: _create_creative(c, a.get("ad_account_id", ""), a.get("name", ""), a.get("page_id", ""), a.get("message"), a.get("link"), a.get("image_hash"), a.get("video_id"), a.get("call_to_action_type")), + "update_creative": lambda c, a: _update_creative(c, a.get("creative_id", ""), a.get("name")), + "delete_creative": lambda c, a: _delete_creative(c, a.get("creative_id", "")), + "preview_creative": lambda c, a: _preview_creative(c, a.get("creative_id", ""), a.get("ad_format", "DESKTOP_FEED_STANDARD")), + "upload_image": lambda c, a: _upload_image(c, a.get("image_path"), a.get("image_url"), a.get("ad_account_id")), + "upload_video": lambda c, a: _upload_video(c, a.get("video_url", ""), a.get("ad_account_id"), a.get("title"), a.get("description")), + "list_videos": lambda c, a: _list_videos(c, a.get("ad_account_id", "")), + "list_custom_audiences": lambda c, a: _list_custom_audiences(c, a.get("ad_account_id", "")), + "get_custom_audience": lambda c, a: _get_custom_audience(c, a.get("audience_id", "")), + "create_custom_audience": lambda c, a: _create_custom_audience(c, a.get("ad_account_id", ""), a.get("name", ""), a.get("subtype", "CUSTOM"), a.get("description"), a.get("customer_file_source")), + "create_lookalike_audience": lambda c, a: _create_lookalike_audience(c, a.get("ad_account_id", ""), a.get("origin_audience_id", ""), a.get("country", ""), float(a.get("ratio", 0.01)), a.get("name")), + "update_custom_audience": lambda c, a: _update_custom_audience(c, a.get("audience_id", ""), a.get("name"), a.get("description")), + "delete_custom_audience": lambda c, a: _delete_custom_audience(c, a.get("audience_id", "")), + "add_users_to_audience": lambda c, a: _add_users_to_audience(c, a.get("audience_id", ""), a.get("emails", []), a.get("phones")), + "list_pixels": lambda c, a: _list_pixels(c, a.get("ad_account_id", "")), + "create_pixel": lambda c, a: _create_pixel(c, a.get("ad_account_id", ""), a.get("name", "")), + "get_pixel_stats": lambda c, a: _get_pixel_stats(c, a.get("pixel_id", ""), a.get("start_time"), a.get("end_time"), a.get("aggregation", "day")), + "search_interests": lambda c, a: _search_interests(c, a.get("q", ""), int(a.get("limit", 20))), + "search_behaviors": lambda c, a: _search_behaviors(c, a.get("q", ""), int(a.get("limit", 20))), + "get_reach_estimate": lambda c, a: _get_reach_estimate(c, a.get("ad_account_id", ""), a.get("targeting", {}), a.get("optimization_goal", "LINK_CLICKS")), + "get_delivery_estimate": lambda c, a: _get_delivery_estimate(c, a.get("ad_account_id", ""), a.get("targeting", {}), a.get("optimization_goal", "LINK_CLICKS"), a.get("bid_amount")), + "list_ad_rules": lambda c, a: _list_ad_rules(c, a.get("ad_account_id", "")), + "create_ad_rule": lambda c, a: _create_ad_rule(c, a.get("ad_account_id", ""), a.get("name", ""), a.get("evaluation_spec", {}), a.get("execution_spec", {}), a.get("schedule_spec"), a.get("status", "ENABLED")), + "update_ad_rule": lambda c, a: _update_ad_rule(c, a.get("rule_id", ""), a.get("name"), a.get("status")), + "delete_ad_rule": lambda c, a: _delete_ad_rule(c, a.get("rule_id", "")), + "execute_ad_rule": lambda c, a: _execute_ad_rule(c, a.get("rule_id", "")), } +# ── Integration class ───────────────────────────────────────────────────────── + class IntegrationMetaMarketingManage: - # Wraps FacebookAdsClient and delegates all manage operations through _HANDLERS. - # Uses OAuth token from rcx (Flexus stores the Meta access token per persona). def __init__(self, rcx: "ckit_bot_exec.RobotContext"): self.client = FacebookAdsClient(rcx.fclient, rcx) - async def called_by_model( - self, - toolcall: ckit_cloudtool.FCloudtoolCall, - model_produced_args: Dict[str, Any], - ) -> str: + async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: try: args = model_produced_args or {} op = str(args.get("op", "help")).strip() @@ -212,7 +999,7 @@ async def called_by_model( return "Error: args.method_id required for op=call." handler = _HANDLERS.get(method_id) if handler is None: - return f"Error: unknown method_id={method_id!r}. Use op=list_methods to see available methods." + return f"Error: unknown method_id={method_id!r}. Use op=list_methods." return await handler(self.client, call_args) except FacebookAuthError as e: return e.message diff --git a/flexus_client_kit/integrations/fi_meta_marketing_metrics.py b/flexus_client_kit/integrations/fi_meta_marketing_metrics.py index 840500ed..c8b2f97d 100644 --- a/flexus_client_kit/integrations/fi_meta_marketing_metrics.py +++ b/flexus_client_kit/integrations/fi_meta_marketing_metrics.py @@ -1,18 +1,15 @@ from __future__ import annotations import logging -from typing import Any, Dict, TYPE_CHECKING +from typing import Any, Dict, List, Optional, TYPE_CHECKING from flexus_client_kit import ckit_cloudtool -from flexus_client_kit.integrations.facebook.client import FacebookAdsClient -from flexus_client_kit.integrations.facebook.exceptions import ( +from flexus_client_kit.integrations._fi_meta_helpers import ( + FacebookAdsClient, FacebookAPIError, FacebookAuthError, FacebookValidationError, -) -from flexus_client_kit.integrations.facebook.insights import ( - get_account_insights, get_campaign_insights, get_adset_insights, get_ad_insights, - create_async_report, get_async_report_status, + InsightsDatePreset, ) if TYPE_CHECKING: @@ -21,7 +18,6 @@ logger = logging.getLogger("meta_marketing_metrics") # Use case: "Measure ad performance data with Marketing API" -# Covers all insights endpoints: account, campaign, ad set, ad level, async reports. PROVIDER_NAME = "meta_marketing_metrics" _HELP = """meta_marketing_metrics: Measure ad performance with Meta Marketing API. @@ -35,26 +31,145 @@ get_async_report_status(report_run_id) """ +_DEFAULT_METRICS = "impressions,clicks,spend,reach,frequency,ctr,cpc,cpm,actions,cost_per_action_type" + + +def _days_to_preset(days: int) -> str: + if days <= 1: + return InsightsDatePreset.TODAY.value + if days <= 7: + return InsightsDatePreset.LAST_7D.value + if days <= 14: + return InsightsDatePreset.LAST_14D.value + if days <= 28: + return InsightsDatePreset.LAST_28D.value + if days <= 30: + return InsightsDatePreset.LAST_30D.value + if days <= 90: + return InsightsDatePreset.LAST_90D.value + return InsightsDatePreset.MAXIMUM.value + + +def _format_insights(data: Dict[str, Any], label: str) -> str: + result = f"Insights for {label}:\n\n" + items = data.get("data", []) + if not items: + return f"No insights data found for {label}\n" + for item in items: + result += f" Date: {item.get('date_start', 'N/A')} - {item.get('date_stop', 'N/A')}\n" + result += f" Impressions: {item.get('impressions', '0')}\n" + result += f" Clicks: {item.get('clicks', '0')}\n" + result += f" Spend: ${item.get('spend', '0')}\n" + result += f" Reach: {item.get('reach', '0')}\n" + result += f" CTR: {item.get('ctr', '0')}%\n" + result += f" CPC: ${item.get('cpc', '0')}\n" + result += f" CPM: ${item.get('cpm', '0')}\n" + actions = item.get("actions", []) + if actions: + result += " Actions:\n" + for action in actions[:5]: + result += f" - {action.get('action_type', 'N/A')}: {action.get('value', '0')}\n" + result += "\n" + return result + + +async def _get_account_insights(client: FacebookAdsClient, ad_account_id: str, days: int, breakdowns: Optional[List[str]], metrics: Optional[str], date_preset: Optional[str]) -> str: + if not ad_account_id: + return "ERROR: ad_account_id required" + if client.is_test_mode: + return f"Account Insights for {ad_account_id} (last {days} days):\n Impressions: 15,000\n Clicks: 450\n Spend: $120.00\n CTR: 3.0%\n" + params: Dict[str, Any] = {"fields": metrics or _DEFAULT_METRICS, "level": "account", "limit": 50} + params["date_preset"] = date_preset or _days_to_preset(days) + if breakdowns: + params["breakdowns"] = ",".join(breakdowns) + data = await client.request("GET", f"{ad_account_id}/insights", params=params) + return _format_insights(data, ad_account_id) + + +async def _get_campaign_insights(client: FacebookAdsClient, campaign_id: str, days: int, breakdowns: Optional[List[str]], metrics: Optional[str], date_preset: Optional[str]) -> str: + if not campaign_id: + return "ERROR: campaign_id required" + if client.is_test_mode: + return f"Campaign Insights for {campaign_id} (last {days} days):\n Impressions: 10,000\n Clicks: 300\n Spend: $80.00\n CTR: 3.0%\n" + params: Dict[str, Any] = {"fields": metrics or _DEFAULT_METRICS, "limit": 50} + params["date_preset"] = date_preset or _days_to_preset(days) + if breakdowns: + params["breakdowns"] = ",".join(breakdowns) + data = await client.request("GET", f"{campaign_id}/insights", params=params) + return _format_insights(data, campaign_id) + + +async def _get_adset_insights(client: FacebookAdsClient, adset_id: str, days: int, breakdowns: Optional[List[str]], metrics: Optional[str], date_preset: Optional[str]) -> str: + if not adset_id: + return "ERROR: adset_id required" + if client.is_test_mode: + return f"Ad Set Insights for {adset_id} (last {days} days):\n Impressions: 5,000\n Clicks: 150\n Spend: $40.00\n" + params: Dict[str, Any] = {"fields": metrics or _DEFAULT_METRICS, "limit": 50} + params["date_preset"] = date_preset or _days_to_preset(days) + if breakdowns: + params["breakdowns"] = ",".join(breakdowns) + data = await client.request("GET", f"{adset_id}/insights", params=params) + return _format_insights(data, adset_id) + + +async def _get_ad_insights(client: FacebookAdsClient, ad_id: str, days: int, breakdowns: Optional[List[str]], metrics: Optional[str], date_preset: Optional[str]) -> str: + if not ad_id: + return "ERROR: ad_id required" + if client.is_test_mode: + return f"Ad Insights for {ad_id} (last {days} days):\n Impressions: 2,000\n Clicks: 60\n Spend: $15.00\n" + params: Dict[str, Any] = {"fields": metrics or _DEFAULT_METRICS, "limit": 50} + params["date_preset"] = date_preset or _days_to_preset(days) + if breakdowns: + params["breakdowns"] = ",".join(breakdowns) + data = await client.request("GET", f"{ad_id}/insights", params=params) + return _format_insights(data, ad_id) + + +async def _create_async_report(client: FacebookAdsClient, ad_account_id: str, level: str, fields: Optional[str], date_preset: str, breakdowns: Optional[List[str]]) -> str: + if not ad_account_id: + return "ERROR: ad_account_id required" + valid_levels = ["account", "campaign", "adset", "ad"] + if level not in valid_levels: + return f"ERROR: level must be one of: {', '.join(valid_levels)}" + if client.is_test_mode: + return f"Async report created:\n Report Run ID: mock_report_run_123\n Level: {level}\n Use get_async_report_status to check progress.\n" + data: Dict[str, Any] = {"level": level, "fields": fields or _DEFAULT_METRICS, "date_preset": date_preset} + if breakdowns: + data["breakdowns"] = ",".join(breakdowns) + result = await client.request("POST", f"{ad_account_id}/insights", data=data) + report_run_id = result.get("report_run_id") + return f"Async report created:\n Report Run ID: {report_run_id}\n Level: {level}\n Date Preset: {date_preset}\n" if report_run_id else f"Failed to create report. Response: {result}" + + +async def _get_async_report_status(client: FacebookAdsClient, report_run_id: str) -> str: + if not report_run_id: + return "ERROR: report_run_id required" + if client.is_test_mode: + return f"Report {report_run_id}: Job Completed (100%)\n" + data = await client.request("GET", report_run_id, params={"fields": "id,async_status,async_percent_completion,date_start,date_stop"}) + status = data.get("async_status", "Unknown") + pct = data.get("async_percent_completion", 0) + result = f"Report {report_run_id}:\n Status: {status} ({pct}%)\n" + if data.get("date_start"): + result += f" Date Range: {data['date_start']} - {data.get('date_stop', 'N/A')}\n" + return result + + _HANDLERS: Dict[str, Any] = { - "get_account_insights": lambda c, a: get_account_insights(c, a.get("ad_account_id", ""), int(a.get("days", 30)), a.get("breakdowns"), a.get("metrics"), a.get("date_preset")), - "get_campaign_insights": lambda c, a: get_campaign_insights(c, a.get("campaign_id", ""), int(a.get("days", 30)), a.get("breakdowns"), a.get("metrics"), a.get("date_preset")), - "get_adset_insights": lambda c, a: get_adset_insights(c, a.get("adset_id", ""), int(a.get("days", 30)), a.get("breakdowns"), a.get("metrics"), a.get("date_preset")), - "get_ad_insights": lambda c, a: get_ad_insights(c, a.get("ad_id", ""), int(a.get("days", 30)), a.get("breakdowns"), a.get("metrics"), a.get("date_preset")), - "create_async_report": lambda c, a: create_async_report(c, a.get("ad_account_id", ""), a.get("level", "campaign"), a.get("fields"), a.get("date_preset", "last_30d"), a.get("breakdowns")), - "get_async_report_status": lambda c, a: get_async_report_status(c, a.get("report_run_id", "")), + "get_account_insights": lambda c, a: _get_account_insights(c, a.get("ad_account_id", ""), int(a.get("days", 30)), a.get("breakdowns"), a.get("metrics"), a.get("date_preset")), + "get_campaign_insights": lambda c, a: _get_campaign_insights(c, a.get("campaign_id", ""), int(a.get("days", 30)), a.get("breakdowns"), a.get("metrics"), a.get("date_preset")), + "get_adset_insights": lambda c, a: _get_adset_insights(c, a.get("adset_id", ""), int(a.get("days", 30)), a.get("breakdowns"), a.get("metrics"), a.get("date_preset")), + "get_ad_insights": lambda c, a: _get_ad_insights(c, a.get("ad_id", ""), int(a.get("days", 30)), a.get("breakdowns"), a.get("metrics"), a.get("date_preset")), + "create_async_report": lambda c, a: _create_async_report(c, a.get("ad_account_id", ""), a.get("level", "campaign"), a.get("fields"), a.get("date_preset", "last_30d"), a.get("breakdowns")), + "get_async_report_status": lambda c, a: _get_async_report_status(c, a.get("report_run_id", "")), } class IntegrationMetaMarketingMetrics: - # Wraps FacebookAdsClient and delegates all insights/metrics operations. def __init__(self, rcx: "ckit_bot_exec.RobotContext"): self.client = FacebookAdsClient(rcx.fclient, rcx) - async def called_by_model( - self, - toolcall: ckit_cloudtool.FCloudtoolCall, - model_produced_args: Dict[str, Any], - ) -> str: + async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: try: args = model_produced_args or {} op = str(args.get("op", "help")).strip() @@ -72,7 +187,7 @@ async def called_by_model( return "Error: args.method_id required for op=call." handler = _HANDLERS.get(method_id) if handler is None: - return f"Error: unknown method_id={method_id!r}. Use op=list_methods to see available methods." + return f"Error: unknown method_id={method_id!r}. Use op=list_methods." return await handler(self.client, call_args) except FacebookAuthError as e: return e.message diff --git a/flexus_client_kit/integrations/fi_meta_pages.py b/flexus_client_kit/integrations/fi_meta_pages.py index 903e1e0b..96d29542 100644 --- a/flexus_client_kit/integrations/fi_meta_pages.py +++ b/flexus_client_kit/integrations/fi_meta_pages.py @@ -1,18 +1,17 @@ from __future__ import annotations import logging -from typing import Any, Dict, TYPE_CHECKING +from typing import Any, Dict, List, TYPE_CHECKING from flexus_client_kit import ckit_cloudtool -from flexus_client_kit.integrations.facebook.client import FacebookAdsClient -from flexus_client_kit.integrations.facebook.exceptions import ( +from flexus_client_kit.integrations._fi_meta_helpers import ( + FacebookAdsClient, FacebookAPIError, FacebookAuthError, FacebookValidationError, -) -from flexus_client_kit.integrations.facebook.accounts import ( - list_ad_accounts, get_ad_account_info, update_spending_limit, - list_account_users, list_pages, + format_currency, + format_account_status, + validate_ad_account_id, ) if TYPE_CHECKING: @@ -20,39 +19,157 @@ logger = logging.getLogger("meta_pages") -# Use case: "Manage everything on your Page" -# Covers Facebook Pages, ad accounts, users — the account-level layer above campaigns. +# Use case: "Manage Facebook Pages, ad accounts, users" PROVIDER_NAME = "meta_pages" _HELP = """meta_pages: Manage Facebook Pages and ad accounts. op=help | status | list_methods | call(args={method_id, ...}) - list_pages() -- Facebook Pages you manage (needed for ad creatives) - list_ad_accounts() -- All ad accounts accessible with your token + list_ad_accounts() get_ad_account_info(ad_account_id) update_spending_limit(ad_account_id, spending_limit) list_account_users(ad_account_id) + list_pages() """ +_AD_ACCOUNT_FIELDS = "id,account_id,name,currency,timezone_name,account_status,balance,amount_spent,spend_cap,business{id,name}" +_AD_ACCOUNT_DETAIL_FIELDS = "id,account_id,name,currency,timezone_name,account_status,balance,amount_spent,spend_cap,business,funding_source_details,min_daily_budget,created_time" + + +async def _list_ad_accounts(client: FacebookAdsClient) -> str: + if client.is_test_mode: + return "Found 1 ad account:\n Test Ad Account (ID: act_MOCK_TEST_000) — USD — Active\n" + data = await client.request("GET", "me/adaccounts", params={"fields": _AD_ACCOUNT_FIELDS, "limit": 50}) + accounts = data.get("data", []) + if not accounts: + return "No ad accounts found. You may need to create one in Facebook Business Manager." + business_accounts: Dict[str, List[Any]] = {} + personal_accounts: List[Any] = [] + for acc in accounts: + business = acc.get("business") + if business: + biz_name = business.get("name", f"Business {business.get('id', 'Unknown')}") + if biz_name not in business_accounts: + business_accounts[biz_name] = [] + business_accounts[biz_name].append(acc) + else: + personal_accounts.append(acc) + result = f"Found {len(accounts)} ad account{'s' if len(accounts) != 1 else ''}:\n\n" + for biz_name, biz_accounts in business_accounts.items(): + result += f"Business: {biz_name} ({len(biz_accounts)} accounts)\n" + for acc in biz_accounts: + result += _format_account_summary(acc) + if personal_accounts: + result += f"Personal Accounts ({len(personal_accounts)}):\n" + for acc in personal_accounts: + result += _format_account_summary(acc) + return result + + +def _format_account_summary(acc: Dict[str, Any]) -> str: + currency = acc.get("currency", "USD") + status_text = format_account_status(int(acc.get("account_status", 1))) + result = f" {acc.get('name', 'Unnamed')} (ID: {acc['id']})\n" + result += f" Status: {status_text} | Currency: {currency}\n" + if "amount_spent" in acc: + result += f" Spent: {format_currency(int(acc['amount_spent']), currency)}\n" + return result + + +async def _get_ad_account_info(client: FacebookAdsClient, ad_account_id: str) -> str: + if not ad_account_id: + return "ERROR: ad_account_id required" + try: + ad_account_id = validate_ad_account_id(ad_account_id) + except FacebookValidationError as e: + return f"ERROR: {e.message}" + if client.is_test_mode: + return f"Ad Account:\n {ad_account_id}\n Status: Active\n Currency: USD\n Spent: $1,234.56\n" + acc = await client.request("GET", ad_account_id, params={"fields": _AD_ACCOUNT_DETAIL_FIELDS}) + currency = acc.get("currency", "USD") + status_text = format_account_status(int(acc.get("account_status", 1))) + result = f"Ad Account Details:\n\n {acc.get('name', 'Unnamed')} (ID: {acc['id']})\n" + result += f" Status: {status_text}\n Currency: {currency}\n Timezone: {acc.get('timezone_name', 'N/A')}\n" + result += f" Balance: {format_currency(int(acc.get('balance', 0)), currency)}\n" + result += f" Total Spent: {format_currency(int(acc.get('amount_spent', 0)), currency)}\n" + spend_cap = int(acc.get("spend_cap", 0)) + if spend_cap > 0: + amount_spent = int(acc.get("amount_spent", 0)) + result += f" Spend Cap: {format_currency(spend_cap, currency)}\n" + result += f" Remaining: {format_currency(spend_cap - amount_spent, currency)}\n" + if acc.get("business"): + biz = acc["business"] + result += f" Business: {biz.get('name', 'N/A')} (ID: {biz.get('id', 'N/A')})\n" + return result + + +async def _update_spending_limit(client: FacebookAdsClient, ad_account_id: str, spending_limit: int) -> str: + if not ad_account_id: + return "ERROR: ad_account_id required" + try: + ad_account_id = validate_ad_account_id(ad_account_id) + except FacebookValidationError as e: + return f"ERROR: {e.message}" + spending_limit = int(spending_limit) + if spending_limit < 0: + return "ERROR: spending_limit must be positive" + if client.is_test_mode: + return f"Spending limit updated to {format_currency(spending_limit)} for {ad_account_id}" + result = await client.request("POST", ad_account_id, data={"spend_cap": spending_limit}) + return f"Spending limit updated to {format_currency(spending_limit)} for {ad_account_id}" if result.get("success") else f"Failed. Response: {result}" + + +async def _list_account_users(client: FacebookAdsClient, ad_account_id: str) -> str: + if not ad_account_id: + return "ERROR: ad_account_id required" + try: + ad_account_id = validate_ad_account_id(ad_account_id) + except FacebookValidationError as e: + return f"ERROR: {e.message}" + if client.is_test_mode: + return f"Users for {ad_account_id}:\n Test User (ID: 123456789) — ADMIN\n" + data = await client.request("GET", f"{ad_account_id}/users", params={"fields": "id,name,role,status", "limit": 50}) + users = data.get("data", []) + if not users: + return f"No users found for {ad_account_id}" + result = f"Users for {ad_account_id} ({len(users)}):\n\n" + for u in users: + result += f" {u.get('name', 'Unknown')} (ID: {u.get('id', 'N/A')}) — {u.get('role', 'N/A')}\n" + return result + + +async def _list_pages(client: FacebookAdsClient) -> str: + if client.is_test_mode: + return "Pages you manage:\n Test Page (ID: 111111111) — ACTIVE\n" + data = await client.request("GET", "me/accounts", params={"fields": "id,name,category,tasks,access_token", "limit": 50}) + pages = data.get("data", []) + if not pages: + return "No pages found. You need to be an admin of at least one Facebook Page to create ads." + result = f"Pages you manage ({len(pages)}):\n\n" + for page in pages: + tasks = ", ".join(page.get("tasks", [])) + result += f" {page.get('name', 'Unnamed')} (ID: {page['id']})\n" + result += f" Category: {page.get('category', 'N/A')}\n" + if tasks: + result += f" Tasks: {tasks}\n" + result += "\n" + return result + + _HANDLERS: Dict[str, Any] = { - "list_pages": lambda c, a: list_pages(c), - "list_ad_accounts": lambda c, a: list_ad_accounts(c), - "get_ad_account_info": lambda c, a: get_ad_account_info(c, a.get("ad_account_id", "")), - "update_spending_limit": lambda c, a: update_spending_limit(c, a.get("ad_account_id", ""), a.get("spending_limit", 0)), - "list_account_users": lambda c, a: list_account_users(c, a.get("ad_account_id", "")), + "list_ad_accounts": lambda c, a: _list_ad_accounts(c), + "get_ad_account_info": lambda c, a: _get_ad_account_info(c, a.get("ad_account_id", "")), + "update_spending_limit": lambda c, a: _update_spending_limit(c, a.get("ad_account_id", ""), int(a.get("spending_limit", 0))), + "list_account_users": lambda c, a: _list_account_users(c, a.get("ad_account_id", "")), + "list_pages": lambda c, a: _list_pages(c), } class IntegrationMetaPages: - # Wraps FacebookAdsClient for page/account-level operations. def __init__(self, rcx: "ckit_bot_exec.RobotContext"): self.client = FacebookAdsClient(rcx.fclient, rcx) - async def called_by_model( - self, - toolcall: ckit_cloudtool.FCloudtoolCall, - model_produced_args: Dict[str, Any], - ) -> str: + async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: try: args = model_produced_args or {} op = str(args.get("op", "help")).strip() @@ -70,7 +187,7 @@ async def called_by_model( return "Error: args.method_id required for op=call." handler = _HANDLERS.get(method_id) if handler is None: - return f"Error: unknown method_id={method_id!r}. Use op=list_methods to see available methods." + return f"Error: unknown method_id={method_id!r}. Use op=list_methods." return await handler(self.client, call_args) except FacebookAuthError as e: return e.message