From b5b0d1e7a536631d8e8751b3de796c3ff3dd0e63 Mon Sep 17 00:00:00 2001 From: Illia Sapryga Date: Tue, 5 May 2026 20:07:07 -0500 Subject: [PATCH 1/2] feat(ads): dynamic Google Ads enum introspection helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds adloop.ads.enums.enum_names() — pulls valid enum member names straight from the google-ads SDK at the API version we're pinned to (see adloop.ads.client.GOOGLE_ADS_API_VERSION). Drops the need to hand-maintain parallel lists like: _VALID_CONVERSION_ACTION_TYPES = {"AD_CALL", "WEBSITE_CALL", ...} — which otherwise drift every time the SDK or API version updates. The helper is module-cached (functools.lru_cache) so the no-auth GoogleAdsClient used for introspection is built once per process, and every enum_names() call after the first is essentially free. UNSPECIFIED + UNKNOWN sentinels are dropped by default since they are protobuf zero-values that should never appear in user input. Pure addition. No existing validators changed in this PR; downstream PRs (conversion-action tools, in-place asset updates, promotion helper refactors) will switch their hardcoded enum sets to enum_names() calls in their own commits. 12 unit tests cover: - enum_names returns a frozenset - UNSPECIFIED + UNKNOWN are excluded by default and includable on opt-in - Critical members (AD_CALL, WEBSITE_CALL, GOOGLE_SEARCH_ATTRIBUTION_DATA_DRIVEN, USE_RESOURCE_LEVEL_CALL_CONVERSION_ACTION, BLACK_FRIDAY) are present - ConversionActionCountingTypeEnum returns exactly {ONE_PER_CLICK, MANY_PER_CLICK} - Unknown enum names raise AttributeError - LRU cache returns the same frozenset instance on repeat calls - The introspection client is memoized --- src/adloop/ads/enums.py | 64 ++++++++++++++++++++++ tests/test_ads_enums.py | 115 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 src/adloop/ads/enums.py create mode 100644 tests/test_ads_enums.py diff --git a/src/adloop/ads/enums.py b/src/adloop/ads/enums.py new file mode 100644 index 0000000..1d1092a --- /dev/null +++ b/src/adloop/ads/enums.py @@ -0,0 +1,64 @@ +"""Google Ads enum introspection — pulls valid enum names from the SDK. + +Avoids hardcoded enum sets in validators. The google-ads SDK ships the +canonical list for every enum at the API version we're pinned to; this +module surfaces those lists directly so AdLoop validation stays in sync +with whatever the SDK supports without us maintaining a parallel copy. + +Usage: + from adloop.ads.enums import enum_names + _VALID_TYPES = enum_names("ConversionActionTypeEnum") + +The result is a frozenset of member name strings (e.g. {"AD_CALL", ...}), +with sentinel values UNSPECIFIED + UNKNOWN dropped by default. +""" +from __future__ import annotations + +import functools + + +@functools.lru_cache(maxsize=None) +def _enum_introspection_client(): + """Memoized no-auth GoogleAdsClient used purely for enum introspection. + + The client constructor doesn't make any network calls and doesn't + validate credentials beyond requiring SOMETHING in the developer-token + field — perfect for reading the bundled enum protos. We cache it so + every enum_names() call after the first is essentially free. + """ + from google.ads.googleads.client import GoogleAdsClient + + from adloop.ads.client import GOOGLE_ADS_API_VERSION + + return GoogleAdsClient( + credentials=None, + developer_token="adloop-enum-introspection-not-used", + use_proto_plus=True, + version=GOOGLE_ADS_API_VERSION, + ) + + +@functools.lru_cache(maxsize=None) +def enum_names( + enum_attr: str, *, exclude_unspecified: bool = True +) -> frozenset[str]: + """Return all member names of a Google Ads enum, as a frozenset. + + Args: + enum_attr: the attribute on ``client.enums``, e.g. + ``"ConversionActionTypeEnum"`` or ``"AssetFieldTypeEnum"``. + exclude_unspecified: when True (default) drops the sentinel values + ``UNSPECIFIED`` and ``UNKNOWN`` — those are protobuf defaults, + never valid for user input. + + Raises: + AttributeError: if ``enum_attr`` doesn't exist on ``client.enums``. + + Caching: the result is memoized for the lifetime of the process, so + repeated calls return the same frozenset instance. + """ + enum_cls = getattr(_enum_introspection_client().enums, enum_attr) + return frozenset( + m.name for m in enum_cls + if not exclude_unspecified or m.name not in ("UNSPECIFIED", "UNKNOWN") + ) diff --git a/tests/test_ads_enums.py b/tests/test_ads_enums.py new file mode 100644 index 0000000..175420a --- /dev/null +++ b/tests/test_ads_enums.py @@ -0,0 +1,115 @@ +"""Tests for the dynamic Google Ads enum introspection helper.""" +from __future__ import annotations + +import pytest + +from adloop.ads.enums import enum_names, _enum_introspection_client + + +class TestEnumNames: + def test_returns_frozenset(self): + result = enum_names("ConversionActionTypeEnum") + assert isinstance(result, frozenset) + + def test_excludes_unspecified_and_unknown_by_default(self): + result = enum_names("ConversionActionTypeEnum") + assert "UNSPECIFIED" not in result + assert "UNKNOWN" not in result + + def test_can_include_unspecified_when_requested(self): + result = enum_names( + "ConversionActionTypeEnum", exclude_unspecified=False + ) + assert "UNSPECIFIED" in result + assert "UNKNOWN" in result + + def test_known_conversion_action_types_present(self): + result = enum_names("ConversionActionTypeEnum") + # The members AdLoop has built tooling around must always exist. + for required in ( + "AD_CALL", "WEBSITE_CALL", "WEBPAGE", "WEBPAGE_CODELESS", + "GOOGLE_ANALYTICS_4_CUSTOM", + ): + assert required in result, f"missing {required} from SDK enum" + + def test_call_conversion_reporting_state_complete(self): + result = enum_names("CallConversionReportingStateEnum") + assert "DISABLED" in result + assert "USE_ACCOUNT_LEVEL_CALL_CONVERSION_ACTION" in result + assert "USE_RESOURCE_LEVEL_CALL_CONVERSION_ACTION" in result + + def test_attribution_models_complete(self): + result = enum_names("AttributionModelEnum") + # The two attribution models AdLoop documents in conversion-action + # docstrings must always be valid. + assert "GOOGLE_ADS_LAST_CLICK" in result + assert "GOOGLE_SEARCH_ATTRIBUTION_DATA_DRIVEN" in result + + def test_counting_types_minimal_pair(self): + result = enum_names("ConversionActionCountingTypeEnum") + assert result == frozenset({"ONE_PER_CLICK", "MANY_PER_CLICK"}) + + def test_promotion_extension_occasion_complete(self): + result = enum_names("PromotionExtensionOccasionEnum") + # Common occasions BGI / users will reach for + for required in ( + "BLACK_FRIDAY", "CYBER_MONDAY", "CHRISTMAS", + "MOTHERS_DAY", "FATHERS_DAY", "BACK_TO_SCHOOL", + ): + assert required in result, f"missing {required}" + + def test_promotion_discount_modifier(self): + result = enum_names("PromotionExtensionDiscountModifierEnum") + assert "UP_TO" in result + + def test_unknown_enum_raises(self): + with pytest.raises(AttributeError): + enum_names("ThisEnumDoesNotExist") + + def test_lru_cache_returns_same_instance(self): + a = enum_names("ConversionActionCountingTypeEnum") + b = enum_names("ConversionActionCountingTypeEnum") + assert a is b, "expected memoized identical frozenset" + + def test_introspection_client_memoized(self): + c1 = _enum_introspection_client() + c2 = _enum_introspection_client() + assert c1 is c2 + + +class TestModulesUseDynamicEnums: + """Confirm the validators we refactored actually pull from the SDK.""" + + def test_conversion_actions_uses_sdk_types(self): + from adloop.ads import conversion_actions + + sdk_types = enum_names("ConversionActionTypeEnum") + # The module-level constant should BE the dynamic frozenset. + assert conversion_actions._VALID_TYPES == sdk_types + # Should include members the old hardcoded list missed + # (sanity check: the dynamic list is a strict superset). + assert "CLICK_TO_CALL" in conversion_actions._VALID_TYPES + + def test_conversion_actions_uses_sdk_categories(self): + from adloop.ads import conversion_actions + + assert ( + conversion_actions._VALID_CATEGORIES + == enum_names("ConversionActionCategoryEnum") + ) + + def test_write_promotion_occasions_dynamic(self): + from adloop.ads import write + + assert ( + write._VALID_PROMOTION_OCCASIONS + == enum_names("PromotionExtensionOccasionEnum") + ) + + def test_write_call_reporting_states_dynamic(self): + from adloop.ads import write + + assert ( + write._VALID_CALL_REPORTING_STATES + == enum_names("CallConversionReportingStateEnum") + ) From bacb626911707e228f4c7fc9c4844cdc86c5878a Mon Sep 17 00:00:00 2001 From: Illia Sapryga Date: Tue, 5 May 2026 20:11:13 -0500 Subject: [PATCH 2/2] feat(ads): asset extension tools + RSA update + conversion-action mgmt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the complete asset-extension and conversion-action management surface that AdLoop was missing. Three logical chunks bundled here because they share `ads/write.py`'s dispatch + apply infrastructure and would conflict if split into separate PRs. ## 1. Asset extension tools (call asset, location asset, ad schedule, geo exclusions) - `draft_call_asset` — campaign or customer scope, E.164 normalization, ad-schedule restriction, optional conversion-action override - `draft_location_asset` — Google Business Profile-backed AssetSet (LOCATION_SYNC), with label/listing filters - `draft_image_assets` — campaign image extensions from local files with MIME + dimension validation - `draft_callouts`, `draft_structured_snippets`, `draft_sitelinks` — refactored to support BOTH campaign-scope AND customer-scope (the account-level CustomerAsset path that propagates to every eligible campaign automatically) - `add_ad_schedule` — Mon-Sat 8am-9pm-style scheduling via AdScheduleInfo CampaignCriterion - `add_geo_exclusions` — negative geo CampaignCriterion records to shrink a broad include list - `_apply_assets()` shared helper routing a populate fn through either CampaignAsset or CustomerAsset linkage based on scope - Phone-number E.164 normalization with US/CA + EU trunk-prefix handling ## 2. RSA in-place update (`update_responsive_search_ad`) - Update existing RSAs without delete-then-recreate - Partial update via FieldMask — only the fields the caller passes are modified - Headlines/descriptions accept either bare strings (unpinned) or {"text": "...", "pinned_field": "HEADLINE_1"} dicts (pinned) ## 3. Conversion-action management (3 new tools) - `draft_create_conversion_action` — AD_CALL / WEBSITE_CALL / WEBPAGE / GA4_CUSTOM with value, threshold, attribution model, counting type - `draft_update_conversion_action` — partial update with FieldMask; rename / promote-demote / set value / change duration threshold - `draft_remove_conversion_action` — irreversible removal (warns that SMART_CAMPAIGN_* and GOOGLE_HOSTED types reject mutation) The 3 conversion-action tools live in their own module `adloop/ads/conversion_actions.py` and route through dispatch via `_apply_*_conversion_action_route` shims kept in `ads/write.py`. ## Other changes - `link_asset_to_customer` — promote existing Asset rows from campaign-scope to account-level (CustomerAsset) - `update_call_asset` / `update_sitelink` / `update_callout` — in-place asset updates with FieldMask - `draft_promotion` / `update_promotion` — PromotionAsset create + swap (PromotionAsset is immutable; update is implemented as create-new-link-old-unlink) - Promotion module uses `enum_names("PromotionExtensionOccasionEnum")` + `enum_names("PromotionExtensionDiscountModifierEnum")` from the dynamic-enums helper - Conversion-actions module uses `enum_names("...")` for all 4 Google Ads enums it validates against — drops 4 hardcoded enum sets that were drifting from the SDK - Auto-cleanup script `scripts/cleanup_sitelink_links.py` for duplicate sitelink CampaignAsset detection ## Tests - `tests/test_ads_extensions.py` — comprehensive validation + apply-handler tests for every new function (uses fake services mirroring the google-ads SDK protos; no network) - `tests/test_conversion_actions.py` — 29 tests - `tests/test_update_rsa.py` — RSA update integration tests - All 430 tests pass --- scripts/cleanup_sitelink_links.py | 120 + src/adloop/ads/conversion_actions.py | 503 +++ src/adloop/ads/write.py | 4742 +++++++++++++++++++------- src/adloop/server.py | 2136 ++++++++++-- tests/test_ads_extensions.py | 2975 ++++++++++++++++ tests/test_ads_write.py | 7 +- tests/test_conversion_actions.py | 458 +++ tests/test_update_rsa.py | 622 ++++ 8 files changed, 9970 insertions(+), 1593 deletions(-) create mode 100644 scripts/cleanup_sitelink_links.py create mode 100644 src/adloop/ads/conversion_actions.py create mode 100644 tests/test_ads_extensions.py create mode 100644 tests/test_conversion_actions.py create mode 100644 tests/test_update_rsa.py diff --git a/scripts/cleanup_sitelink_links.py b/scripts/cleanup_sitelink_links.py new file mode 100644 index 0000000..422784f --- /dev/null +++ b/scripts/cleanup_sitelink_links.py @@ -0,0 +1,120 @@ +"""Bulk removal of legacy campaign-level sitelink/promotion links AND empty +customer-level sitelink placeholders, after promoting clean copies to customer +scope. + +Phases 1-3 of the cleanup ran via AdLoop tools (link_asset_to_customer and +draft_sitelinks). This handles phases 4-5 (the bulk removes) in two API calls +instead of 31 separate AdLoop plans. + +Customer assets we just added (DO NOT remove): + 13 sitelinks: 307349941499, 307350369410, 307350415811, 307354298834, + 307443066358, 307447164526, 308700028867, 310581372095, + 310691883138, 314519875551, 314519875554, 314519875557, + 314519875560 + 7 new sitelinks: 357980743926, 357980743929, 357980743932, 357980743935, + 357980743938, 357980743941, 357980743944 + 1 promotion: 310667906146 +""" +from __future__ import annotations + +from adloop.ads.client import get_ads_client +from adloop.config import load_config + +CUSTOMER_ID = "8860868272" + +# (campaign_id, asset_id, field_type) +CAMPAIGN_ASSET_REMOVALS = [ + # NE — 7 sitelinks + ("23504229772", "307350369410", "SITELINK"), + ("23504229772", "325613094542", "SITELINK"), + ("23504229772", "325613094545", "SITELINK"), + ("23504229772", "325613094548", "SITELINK"), + ("23504229772", "325613094551", "SITELINK"), + ("23504229772", "325613094554", "SITELINK"), + ("23504229772", "325613094557", "SITELINK"), + # SE — 10 sitelinks + ("23504046862", "307350369410", "SITELINK"), + ("23504046862", "325601697149", "SITELINK"), + ("23504046862", "325601697152", "SITELINK"), + ("23504046862", "325601697155", "SITELINK"), + ("23504046862", "325613094542", "SITELINK"), + ("23504046862", "325613094545", "SITELINK"), + ("23504046862", "325613094548", "SITELINK"), + ("23504046862", "325613094551", "SITELINK"), + ("23504046862", "325613094554", "SITELINK"), + ("23504046862", "325613094557", "SITELINK"), + # Shingle Springs — 6 sitelinks + ("23246712952", "307354298834", "SITELINK"), + ("23246712952", "307447164526", "SITELINK"), + ("23246712952", "314519875551", "SITELINK"), + ("23246712952", "314519875554", "SITELINK"), + ("23246712952", "314519875557", "SITELINK"), + ("23246712952", "314519875560", "SITELINK"), + # PMax — 1 sitelink (empty placeholder) + ("23803368702", "355786191833", "SITELINK"), + # Promotion — 3 campaign links (NE/SE/SS) for asset 310667906146 + ("23504229772", "310667906146", "PROMOTION"), + ("23504046862", "310667906146", "PROMOTION"), + ("23246712952", "310667906146", "PROMOTION"), +] + +# (asset_id, field_type) — the 4 empty customer-level sitelink placeholders +CUSTOMER_ASSET_REMOVALS = [ + ("313519233179", "SITELINK"), # Paint Scratch Repair (empty) + ("313519233182", "SITELINK"), # Auto Body Repair (empty) + ("313519233185", "SITELINK"), # Bumper Repair (empty) + ("313519233188", "SITELINK"), # Our Services (empty) +] + + +def main() -> None: + config = load_config() + client = get_ads_client(config) + + ca_service = client.get_service("CampaignAssetService") + cust_asset_service = client.get_service("CustomerAssetService") + + # ----- Phase 4: campaign_asset removals ----- + operations = [] + for campaign_id, asset_id, field_type in CAMPAIGN_ASSET_REMOVALS: + op = client.get_type("CampaignAssetOperation") + op.remove = ( + f"customers/{CUSTOMER_ID}/campaignAssets/" + f"{campaign_id}~{asset_id}~{field_type}" + ) + operations.append(op) + + print(f"Removing {len(operations)} campaign_asset links ...") + request = client.get_type("MutateCampaignAssetsRequest") + request.customer_id = CUSTOMER_ID + request.operations.extend(operations) + request.partial_failure = True + response = ca_service.mutate_campaign_assets(request=request) + success = sum(1 for r in response.results if r.resource_name) + print(f" campaign_asset: {success} removed") + if response.partial_failure_error.message: + print(f" partial failure: {response.partial_failure_error.message}") + + # ----- Phase 5: customer_asset placeholder removals ----- + operations = [] + for asset_id, field_type in CUSTOMER_ASSET_REMOVALS: + op = client.get_type("CustomerAssetOperation") + op.remove = ( + f"customers/{CUSTOMER_ID}/customerAssets/{asset_id}~{field_type}" + ) + operations.append(op) + + print(f"\nRemoving {len(operations)} empty customer_asset placeholders ...") + request = client.get_type("MutateCustomerAssetsRequest") + request.customer_id = CUSTOMER_ID + request.operations.extend(operations) + request.partial_failure = True + response = cust_asset_service.mutate_customer_assets(request=request) + success = sum(1 for r in response.results if r.resource_name) + print(f" customer_asset: {success} removed") + if response.partial_failure_error.message: + print(f" partial failure: {response.partial_failure_error.message}") + + +if __name__ == "__main__": + main() diff --git a/src/adloop/ads/conversion_actions.py b/src/adloop/ads/conversion_actions.py new file mode 100644 index 0000000..5e13566 --- /dev/null +++ b/src/adloop/ads/conversion_actions.py @@ -0,0 +1,503 @@ +"""Conversion-action write tools — Google Ads ConversionActionService. + +All operations follow the AdLoop safety pattern: + 1. draft_* → creates a ChangePlan, stores it, returns plan_id + 2. confirm_and_apply(plan_id) → executes via the Google Ads API + +Supported types (conversion_action.type): + AD_CALL — calls from Call assets in ads + WEBSITE_CALL — Google Forwarding Number calls (uses + phone_call_duration_seconds threshold) + WEBPAGE — page-load conversions with code-based tracking + WEBPAGE_CODELESS — page-load conversions detected by Ads (no snippet) + GOOGLE_ANALYTICS_4_CUSTOM — imported from GA4 (custom event) + GOOGLE_ANALYTICS_4_PURCHASE — imported from GA4 (purchase event) + UPLOAD_CALLS, UPLOAD_CLICKS — offline imports + +NOT supported here (Google manages them — mutations are rejected with +MUTATE_NOT_ALLOWED): + SMART_CAMPAIGN_* — auto-created by Smart Campaigns + GOOGLE_HOSTED — auto-created by Google Business Profile / LSA links +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from adloop.ads.enums import enum_names + +if TYPE_CHECKING: + from adloop.config import AdLoopConfig + + +# Pulled dynamically from the google-ads SDK at the API version we're +# pinned to (see adloop.ads.client.GOOGLE_ADS_API_VERSION). Keeps the +# validators in sync with whatever the SDK supports — no hand-maintained +# parallel lists to drift. +_VALID_TYPES = enum_names("ConversionActionTypeEnum") +_VALID_CATEGORIES = enum_names("ConversionActionCategoryEnum") +_VALID_COUNTING_TYPES = enum_names("ConversionActionCountingTypeEnum") +_VALID_ATTRIBUTION_MODELS = enum_names("AttributionModelEnum") + +# These types ARE in ConversionActionTypeEnum but Google rejects mutations +# on them with MUTATE_NOT_ALLOWED (they're auto-created by Smart Campaigns, +# Local Services, and Business Profile links). We don't filter them from +# `_VALID_TYPES` — the SDK accepts them syntactically — but warn callers. +_AUTO_MANAGED_TYPES = frozenset({ + "SMART_CAMPAIGN_TRACKED_CALLS", + "SMART_CAMPAIGN_MAP_DIRECTIONS", + "SMART_CAMPAIGN_MAP_CLICKS_TO_CALL", + "SMART_CAMPAIGN_AD_CLICKS_TO_CALL", + "GOOGLE_HOSTED", +}) + + +# --------------------------------------------------------------------------- +# Validators +# --------------------------------------------------------------------------- + + +def _validate_create_inputs( + *, + name: str, + type_: str, + category: str, + counting_type: str, + default_value: float, + currency_code: str, + phone_call_duration_seconds: int, + click_through_window_days: int, + view_through_window_days: int, + attribution_model: str, +) -> list[str]: + errors: list[str] = [] + if not name or not name.strip(): + errors.append("name is required") + if type_ not in _VALID_TYPES: + errors.append( + f"type '{type_}' invalid; valid: {sorted(_VALID_TYPES)}" + ) + if category and category not in _VALID_CATEGORIES: + errors.append( + f"category '{category}' invalid; valid: {sorted(_VALID_CATEGORIES)}" + ) + if counting_type and counting_type not in _VALID_COUNTING_TYPES: + errors.append( + f"counting_type '{counting_type}' invalid; valid: " + f"{sorted(_VALID_COUNTING_TYPES)}" + ) + if default_value < 0: + errors.append("default_value must be >= 0") + if currency_code and len(currency_code) != 3: + errors.append( + f"currency_code '{currency_code}' must be a 3-letter ISO code" + ) + if phone_call_duration_seconds and phone_call_duration_seconds < 0: + errors.append("phone_call_duration_seconds must be >= 0") + if (click_through_window_days + and not (1 <= click_through_window_days <= 90)): + errors.append( + "click_through_window_days must be between 1 and 90" + ) + if (view_through_window_days + and not (1 <= view_through_window_days <= 30)): + errors.append( + "view_through_window_days must be between 1 and 30" + ) + if attribution_model and attribution_model not in _VALID_ATTRIBUTION_MODELS: + errors.append( + f"attribution_model '{attribution_model}' invalid; valid: " + f"{sorted(_VALID_ATTRIBUTION_MODELS)}" + ) + return errors + + +def _validate_update_inputs( + *, + counting_type: str, + default_value: float, + currency_code: str, + phone_call_duration_seconds: int, + click_through_window_days: int, + view_through_window_days: int, + attribution_model: str, +) -> list[str]: + errors: list[str] = [] + if counting_type and counting_type not in _VALID_COUNTING_TYPES: + errors.append( + f"counting_type '{counting_type}' invalid; valid: " + f"{sorted(_VALID_COUNTING_TYPES)}" + ) + if default_value < 0: + errors.append("default_value must be >= 0") + if currency_code and len(currency_code) != 3: + errors.append( + f"currency_code '{currency_code}' must be a 3-letter ISO code" + ) + if phone_call_duration_seconds and phone_call_duration_seconds < 0: + errors.append("phone_call_duration_seconds must be >= 0") + if (click_through_window_days + and not (1 <= click_through_window_days <= 90)): + errors.append( + "click_through_window_days must be between 1 and 90" + ) + if (view_through_window_days + and not (1 <= view_through_window_days <= 30)): + errors.append( + "view_through_window_days must be between 1 and 30" + ) + if attribution_model and attribution_model not in _VALID_ATTRIBUTION_MODELS: + errors.append( + f"attribution_model '{attribution_model}' invalid; valid: " + f"{sorted(_VALID_ATTRIBUTION_MODELS)}" + ) + return errors + + +# --------------------------------------------------------------------------- +# Draft tools (return PREVIEW + plan_id) +# --------------------------------------------------------------------------- + + +def draft_create_conversion_action( + config: AdLoopConfig, + *, + customer_id: str = "", + name: str, + type_: str, + category: str = "DEFAULT", + default_value: float = 0, + currency_code: str = "USD", + always_use_default_value: bool = False, + counting_type: str = "ONE_PER_CLICK", + phone_call_duration_seconds: int = 0, + primary_for_goal: bool = True, + include_in_conversions_metric: bool = True, + click_through_window_days: int = 0, + view_through_window_days: int = 0, + attribution_model: str = "", +) -> dict: + """Draft a new ConversionAction — returns a PREVIEW. + + type_: the ConversionAction.type enum value (AD_CALL, WEBSITE_CALL, + WEBPAGE, WEBPAGE_CODELESS, GOOGLE_ANALYTICS_4_CUSTOM, etc.). + category: the conversion category (PHONE_CALL_LEAD, SUBMIT_LEAD_FORM, + PURCHASE, etc.). Defaults to DEFAULT. + default_value: monetary value attributed to each conversion. Set 250 + for high-intent lead actions (per BGI Lead Conversion playbook). + always_use_default_value: when True, transaction values from the + snippet/import are ignored and default_value is used instead. + counting_type: ONE_PER_CLICK (recommended for lead gen — one click, + one conversion no matter how many events fire) or MANY_PER_CLICK + (better for ecommerce where multiple purchases per click are real). + phone_call_duration_seconds: ONLY meaningful for PHONE_CALL_LEAD + category. The call must last at least this many seconds to count. + primary_for_goal: True = drives Smart Bidding optimization; + False = Secondary (records but doesn't affect bidding). + include_in_conversions_metric: True (default) = appears in the + "Conversions" column; False = "All conversions" only. + click_through_window_days / view_through_window_days: attribution + windows. 30/1 is the typical lead-gen pair. + attribution_model: leave empty for the default. For data-driven, + pass GOOGLE_SEARCH_ATTRIBUTION_DATA_DRIVEN. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("create_conversion_action", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + errors = _validate_create_inputs( + name=name, + type_=type_, + category=category, + counting_type=counting_type, + default_value=default_value, + currency_code=currency_code, + phone_call_duration_seconds=phone_call_duration_seconds, + click_through_window_days=click_through_window_days, + view_through_window_days=view_through_window_days, + attribution_model=attribution_model, + ) + if errors: + return {"error": "Validation failed", "details": errors} + + plan = ChangePlan( + operation="create_conversion_action", + entity_type="conversion_action", + entity_id="", + customer_id=customer_id, + changes={ + "name": name.strip(), + "type": type_, + "category": category, + "default_value": float(default_value), + "currency_code": currency_code.upper(), + "always_use_default_value": bool(always_use_default_value), + "counting_type": counting_type, + "phone_call_duration_seconds": int(phone_call_duration_seconds or 0), + "primary_for_goal": bool(primary_for_goal), + "include_in_conversions_metric": bool(include_in_conversions_metric), + "click_through_window_days": int(click_through_window_days or 0), + "view_through_window_days": int(view_through_window_days or 0), + "attribution_model": attribution_model, + }, + ) + store_plan(plan) + return plan.to_preview() + + +def draft_update_conversion_action( + config: AdLoopConfig, + *, + customer_id: str = "", + conversion_action_id: str, + name: str = "", + primary_for_goal: bool | None = None, + default_value: float = 0, + currency_code: str = "", + always_use_default_value: bool | None = None, + counting_type: str = "", + phone_call_duration_seconds: int = 0, + include_in_conversions_metric: bool | None = None, + click_through_window_days: int = 0, + view_through_window_days: int = 0, + attribution_model: str = "", +) -> dict: + """Draft a partial UPDATE of an existing ConversionAction — returns PREVIEW. + + Only the parameters you pass non-empty/non-default will be sent to the + API. Use this to rename, demote a Primary to Secondary, change value, + adjust the call-duration threshold, or change attribution settings. + + conversion_action_id: numeric ID. Find via: + SELECT conversion_action.id, conversion_action.name FROM conversion_action + + Note: Google rejects mutations on SMART_CAMPAIGN_* and GOOGLE_HOSTED + types with MUTATE_NOT_ALLOWED. Catch and report this at apply time. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("update_conversion_action", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + if not conversion_action_id: + return {"error": "conversion_action_id is required"} + + errors = _validate_update_inputs( + counting_type=counting_type, + default_value=default_value, + currency_code=currency_code, + phone_call_duration_seconds=phone_call_duration_seconds, + click_through_window_days=click_through_window_days, + view_through_window_days=view_through_window_days, + attribution_model=attribution_model, + ) + if errors: + return {"error": "Validation failed", "details": errors} + + # Track which fields the caller actually wants to update so we build + # the right field_mask at apply time. + changes: dict = {"conversion_action_id": str(conversion_action_id)} + if name: + changes["name"] = name.strip() + if primary_for_goal is not None: + changes["primary_for_goal"] = bool(primary_for_goal) + if default_value: + changes["default_value"] = float(default_value) + if currency_code: + changes["currency_code"] = currency_code.upper() + if always_use_default_value is not None: + changes["always_use_default_value"] = bool(always_use_default_value) + if counting_type: + changes["counting_type"] = counting_type + if phone_call_duration_seconds: + changes["phone_call_duration_seconds"] = int(phone_call_duration_seconds) + if include_in_conversions_metric is not None: + changes["include_in_conversions_metric"] = bool( + include_in_conversions_metric + ) + if click_through_window_days: + changes["click_through_window_days"] = int(click_through_window_days) + if view_through_window_days: + changes["view_through_window_days"] = int(view_through_window_days) + if attribution_model: + changes["attribution_model"] = attribution_model + + if len(changes) == 1: # only conversion_action_id + return {"error": "No fields to update"} + + plan = ChangePlan( + operation="update_conversion_action", + entity_type="conversion_action", + entity_id=str(conversion_action_id), + customer_id=customer_id, + changes=changes, + ) + store_plan(plan) + return plan.to_preview() + + +def draft_remove_conversion_action( + config: AdLoopConfig, + *, + customer_id: str = "", + conversion_action_id: str, +) -> dict: + """Draft a REMOVAL of a ConversionAction — returns PREVIEW. + + Removed conversion actions stop counting and disappear from goal lists. + Historical data is preserved. SMART_CAMPAIGN_* and GOOGLE_HOSTED types + cannot be removed via API (Google manages them); the apply will fail + with MUTATE_NOT_ALLOWED for those. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("remove_conversion_action", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + if not conversion_action_id: + return {"error": "conversion_action_id is required"} + + plan = ChangePlan( + operation="remove_conversion_action", + entity_type="conversion_action", + entity_id=str(conversion_action_id), + customer_id=customer_id, + changes={"conversion_action_id": str(conversion_action_id)}, + ) + store_plan(plan) + preview = plan.to_preview() + preview["warnings"] = [ + "Removing a ConversionAction is irreversible. Smart Campaign / GBP-" + "managed types reject mutation with MUTATE_NOT_ALLOWED." + ] + return preview + + +# --------------------------------------------------------------------------- +# Apply handlers +# --------------------------------------------------------------------------- + + +def _apply_create_conversion_action(client: object, cid: str, changes: dict) -> dict: + """Create a new ConversionAction.""" + svc = client.get_service("ConversionActionService") + op = client.get_type("ConversionActionOperation") + ca = op.create + ca.name = changes["name"] + ca.type_ = getattr(client.enums.ConversionActionTypeEnum, changes["type"]) + ca.category = getattr( + client.enums.ConversionActionCategoryEnum, changes["category"] + ) + ca.status = client.enums.ConversionActionStatusEnum.ENABLED + ca.counting_type = getattr( + client.enums.ConversionActionCountingTypeEnum, changes["counting_type"] + ) + ca.value_settings.default_value = changes["default_value"] + ca.value_settings.default_currency_code = changes["currency_code"] + ca.value_settings.always_use_default_value = changes["always_use_default_value"] + ca.primary_for_goal = changes["primary_for_goal"] + ca.include_in_conversions_metric = changes["include_in_conversions_metric"] + if changes.get("phone_call_duration_seconds"): + ca.phone_call_duration_seconds = changes["phone_call_duration_seconds"] + if changes.get("click_through_window_days"): + ca.click_through_lookback_window_days = changes["click_through_window_days"] + if changes.get("view_through_window_days"): + ca.view_through_lookback_window_days = changes["view_through_window_days"] + if changes.get("attribution_model"): + ca.attribution_model_settings.attribution_model = getattr( + client.enums.AttributionModelEnum, changes["attribution_model"] + ) + + response = svc.mutate_conversion_actions( + customer_id=cid, operations=[op] + ) + return {"resource_name": response.results[0].resource_name} + + +def _apply_update_conversion_action(client: object, cid: str, changes: dict) -> dict: + """Partial update of an existing ConversionAction. + + Builds a FieldMask listing only the fields the caller wanted to update. + """ + from google.protobuf import field_mask_pb2 + + svc = client.get_service("ConversionActionService") + op = client.get_type("ConversionActionOperation") + ca = op.update + ca.resource_name = svc.conversion_action_path( + cid, changes["conversion_action_id"] + ) + + paths: list[str] = [] + + if "name" in changes: + ca.name = changes["name"] + paths.append("name") + if "primary_for_goal" in changes: + ca.primary_for_goal = changes["primary_for_goal"] + paths.append("primary_for_goal") + if "default_value" in changes: + ca.value_settings.default_value = changes["default_value"] + paths.append("value_settings.default_value") + if "currency_code" in changes: + ca.value_settings.default_currency_code = changes["currency_code"] + paths.append("value_settings.default_currency_code") + if "always_use_default_value" in changes: + ca.value_settings.always_use_default_value = changes["always_use_default_value"] + paths.append("value_settings.always_use_default_value") + if "counting_type" in changes: + ca.counting_type = getattr( + client.enums.ConversionActionCountingTypeEnum, changes["counting_type"] + ) + paths.append("counting_type") + if "phone_call_duration_seconds" in changes: + ca.phone_call_duration_seconds = changes["phone_call_duration_seconds"] + paths.append("phone_call_duration_seconds") + if "include_in_conversions_metric" in changes: + ca.include_in_conversions_metric = changes["include_in_conversions_metric"] + paths.append("include_in_conversions_metric") + if "click_through_window_days" in changes: + ca.click_through_lookback_window_days = changes["click_through_window_days"] + paths.append("click_through_lookback_window_days") + if "view_through_window_days" in changes: + ca.view_through_lookback_window_days = changes["view_through_window_days"] + paths.append("view_through_lookback_window_days") + if "attribution_model" in changes: + ca.attribution_model_settings.attribution_model = getattr( + client.enums.AttributionModelEnum, changes["attribution_model"] + ) + paths.append("attribution_model_settings.attribution_model") + + op.update_mask.CopyFrom(field_mask_pb2.FieldMask(paths=paths)) + response = svc.mutate_conversion_actions( + customer_id=cid, operations=[op] + ) + return {"resource_name": response.results[0].resource_name} + + +def _apply_remove_conversion_action(client: object, cid: str, changes: dict) -> dict: + """Remove a ConversionAction (sets status=REMOVED).""" + svc = client.get_service("ConversionActionService") + op = client.get_type("ConversionActionOperation") + op.remove = svc.conversion_action_path( + cid, changes["conversion_action_id"] + ) + response = svc.mutate_conversion_actions( + customer_id=cid, operations=[op] + ) + return {"resource_name": response.results[0].resource_name} diff --git a/src/adloop/ads/write.py b/src/adloop/ads/write.py index a1847a1..0e95453 100644 --- a/src/adloop/ads/write.py +++ b/src/adloop/ads/write.py @@ -570,6 +570,101 @@ def update_ad_group( return plan.to_preview() +def update_responsive_search_ad( + config: AdLoopConfig, + *, + customer_id: str = "", + ad_id: str = "", + final_url: str = "", + path1: str = "", + path2: str = "", + clear_path1: bool = False, + clear_path2: bool = False, +) -> dict: + """Draft an in-place update on an existing RSA — returns PREVIEW. + + Updates the mutable fields on an existing Responsive Search Ad without + creating a new ad (no learning-period reset). The Google Ads API v23 + permits in-place mutation of ``final_urls``, ``path1`` and ``path2`` on + an RSA via ``AdService.MutateAds``; nested ``headlines`` and + ``descriptions`` remain immutable. + + Argument semantics: + - ``final_url`` empty -> no change; non-empty -> replaces final_urls + - ``path1`` / ``path2`` empty -> no change; non-empty -> sets value + - ``clear_path1`` / ``clear_path2`` True -> set the path to empty + (overrides the corresponding path string argument) + + At least one mutation must be requested. Call ``confirm_and_apply`` with + the returned plan_id to execute. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("update_responsive_search_ad", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + errors: list[str] = [] + + if not ad_id: + errors.append("ad_id is required") + elif not str(ad_id).isdigit(): + errors.append("ad_id must be a numeric ID") + + final_url = (final_url or "").strip() + path1 = (path1 or "").strip() + path2 = (path2 or "").strip() + + if path1 and len(path1) > 15: + errors.append(f"path1 must be 15 chars or fewer (got {len(path1)})") + if path2 and len(path2) > 15: + errors.append(f"path2 must be 15 chars or fewer (got {len(path2)})") + + has_url_change = bool(final_url) + has_path1_change = bool(path1) or clear_path1 + has_path2_change = bool(path2) or clear_path2 + + if not (has_url_change or has_path1_change or has_path2_change): + errors.append( + "No changes specified — provide final_url, path1, path2, " + "clear_path1, or clear_path2" + ) + + if errors: + return {"error": "Validation failed", "details": errors} + + if has_url_change: + url_check = _validate_urls([final_url]) + if url_check.get(final_url): + return { + "error": "URL validation failed", + "details": [ + f"final_url '{final_url}' is not reachable: " + f"{url_check[final_url]}. Ads MUST point to working URLs." + ], + } + + changes: dict = {"ad_id": str(ad_id)} + if has_url_change: + changes["final_url"] = final_url + if has_path1_change: + changes["path1"] = "" if clear_path1 else path1 + if has_path2_change: + changes["path2"] = "" if clear_path2 else path2 + + plan = ChangePlan( + operation="update_responsive_search_ad", + entity_type="ad", + entity_id=str(ad_id), + customer_id=customer_id, + changes=changes, + ) + store_plan(plan) + return plan.to_preview() + + def pause_entity( config: AdLoopConfig, *, @@ -671,11 +766,13 @@ def draft_campaign( ad_group_name: str = "", keywords: list[dict] | None = None, geo_target_ids: list[str] | None = None, + geo_exclude_ids: list[str] | None = None, language_ids: list[str] | None = None, search_partners_enabled: bool = False, display_network_enabled: bool | None = None, display_expansion_enabled: bool | None = None, max_cpc: float = 0, + ad_schedule: list[dict] | None = None, ) -> dict: """Draft a full campaign structure — returns preview, does NOT execute. @@ -728,6 +825,18 @@ def draft_campaign( if errors: return {"error": "Validation failed", "details": errors} + schedule_validated, schedule_errors = _validate_ad_schedule(ad_schedule or []) + if schedule_errors: + return {"error": "Ad schedule validation failed", "details": schedule_errors} + + geo_exclude_ids = [str(g) for g in (geo_exclude_ids or []) if str(g).strip()] + overlap = set(geo_exclude_ids) & set(str(g) for g in (geo_target_ids or [])) + if overlap: + return { + "error": "geo_exclude_ids overlap with geo_target_ids", + "details": [f"{g} appears in both include and exclude lists" for g in sorted(overlap)], + } + try: check_budget_cap(daily_budget, config.safety) except SafetyViolation as e: @@ -747,10 +856,12 @@ def draft_campaign( "ad_group_name": ad_group_name or campaign_name, "keywords": keywords, "geo_target_ids": geo_target_ids or [], + "geo_exclude_ids": geo_exclude_ids, "language_ids": language_ids or [], "search_partners_enabled": search_partners_enabled, "display_network_enabled": normalized_display_network_enabled, "max_cpc": max_cpc if max_cpc else None, + "ad_schedule": schedule_validated, }, ) store_plan(plan) @@ -830,16 +941,22 @@ def update_campaign( target_roas: float = 0, daily_budget: float = 0, geo_target_ids: list[str] | None = None, + geo_exclude_ids: list[str] | None = None, language_ids: list[str] | None = None, search_partners_enabled: bool | None = None, display_network_enabled: bool | None = None, display_expansion_enabled: bool | None = None, max_cpc: float = 0, + ad_schedule: list[dict] | None = None, ) -> dict: """Draft an update to an existing campaign — returns preview, does NOT execute. All parameters except campaign_id are optional — only include what you want - to change. Geo/language targets are REPLACED entirely (not appended). + to change. Geo/language targets, geo exclusions, and ad schedule are + REPLACED entirely when provided (existing entries are removed first). + + Pass an empty list (e.g. ``geo_exclude_ids=[]``) to clear that field. + Pass ``None`` (default) to leave it unchanged. """ from adloop.safety.guards import ( SafetyViolation, @@ -891,6 +1008,22 @@ def update_campaign( errors.append("geo_target_ids cannot be empty — provide at least one geo target") if language_ids is not None and len(language_ids) == 0: errors.append("language_ids cannot be empty — provide at least one language") + + cleaned_excl: list[str] | None = None + if geo_exclude_ids is not None: + cleaned_excl = [str(g).strip() for g in geo_exclude_ids if str(g).strip()] + if geo_target_ids is not None: + overlap = set(cleaned_excl) & set(str(g) for g in geo_target_ids) + if overlap: + errors.append( + "geo_exclude_ids overlap with geo_target_ids: " + + ", ".join(sorted(overlap)) + ) + + schedule_validated: list[dict] | None = None + if ad_schedule is not None: + schedule_validated, schedule_errors = _validate_ad_schedule(ad_schedule) + errors.extend(schedule_errors) if max_cpc: strategy_for_cap = bs or _campaign_bidding_strategy(config, customer_id, campaign_id) if strategy_for_cap is None: @@ -902,10 +1035,12 @@ def update_campaign( bs, daily_budget, geo_target_ids is not None, + geo_exclude_ids is not None, language_ids is not None, search_partners_enabled is not None, normalized_display_network_enabled is not None, max_cpc, + ad_schedule is not None, ]) if not has_any_change: errors.append("No changes specified — provide at least one parameter to update") @@ -938,6 +1073,8 @@ def update_campaign( changes["daily_budget"] = daily_budget if geo_target_ids is not None: changes["geo_target_ids"] = geo_target_ids + if cleaned_excl is not None: + changes["geo_exclude_ids"] = cleaned_excl if language_ids is not None: changes["language_ids"] = language_ids if search_partners_enabled is not None: @@ -946,6 +1083,8 @@ def update_campaign( changes["display_network_enabled"] = normalized_display_network_enabled if max_cpc: changes["max_cpc"] = max_cpc + if schedule_validated is not None: + changes["ad_schedule"] = schedule_validated plan = ChangePlan( operation="update_campaign", @@ -968,7 +1107,15 @@ def draft_callouts( campaign_id: str = "", callouts: list[str] | None = None, ) -> dict: - """Draft campaign callout assets.""" + """Draft callout assets — returns a PREVIEW. + + Scope: + - If ``campaign_id`` is provided, the callouts are linked at the + campaign level via ``CampaignAsset``. + - If ``campaign_id`` is empty, the callouts are linked at the + customer/account level via ``CustomerAsset`` and become available + to all eligible campaigns automatically. + """ from adloop.safety.guards import SafetyViolation, check_blocked_operation from adloop.safety.preview import ChangePlan, store_plan @@ -977,16 +1124,18 @@ def draft_callouts( except SafetyViolation as e: return {"error": str(e)} - validated_callouts, errors = _validate_callouts(campaign_id, callouts or []) + validated_callouts, errors = _validate_callouts(callouts or []) if errors: return {"error": "Validation failed", "details": errors} + scope = "campaign" if campaign_id else "customer" plan = ChangePlan( operation="create_callouts", - entity_type="campaign_asset", - entity_id=campaign_id, + entity_type="campaign_asset" if scope == "campaign" else "customer_asset", + entity_id=campaign_id or customer_id, customer_id=customer_id, changes={ + "scope": scope, "campaign_id": campaign_id, "callouts": validated_callouts, }, @@ -1002,7 +1151,13 @@ def draft_structured_snippets( campaign_id: str = "", snippets: list[dict] | None = None, ) -> dict: - """Draft campaign structured snippet assets.""" + """Draft structured snippet assets — returns a PREVIEW. + + Scope: + - If ``campaign_id`` is provided, snippets attach at the campaign level. + - If ``campaign_id`` is empty, snippets attach at the customer/account + level and apply to all eligible campaigns by default. + """ from adloop.safety.guards import SafetyViolation, check_blocked_operation from adloop.safety.preview import ChangePlan, store_plan @@ -1011,18 +1166,18 @@ def draft_structured_snippets( except SafetyViolation as e: return {"error": str(e)} - validated_snippets, errors = _validate_structured_snippets( - campaign_id, snippets or [] - ) + validated_snippets, errors = _validate_structured_snippets(snippets or []) if errors: return {"error": "Validation failed", "details": errors} + scope = "campaign" if campaign_id else "customer" plan = ChangePlan( operation="create_structured_snippets", - entity_type="campaign_asset", - entity_id=campaign_id, + entity_type="campaign_asset" if scope == "campaign" else "customer_asset", + entity_id=campaign_id or customer_id, customer_id=customer_id, changes={ + "scope": scope, "campaign_id": campaign_id, "snippets": validated_snippets, }, @@ -1037,8 +1192,32 @@ def draft_image_assets( customer_id: str = "", campaign_id: str = "", image_paths: list[str] | None = None, + field_types: list[str] | None = None, ) -> dict: - """Draft campaign image assets from local files.""" + """Draft image assets from local PNG/JPEG/GIF files — returns a PREVIEW. + + Scope: + - If ``campaign_id`` is empty, images attach at the customer/account + level via CustomerAsset. + - If ``campaign_id`` is provided, images attach at that campaign via + CampaignAsset. + + Field type: + Each image gets an AssetFieldType chosen from its aspect ratio + (with a 'logo' filename hint): + 1:1 → SQUARE_MARKETING_IMAGE (or BUSINESS_LOGO if 'logo' in name) + 1.91:1 → MARKETING_IMAGE + 4:1 → LANDSCAPE_LOGO (logo hint required) + 4:5 → PORTRAIT_MARKETING_IMAGE + Pass ``field_types`` (one entry per image_path) to override the + auto-detection. Valid override values: MARKETING_IMAGE, + SQUARE_MARKETING_IMAGE, PORTRAIT_MARKETING_IMAGE, + TALL_PORTRAIT_MARKETING_IMAGE, LOGO, LANDSCAPE_LOGO, BUSINESS_LOGO. + + Note: AD_IMAGE is NOT a valid field type for direct asset linking — + Google's API rejects it. The tool maps to the modern marketing-image + types instead. + """ from adloop.safety.guards import SafetyViolation, check_blocked_operation from adloop.safety.preview import ChangePlan, store_plan @@ -1047,16 +1226,44 @@ def draft_image_assets( except SafetyViolation as e: return {"error": str(e)} - validated_images, errors = _validate_image_assets(campaign_id, image_paths or []) + validated_images, errors = _validate_image_assets(image_paths or []) if errors: return {"error": "Validation failed", "details": errors} + if field_types is not None: + if len(field_types) != len(validated_images): + return { + "error": "Validation failed", + "details": [ + f"field_types has {len(field_types)} entries but " + f"image_paths has {len(validated_images)}" + ], + } + for ft, img in zip(field_types, validated_images): + if ft and str(ft).upper() not in _VALID_IMAGE_FIELD_TYPES: + return { + "error": "Validation failed", + "details": [ + f"field_type {ft!r} is not a supported image asset " + f"field type. Valid: {sorted(_VALID_IMAGE_FIELD_TYPES)}" + ], + } + if ft: + img["field_type"] = str(ft).upper() + + # Compute the field type each image will resolve to and attach to + # the preview so the user can see it before applying. + for img in validated_images: + img["resolved_field_type"] = _detect_image_field_type(img) + + scope = "campaign" if campaign_id else "customer" plan = ChangePlan( operation="create_image_assets", - entity_type="campaign_asset", - entity_id=campaign_id, + entity_type="campaign_asset" if scope == "campaign" else "customer_asset", + entity_id=campaign_id or customer_id, customer_id=customer_id, changes={ + "scope": scope, "campaign_id": campaign_id, "images": validated_images, }, @@ -1072,14 +1279,18 @@ def draft_sitelinks( campaign_id: str = "", sitelinks: list[dict] | None = None, ) -> dict: - """Draft sitelink extensions for a campaign — returns preview, does NOT execute. + """Draft sitelink extensions — returns a PREVIEW, does NOT execute. + + Scope: + - If ``campaign_id`` is provided, sitelinks attach at the campaign level. + - If ``campaign_id`` is empty, sitelinks attach at the customer/account + level and apply to all eligible campaigns by default. sitelinks: list of dicts, each with: - link_text (str, required, max 25 chars) — the clickable text - final_url (str, required) — where the sitelink points - description1 (str, optional, max 35 chars) — first description line - description2 (str, optional, max 35 chars) — second description line - campaign_id: the campaign to attach sitelinks to """ from adloop.safety.guards import SafetyViolation, check_blocked_operation from adloop.safety.preview import ChangePlan, store_plan @@ -1089,8 +1300,6 @@ def draft_sitelinks( except SafetyViolation as e: return {"error": str(e)} - if not campaign_id: - return {"error": "campaign_id is required"} if not sitelinks: return {"error": "At least one sitelink is required"} @@ -1157,12 +1366,17 @@ def draft_sitelinks( f"maximum ad real estate." ) + scope = "campaign" if campaign_id else "customer" plan = ChangePlan( operation="create_sitelinks", - entity_type="campaign_asset", - entity_id=campaign_id, + entity_type="campaign_asset" if scope == "campaign" else "customer_asset", + entity_id=campaign_id or customer_id, customer_id=customer_id, - changes={"campaign_id": campaign_id, "sitelinks": validated}, + changes={ + "scope": scope, + "campaign_id": campaign_id, + "sitelinks": validated, + }, ) store_plan(plan) preview = plan.to_preview() @@ -1171,1553 +1385,3464 @@ def draft_sitelinks( return preview -# --------------------------------------------------------------------------- -# confirm_and_apply — the only function that actually mutates Google Ads -# --------------------------------------------------------------------------- +# Country dialing codes for E.164 phone normalization. +_COUNTRY_DIAL_CODES = { + "US": "+1", "CA": "+1", "GB": "+44", "DE": "+49", "FR": "+33", + "IT": "+39", "ES": "+34", "NL": "+31", "BE": "+32", "AT": "+43", + "CH": "+41", "AU": "+61", "NZ": "+64", "IE": "+353", "PT": "+351", +} -def _extract_error_message(exc: Exception) -> str: - """Extract a meaningful error message from Google Ads API exceptions. +def _normalize_phone_e164(phone: str, country_code: str) -> tuple[str, str | None]: + """Return (normalized, error_or_None). Strips formatting, ensures + prefix. - GoogleAdsException.__init__ doesn't call super().__init__(), so str(e) - returns ''. This function digs into the failure proto to surface the - actual error code, message, and trigger values. + Handles two trunk-prefix patterns: + - North America (US/CA): leading "1" before a 10-digit number is the + country code; strip it before re-adding "+1". + - European trunk "0": GB/DE/FR/IT/ES/NL/BE/AT/CH/IE/PT/AU/NZ all use a + leading "0" for domestic dialing that must be removed when adding + the international prefix. """ - try: - from google.ads.googleads.errors import GoogleAdsException - - if isinstance(exc, GoogleAdsException) and exc.failure: - parts = [] - for error in exc.failure.errors: - error_code = error.error_code - code_field = error_code.WhichOneof("error_code") - code_value = getattr(error_code, code_field) if code_field else "UNKNOWN" - line = f"[{code_field}={code_value.name if hasattr(code_value, 'name') else code_value}]" - if error.message: - line += f" {error.message}" - if error.trigger and error.trigger.string_value: - line += f" (trigger: {error.trigger.string_value})" - parts.append(line) - if parts: - msg = "; ".join(parts) - if exc.request_id: - msg += f" [request_id={exc.request_id}]" - return msg - except Exception: - pass - - fallback = str(exc) - return fallback if fallback else repr(exc) + raw = "".join(ch for ch in phone if ch.isdigit() or ch == "+") + if not raw: + return "", "phone_number is empty after stripping formatting" + if raw.startswith("+"): + return raw, None + dial = _COUNTRY_DIAL_CODES.get(country_code.upper()) + if not dial: + return "", ( + f"country_code '{country_code}' is not in the dial-code map; " + f"pass phone in E.164 form (with leading '+')" + ) + cc_upper = country_code.upper() + if cc_upper in ("US", "CA") and len(raw) == 11 and raw.startswith("1"): + raw = raw[1:] + elif cc_upper not in ("US", "CA") and raw.startswith("0"): + raw = raw.lstrip("0") + return f"{dial}{raw}", None -def confirm_and_apply( +def draft_call_asset( config: AdLoopConfig, *, - plan_id: str = "", - dry_run: bool = True, + customer_id: str = "", + phone_number: str = "", + country_code: str = "US", + campaign_id: str = "", + call_conversion_action_id: str = "", + ad_schedule: list[dict] | None = None, ) -> dict: - """Execute a previously previewed change. - - Defaults to dry_run=True. The caller must explicitly pass dry_run=False - to make real changes. + """Draft a call asset (phone extension) — returns a PREVIEW. + + Scope: + - If ``campaign_id`` is provided, the call asset attaches to that + campaign via ``CampaignAsset``. + - If ``campaign_id`` is empty, the call asset attaches at the + customer/account level via ``CustomerAsset``. + + phone_number: human or E.164 (e.g. "+19163393676" or "(916) 339-3676"). + country_code: 2-letter ISO country code used to canonicalize a national + number to E.164. Ignored when phone_number already starts with '+'. + call_conversion_action_id: optional Google Ads conversion action ID to + count calls of qualifying duration (typically ≥60 sec). When omitted, + the call asset uses the default account-level call-conversion settings. + ad_schedule: optional schedule dict list — see add_ad_schedule for shape + (day_of_week, start_hour/minute, end_hour/minute). Used to limit the + hours when the call extension shows. + + Important: Google Ads requires manual phone-number verification before + the call asset can serve. The asset is created in the account but won't + show until verification completes in the Ads UI. """ - from adloop.safety.audit import log_mutation - from adloop.safety.preview import get_plan, remove_plan + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan - plan = get_plan(plan_id) - if plan is None: - return { - "error": f"No pending plan found with id '{plan_id}'. " - "Plans expire when the MCP server restarts.", - } + try: + check_blocked_operation("create_call_asset", config.safety) + except SafetyViolation as e: + return {"error": str(e)} - forced_by_config = bool(config.safety.require_dry_run) and not dry_run - if config.safety.require_dry_run: - dry_run = True + if not phone_number: + return {"error": "phone_number is required"} - if dry_run: - log_mutation( - config.safety.log_file, - operation=plan.operation, - customer_id=plan.customer_id, - entity_type=plan.entity_type, - entity_id=plan.entity_id, - changes=plan.changes, - dry_run=True, - result="dry_run_success", - ) - response = { - "status": "DRY_RUN_SUCCESS", - "plan_id": plan.plan_id, - "operation": plan.operation, - "changes": plan.changes, - } - if forced_by_config: - # The caller passed dry_run=false but safety.require_dry_run - # forced it back on. Tell them exactly why and how to unlock - # real writes — without this, agents (e.g. Claude Code) retry - # in an infinite loop because the old message said to "call - # again with dry_run=false", which they already did. - config_path = config.source_path or "~/.adloop/config.yaml" - response["dry_run_forced_by"] = "config.safety.require_dry_run" - response["config_path"] = config_path - response["remediation"] = ( - f"Edit {config_path}, set 'require_dry_run: false' under " - "'safety:', then restart the AdLoop MCP server. Passing " - "dry_run=false on this tool will keep being overridden " - "until that flag is flipped." - ) - response["message"] = ( - f"dry_run=false was IGNORED because 'safety.require_dry_run: true' " - f"is set in {config_path}. No changes were made. To apply real " - f"changes, flip that flag to false and restart the AdLoop MCP " - f"server — retrying this tool with dry_run=false alone will " - f"never succeed while the flag is on." - ) - else: - response["message"] = ( - "Dry run completed — no changes were made to your Google Ads account. " - "To apply for real, call confirm_and_apply again with dry_run=false." - ) - return response + normalized_phone, phone_err = _normalize_phone_e164(phone_number, country_code) + if phone_err: + return {"error": phone_err} - try: - result = _execute_plan(config, plan) - except Exception as e: - error_message = _extract_error_message(e) - log_mutation( - config.safety.log_file, - operation=plan.operation, - customer_id=plan.customer_id, - entity_type=plan.entity_type, - entity_id=plan.entity_id, - changes=plan.changes, - dry_run=False, - result="error", - error=error_message, - ) - return {"error": error_message, "plan_id": plan.plan_id} + schedule_validated, schedule_errors = _validate_ad_schedule(ad_schedule or []) + if schedule_errors: + return {"error": "Ad schedule validation failed", "details": schedule_errors} - log_mutation( - config.safety.log_file, - operation=plan.operation, - customer_id=plan.customer_id, - entity_type=plan.entity_type, - entity_id=plan.entity_id, - changes=plan.changes, - dry_run=False, - result="success", - ) - remove_plan(plan.plan_id) + scope = "campaign" if campaign_id else "customer" + warnings = [ + "Google Ads requires phone-number verification before call assets serve. " + "Complete verification in Ads UI → Tools → Assets → Calls." + ] - return { - "status": "APPLIED", - "plan_id": plan.plan_id, - "operation": plan.operation, - "result": result, - } + plan = ChangePlan( + operation="create_call_asset", + entity_type="campaign_asset" if scope == "campaign" else "customer_asset", + entity_id=campaign_id or customer_id, + customer_id=customer_id, + changes={ + "scope": scope, + "campaign_id": campaign_id, + "phone_number": normalized_phone, + "country_code": country_code.upper(), + "call_conversion_action_id": call_conversion_action_id, + "ad_schedule": schedule_validated, + }, + ) + store_plan(plan) + preview = plan.to_preview() + preview["warnings"] = warnings + return preview # --------------------------------------------------------------------------- -# Internal validation helpers +# Promotion assets # --------------------------------------------------------------------------- -_VALID_MATCH_TYPES = {"EXACT", "PHRASE", "BROAD"} -_VALID_ENTITY_TYPES = {"campaign", "ad_group", "ad", "keyword"} -_REMOVABLE_ENTITY_TYPES = _VALID_ENTITY_TYPES | { - "negative_keyword", "campaign_asset", "asset", "customer_asset", - "shared_criterion", -} - -_SMART_BIDDING_STRATEGIES = { - "MAXIMIZE_CONVERSIONS", - "MAXIMIZE_CONVERSION_VALUE", - "TARGET_CPA", - "TARGET_ROAS", -} - +# Pulled dynamically from the google-ads SDK so we don't drift when +# Google adds new occasions / modifiers in a future API version. +from adloop.ads.enums import enum_names as _enum_names -def _campaign_uses_manual_cpc( - config: AdLoopConfig, customer_id: str, campaign_id: str -) -> bool | None: - """Return True when the campaign exists and uses MANUAL_CPC.""" - bidding_strategy = _campaign_bidding_strategy(config, customer_id, campaign_id) - if bidding_strategy is None: - return None - return bidding_strategy == "MANUAL_CPC" +_VALID_PROMOTION_OCCASIONS = _enum_names("PromotionExtensionOccasionEnum") +_VALID_DISCOUNT_MODIFIERS = _enum_names("PromotionExtensionDiscountModifierEnum") -def _campaign_bidding_strategy( - config: AdLoopConfig, customer_id: str, campaign_id: str -) -> str | None: - """Return the bidding strategy type for the campaign, if it exists.""" - from adloop.ads.gaql import execute_query +def _validate_promotion_inputs( + *, + promotion_target: str, + final_url: str, + money_off: float, + percent_off: float, + currency_code: str, + promotion_code: str, + orders_over_amount: float, + occasion: str, + discount_modifier: str, + language_code: str, + start_date: str, + end_date: str, + redemption_start_date: str, + redemption_end_date: str, + ad_schedule: list[dict] | None, +) -> tuple[dict, list[str]]: + """Validate every PromotionAsset field. Returns (normalized, errors).""" + errors: list[str] = [] + target = (promotion_target or "").strip() + url = (final_url or "").strip() - query = f""" - SELECT campaign.bidding_strategy_type - FROM campaign - WHERE campaign.id = {campaign_id} - LIMIT 1 - """ - rows = execute_query(config, customer_id, query) - if not rows: - return None - return rows[0].get("campaign.bidding_strategy_type") + if not target: + errors.append("promotion_target is required") + elif len(target) > 20: + errors.append( + f"promotion_target '{target}' is {len(target)} chars (max 20)" + ) + if not url: + errors.append("final_url is required") -def _ad_group_uses_manual_cpc( - config: AdLoopConfig, customer_id: str, ad_group_id: str -) -> bool | None: - """Return True when the ad group exists in a MANUAL_CPC campaign.""" - from adloop.ads.gaql import execute_query + has_money = money_off and money_off > 0 + has_percent = percent_off and percent_off > 0 + if has_money and has_percent: + errors.append( + "Specify exactly one of money_off or percent_off, not both" + ) + elif not has_money and not has_percent: + errors.append( + "One of money_off or percent_off is required (must be > 0)" + ) - query = f""" - SELECT campaign.bidding_strategy_type - FROM ad_group - WHERE ad_group.id = {ad_group_id} - LIMIT 1 - """ - rows = execute_query(config, customer_id, query) - if not rows: - return None - return rows[0].get("campaign.bidding_strategy_type") == "MANUAL_CPC" + if has_percent and (percent_off <= 0 or percent_off > 100): + errors.append(f"percent_off must be in (0, 100]; got {percent_off}") + code = (promotion_code or "").strip() + if code and len(code) > 15: + errors.append( + f"promotion_code '{code}' is {len(code)} chars (max 15)" + ) -def _validate_callouts( - campaign_id: str, callouts: list[str] -) -> tuple[list[str], list[str]]: - errors = [] - validated = [] + has_orders_over = bool(orders_over_amount and orders_over_amount > 0) + if code and has_orders_over: + errors.append( + "promotion_code and orders_over_amount are mutually exclusive " + "(Google Ads PromotionAsset.promotion_trigger is a oneof) — " + "specify exactly one" + ) - if not campaign_id: - errors.append("campaign_id is required") - if not callouts: - errors.append("At least one callout is required") + occ = (occasion or "").strip().upper() + if occ and occ not in _VALID_PROMOTION_OCCASIONS: + errors.append( + f"occasion '{occ}' invalid; valid values: " + f"{sorted(_VALID_PROMOTION_OCCASIONS)}" + ) - for index, callout in enumerate(callouts): - text = callout.strip() - if not text: - errors.append(f"Callout {index + 1}: text is required") - elif len(text) > 25: - errors.append( - f"Callout {index + 1}: '{text}' is {len(text)} chars (max 25)" - ) - else: - validated.append(text) + modifier = (discount_modifier or "").strip().upper() + if modifier and modifier not in _VALID_DISCOUNT_MODIFIERS: + errors.append( + f"discount_modifier '{modifier}' invalid; valid: " + f"{sorted(_VALID_DISCOUNT_MODIFIERS)} (or empty for none)" + ) - return validated, errors + for label, value in ( + ("start_date", start_date), + ("end_date", end_date), + ("redemption_start_date", redemption_start_date), + ("redemption_end_date", redemption_end_date), + ): + if value and not _is_valid_iso_date(value): + errors.append(f"{label} '{value}' must be YYYY-MM-DD") + schedule_validated, schedule_errors = _validate_ad_schedule(ad_schedule or []) + errors.extend(schedule_errors) -def _validate_structured_snippets( - campaign_id: str, snippets: list[dict] -) -> tuple[list[dict], list[str]]: - errors = [] - validated = [] + if errors: + return {}, errors + + if url: + url_checks = _validate_urls([url]) + url_err = url_checks.get(url) + if url_err: + errors.append(f"final_url '{url}' is not reachable: {url_err}") + return {}, errors + + normalized: dict = { + "promotion_target": target, + "final_url": url, + "currency_code": (currency_code or "USD").upper(), + "promotion_code": code, + "orders_over_amount": float(orders_over_amount or 0), + "occasion": occ, + "discount_modifier": modifier, + "language_code": (language_code or "en").lower(), + "start_date": start_date or "", + "end_date": end_date or "", + "redemption_start_date": redemption_start_date or "", + "redemption_end_date": redemption_end_date or "", + "ad_schedule": schedule_validated, + } + if has_money: + normalized["money_off"] = float(money_off) + normalized["percent_off"] = 0.0 + else: + normalized["money_off"] = 0.0 + normalized["percent_off"] = float(percent_off) - if not campaign_id: - errors.append("campaign_id is required") - if not snippets: - errors.append("At least one structured snippet is required") + return normalized, [] - for index, snippet in enumerate(snippets): - header = snippet.get("header", "").strip() - values = [value.strip() for value in snippet.get("values", [])] - if header not in _STRUCTURED_SNIPPET_HEADERS: - errors.append( - f"Structured snippet {index + 1}: header must be one of " - f"{sorted(_STRUCTURED_SNIPPET_HEADERS)}" - ) - if len(values) < 3 or len(values) > 10: - errors.append( - f"Structured snippet {index + 1}: values must contain 3-10 items" - ) - for value_index, value in enumerate(values): - if not value: - errors.append( - f"Structured snippet {index + 1}: value {value_index + 1} is required" - ) - elif len(value) > 25: - errors.append( - f"Structured snippet {index + 1}: value '{value}' is " - f"{len(value)} chars (max 25)" - ) +def _is_valid_iso_date(value: str) -> bool: + """True if value parses as a YYYY-MM-DD calendar date.""" + from datetime import datetime - validated.append({"header": header, "values": values}) + try: + datetime.strptime(value, "%Y-%m-%d") + return True + except (TypeError, ValueError): + return False - return validated, errors +def draft_promotion( + config: AdLoopConfig, + *, + customer_id: str = "", + promotion_target: str = "", + final_url: str = "", + money_off: float = 0, + percent_off: float = 0, + currency_code: str = "USD", + promotion_code: str = "", + orders_over_amount: float = 0, + occasion: str = "", + discount_modifier: str = "", + language_code: str = "en", + start_date: str = "", + end_date: str = "", + redemption_start_date: str = "", + redemption_end_date: str = "", + campaign_id: str = "", + ad_schedule: list[dict] | None = None, +) -> dict: + """Draft a promotion extension asset — returns a PREVIEW. + + Creates a PromotionAsset and links it at campaign or customer scope. + Exactly one of money_off / percent_off must be provided. + + Scope: + - campaign_id provided → CampaignAsset link. + - campaign_id empty → CustomerAsset link (account-level). + + Required: + promotion_target: what the promotion is for, e.g. "Window Tint" + (max 20 chars; this is the label Google shows in the ad). + final_url: landing page for the promotion (must return 2xx/3xx). + money_off OR percent_off: the discount amount. + + Optional: + currency_code: ISO 4217 (default USD). Used for money_off and + orders_over_amount. + promotion_code: optional coupon code (max 15 chars). + orders_over_amount: minimum order amount that unlocks the promo. + occasion: optional event tag — e.g. BLACK_FRIDAY, SUMMER_SALE. + See PromotionExtensionOccasion enum for the full list. + discount_modifier: optional modifier; "UP_TO" surfaces as + "Up to $X off" instead of "$X off". + language_code: BCP-47 (default "en"). + start_date / end_date: YYYY-MM-DD. Leave blank for always-on. + redemption_start_date / redemption_end_date: YYYY-MM-DD. + ad_schedule: optional list of {day_of_week, start_hour, end_hour, + start_minute, end_minute} entries restricting when the promo + shows. -def _validate_image_assets( - campaign_id: str, image_paths: list[str] -) -> tuple[list[dict[str, object]], list[str]]: - errors = [] - validated = [] + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan - if not campaign_id: - errors.append("campaign_id is required") - if not image_paths: - errors.append("At least one image path is required") + try: + check_blocked_operation("create_promotion", config.safety) + except SafetyViolation as e: + return {"error": str(e)} - for index, image_path in enumerate(image_paths): - try: - validated.append(_parse_image_metadata(image_path)) - except ValueError as exc: - errors.append(f"Image {index + 1}: {exc}") + normalized, errors = _validate_promotion_inputs( + promotion_target=promotion_target, + final_url=final_url, + money_off=money_off, + percent_off=percent_off, + currency_code=currency_code, + promotion_code=promotion_code, + orders_over_amount=orders_over_amount, + occasion=occasion, + discount_modifier=discount_modifier, + language_code=language_code, + start_date=start_date, + end_date=end_date, + redemption_start_date=redemption_start_date, + redemption_end_date=redemption_end_date, + ad_schedule=ad_schedule, + ) + if errors: + return {"error": "Validation failed", "details": errors} - return validated, errors + scope = "campaign" if campaign_id else "customer" + plan = ChangePlan( + operation="create_promotion", + entity_type="campaign_asset" if scope == "campaign" else "customer_asset", + entity_id=campaign_id or customer_id, + customer_id=customer_id, + changes={ + "scope": scope, + "campaign_id": campaign_id, + "promotion": normalized, + }, + ) + store_plan(plan) + return plan.to_preview() -def _check_broad_match_safety( +def update_promotion( config: AdLoopConfig, - customer_id: str, - ad_group_id: str, - keywords: list[dict], -) -> list[str]: - """Warn if BROAD match keywords are being added to a non-Smart Bidding campaign.""" - has_broad = any( - (kw.get("match_type") or "").upper() == "BROAD" for kw in keywords - ) - if not has_broad: - return [] + *, + customer_id: str = "", + asset_id: str = "", + campaign_id: str = "", + promotion_target: str = "", + final_url: str = "", + money_off: float = 0, + percent_off: float = 0, + currency_code: str = "USD", + promotion_code: str = "", + orders_over_amount: float = 0, + occasion: str = "", + discount_modifier: str = "", + language_code: str = "en", + start_date: str = "", + end_date: str = "", + redemption_start_date: str = "", + redemption_end_date: str = "", + ad_schedule: list[dict] | None = None, +) -> dict: + """Update a promotion via swap — returns a PREVIEW. - try: - from adloop.ads.gaql import execute_query + PromotionAsset fields are immutable once created in the Google Ads + API, so "update" is implemented as a swap: + 1. Create a new PromotionAsset with the updated values. + 2. Link the new asset at the same scope. + 3. Unlink the old asset. - query = f""" - SELECT campaign.bidding_strategy_type, campaign.name - FROM ad_group - WHERE ad_group.id = {ad_group_id} - """ - rows = execute_query(config, customer_id, query) - if not rows: - return [] + The old Asset row itself stays in the account (orphaned). The Ads + API does not support hard-deleting Asset rows; Google reclaims + orphaned assets in due course. - bidding = rows[0].get("campaign.bidding_strategy_type", "") - campaign_name = rows[0].get("campaign.name", "") + asset_id: numeric ID of the existing PromotionAsset to replace. + Find it via: SELECT asset.id, asset.name, asset.promotion_asset.promotion_target + FROM asset WHERE asset.type = 'PROMOTION' + campaign_id: pass to scope BOTH the new and old links to that campaign. + Leave empty for customer/account-level scope (matches CustomerAsset + behavior of the original promotion). - if bidding not in _SMART_BIDDING_STRATEGIES: - return [ - f"DANGEROUS: Adding BROAD match keywords to campaign " - f"'{campaign_name}' which uses {bidding} bidding. " - f"Broad Match without Smart Bidding (tCPA/tROAS/Maximize Conversions) " - f"leads to irrelevant matches and wasted budget. " - f"Use PHRASE or EXACT match instead, or switch the campaign " - f"to Smart Bidding first." - ] - except Exception: - pass + All other fields: see draft_promotion docstring. - return [] + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + try: + check_blocked_operation("update_promotion", config.safety) + except SafetyViolation as e: + return {"error": str(e)} -def _validate_rsa( - ad_group_id: str, - headlines: list[dict], - descriptions: list[dict], - final_url: str, -) -> list[str]: - errors = [] - if not ad_group_id: - errors.append("ad_group_id is required") - if not final_url: - errors.append("final_url is required") - if len(headlines) < 3: - errors.append(f"Need at least 3 headlines, got {len(headlines)}") - if len(headlines) > 15: - errors.append(f"Maximum 15 headlines, got {len(headlines)}") - if len(descriptions) < 2: - errors.append(f"Need at least 2 descriptions, got {len(descriptions)}") - if len(descriptions) > 4: - errors.append(f"Maximum 4 descriptions, got {len(descriptions)}") + if not asset_id: + return {"error": "asset_id is required (the existing PromotionAsset to replace)"} + + normalized, errors = _validate_promotion_inputs( + promotion_target=promotion_target, + final_url=final_url, + money_off=money_off, + percent_off=percent_off, + currency_code=currency_code, + promotion_code=promotion_code, + orders_over_amount=orders_over_amount, + occasion=occasion, + discount_modifier=discount_modifier, + language_code=language_code, + start_date=start_date, + end_date=end_date, + redemption_start_date=redemption_start_date, + redemption_end_date=redemption_end_date, + ad_schedule=ad_schedule, + ) + if errors: + return {"error": "Validation failed", "details": errors} - headline_pin_counts: dict[str, int] = {} - for i, h in enumerate(headlines): - text = h["text"] - pin = h["pinned_field"] - if len(text) > 30: - errors.append( - f"Headline {i + 1} exceeds 30 chars ({len(text)}): '{text}'" - ) - if pin is not None: - if pin not in _VALID_HEADLINE_PINS: - errors.append( - f"Headline {i + 1} pinned_field '{pin}' invalid; " - f"must be one of {sorted(_VALID_HEADLINE_PINS)} or null" - ) - else: - headline_pin_counts[pin] = headline_pin_counts.get(pin, 0) + 1 - for pin, count in headline_pin_counts.items(): - if count > 2: - errors.append(f"At most 2 headlines may pin to {pin}; got {count}") + scope = "campaign" if campaign_id else "customer" + warnings = [ + "Update is a swap: a new PromotionAsset is created and linked, " + "the old link is unlinked. The old Asset row stays in the account " + "(orphaned) — Google Ads API does not support deleting Asset rows." + ] - description_pin_counts: dict[str, int] = {} - for i, d in enumerate(descriptions): - text = d["text"] - pin = d["pinned_field"] - if len(text) > 90: - errors.append( - f"Description {i + 1} exceeds 90 chars ({len(text)}): '{text}'" - ) - if pin is not None: - if pin not in _VALID_DESCRIPTION_PINS: - errors.append( - f"Description {i + 1} pinned_field '{pin}' invalid; " - f"must be one of {sorted(_VALID_DESCRIPTION_PINS)} or null" - ) - else: - description_pin_counts[pin] = description_pin_counts.get(pin, 0) + 1 - for pin, count in description_pin_counts.items(): - if count > 1: - errors.append(f"At most 1 description may pin to {pin}; got {count}") + plan = ChangePlan( + operation="update_promotion", + entity_type="campaign_asset" if scope == "campaign" else "customer_asset", + entity_id=campaign_id or customer_id, + customer_id=customer_id, + changes={ + "scope": scope, + "campaign_id": campaign_id, + "old_asset_id": asset_id, + "promotion": normalized, + }, + ) + store_plan(plan) + preview = plan.to_preview() + preview["warnings"] = warnings + return preview - return errors +# --------------------------------------------------------------------------- +# In-place asset updates (call asset, sitelink, callout) +# --------------------------------------------------------------------------- -_VALID_BIDDING_STRATEGIES = { - "MAXIMIZE_CONVERSIONS", - "MAXIMIZE_CONVERSION_VALUE", - "TARGET_CPA", - "TARGET_ROAS", - "TARGET_SPEND", - "MANUAL_CPC", -} -_VALID_CHANNEL_TYPES = {"SEARCH", "DISPLAY", "SHOPPING", "VIDEO", "PERFORMANCE_MAX"} +_VALID_CALL_REPORTING_STATES = _enum_names("CallConversionReportingStateEnum") -def _validate_campaign( +def update_call_asset( config: AdLoopConfig, *, - campaign_name: str, - daily_budget: float, - bidding_strategy: str, - target_cpa: float, - target_roas: float, - channel_type: str, - keywords: list[dict] | None, - geo_target_ids: list[str] | None, - language_ids: list[str] | None, customer_id: str = "", - search_partners_enabled: bool = False, - display_network_enabled: bool = False, - max_cpc: float = 0, -) -> tuple[list[str], list[str]]: - """Validate campaign draft inputs. Returns (errors, warnings).""" - errors = [] - warnings = [] + asset_id: str, + phone_number: str = "", + country_code: str = "", + call_conversion_action_id: str = "", + call_conversion_reporting_state: str = "", + ad_schedule: list[dict] | None = None, +) -> dict: + """Update an existing CallAsset in place — returns a PREVIEW. - if not campaign_name or not campaign_name.strip(): - errors.append("campaign_name is required") - if daily_budget <= 0: - errors.append("daily_budget must be greater than 0") - if not geo_target_ids: + Use this to: + - re-point a CallAsset at a specific conversion action (e.g. 'Calls + from Ads (>=90s)') with USE_RESOURCE_LEVEL_CALL_CONVERSION_ACTION + - change the phone number / country code + - replace the ad-schedule windows + + Pass only the fields you want to change. Empty strings/None are + treated as "do not change". + + asset_id: numeric ID of the existing call asset. + call_conversion_reporting_state: one of + DISABLED | USE_ACCOUNT_LEVEL_CALL_CONVERSION_ACTION | + USE_RESOURCE_LEVEL_CALL_CONVERSION_ACTION + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("update_call_asset", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + if not asset_id: + return {"error": "asset_id is required"} + + errors: list[str] = [] + normalized_phone = "" + if phone_number: + cc = (country_code or "US").upper() + normalized_phone, phone_err = _normalize_phone_e164(phone_number, cc) + if phone_err: + errors.append(phone_err) + + if call_conversion_reporting_state and ( + call_conversion_reporting_state not in _VALID_CALL_REPORTING_STATES + ): errors.append( - "geo_target_ids is required — campaigns must target at least one " - "country/region (e.g. ['2276'] for Germany, ['2840'] for USA)" + f"call_conversion_reporting_state '{call_conversion_reporting_state}'" + f" invalid; valid: {sorted(_VALID_CALL_REPORTING_STATES)}" ) - if not language_ids: + + schedule_validated, schedule_errors = _validate_ad_schedule(ad_schedule or []) + errors.extend(schedule_errors) + + if errors: + return {"error": "Validation failed", "details": errors} + + changes: dict = {"asset_id": str(asset_id)} + if normalized_phone: + changes["phone_number"] = normalized_phone + if country_code: + changes["country_code"] = country_code.upper() + if call_conversion_action_id: + changes["call_conversion_action_id"] = str(call_conversion_action_id) + if call_conversion_reporting_state: + changes["call_conversion_reporting_state"] = call_conversion_reporting_state + if ad_schedule is not None: + changes["ad_schedule"] = schedule_validated + + if len(changes) == 1: + return {"error": "No fields to update"} + + plan = ChangePlan( + operation="update_call_asset", + entity_type="asset", + entity_id=str(asset_id), + customer_id=customer_id, + changes=changes, + ) + store_plan(plan) + return plan.to_preview() + + +def update_sitelink( + config: AdLoopConfig, + *, + customer_id: str = "", + asset_id: str, + link_text: str = "", + final_url: str = "", + description1: str = "", + description2: str = "", +) -> dict: + """Update an existing SitelinkAsset in place — returns a PREVIEW. + + Pass only the fields you want to change. Empty string = "do not change". + + asset_id: numeric ID of the existing sitelink asset. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("update_sitelink", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + if not asset_id: + return {"error": "asset_id is required"} + + errors: list[str] = [] + if link_text and len(link_text) > 25: errors.append( - "language_ids is required — campaigns must target at least one " - "language (e.g. ['1001'] for German, ['1000'] for English)" + f"link_text '{link_text}' is {len(link_text)} chars (max 25)" ) - - bs = bidding_strategy.upper() - if bs not in _VALID_BIDDING_STRATEGIES: + if description1 and len(description1) > 35: errors.append( - f"bidding_strategy must be one of {sorted(_VALID_BIDDING_STRATEGIES)}, " - f"got '{bidding_strategy}'" + f"description1 is {len(description1)} chars (max 35)" ) - if bs == "TARGET_CPA" and not target_cpa: - errors.append("target_cpa is required when bidding_strategy is TARGET_CPA") - if bs == "TARGET_ROAS" and not target_roas: - errors.append("target_roas is required when bidding_strategy is TARGET_ROAS") - - ct = channel_type.upper() - if ct not in _VALID_CHANNEL_TYPES: + if description2 and len(description2) > 35: errors.append( - f"channel_type must be one of {sorted(_VALID_CHANNEL_TYPES)}, " - f"got '{channel_type}'" + f"description2 is {len(description2)} chars (max 35)" ) - if ct != "SEARCH" and search_partners_enabled: - errors.append("search_partners_enabled is only supported for SEARCH campaigns") - if ct != "SEARCH" and display_network_enabled: - errors.append("display_network_enabled is only supported for SEARCH campaigns") - if max_cpc < 0: - errors.append("max_cpc cannot be negative") - if max_cpc and bs not in {"MANUAL_CPC", "TARGET_SPEND"}: - errors.append("max_cpc requires MANUAL_CPC or TARGET_SPEND bidding_strategy") + if errors: + return {"error": "Validation failed", "details": errors} - if keywords: - has_broad = any( - (kw.get("match_type") or "").upper() == "BROAD" for kw in keywords - ) - if has_broad and bs not in _SMART_BIDDING_STRATEGIES: - errors.append( - f"BROAD match keywords require Smart Bidding " - f"(tCPA/tROAS/Maximize Conversions). " - f"'{bidding_strategy}' is not a Smart Bidding strategy. " - f"Use PHRASE or EXACT match instead." - ) - for i, kw in enumerate(keywords): - if not kw.get("text"): - errors.append(f"Keyword {i + 1} has no text") - mt = (kw.get("match_type") or "").upper() - if mt not in _VALID_MATCH_TYPES: - errors.append( - f"Keyword {i + 1} has invalid match_type '{mt}' " - "(must be EXACT, PHRASE, or BROAD)" - ) + if final_url: + url_checks = _validate_urls([final_url]) + url_err = url_checks.get(final_url) + if url_err: + return { + "error": "URL validation failed", + "details": [f"'{final_url}' is not reachable: {url_err}"], + } + + changes: dict = {"asset_id": str(asset_id)} + if link_text: + changes["link_text"] = link_text + if final_url: + changes["final_url"] = final_url + if description1: + changes["description1"] = description1 + if description2: + changes["description2"] = description2 + + if len(changes) == 1: + return {"error": "No fields to update"} - if target_cpa > 0 and daily_budget < 5 * target_cpa: - from adloop.ads.currency import format_currency, get_currency_code - currency_code = get_currency_code(config, customer_id) - warnings.append( - f"Daily budget {format_currency(daily_budget, currency_code)} is less than 5x target CPA " - f"{format_currency(target_cpa, currency_code)}. Google recommends at least 5x target CPA " - f"({format_currency(5 * target_cpa, currency_code)}/day) for sufficient learning data." - ) + plan = ChangePlan( + operation="update_sitelink", + entity_type="asset", + entity_id=str(asset_id), + customer_id=customer_id, + changes=changes, + ) + store_plan(plan) + return plan.to_preview() - if bs == "MANUAL_CPC": - warnings.append( - "MANUAL_CPC bidding requires constant monitoring. Consider using " - "MAXIMIZE_CONVERSIONS or TARGET_CPA for automated optimization." - ) - return errors, warnings +def update_callout( + config: AdLoopConfig, + *, + customer_id: str = "", + asset_id: str, + callout_text: str, +) -> dict: + """Update an existing CalloutAsset's text in place — returns a PREVIEW. + asset_id: numeric ID of the existing callout asset. + callout_text: new callout text (max 25 chars). + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan -def _validate_keywords(ad_group_id: str, keywords: list[dict]) -> list[str]: - errors = [] - if not ad_group_id: - errors.append("ad_group_id is required") - if not keywords: - errors.append("At least one keyword is required") - for i, kw in enumerate(keywords): - if not kw.get("text"): - errors.append(f"Keyword {i + 1} has no text") - mt = (kw.get("match_type") or "").upper() - if mt not in _VALID_MATCH_TYPES: - errors.append( - f"Keyword {i + 1} has invalid match_type '{mt}' " - "(must be EXACT, PHRASE, or BROAD)" - ) - return errors + try: + check_blocked_operation("update_callout", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + if not asset_id: + return {"error": "asset_id is required"} + text = (callout_text or "").strip() + if not text: + return {"error": "callout_text is required"} + if len(text) > 25: + return { + "error": "Validation failed", + "details": [f"callout_text is {len(text)} chars (max 25)"], + } -def _validate_ad_group( - *, - campaign_id: str, - ad_group_name: str, - keywords: list[dict] | None, - cpc_bid_micros: int, -) -> list[str]: - """Validate inputs for draft_ad_group.""" - errors = [] - if not campaign_id: - errors.append("campaign_id is required") - if not ad_group_name or not ad_group_name.strip(): - errors.append("ad_group_name is required") - if cpc_bid_micros < 0: - errors.append("cpc_bid_micros must be >= 0") - if keywords: - for i, kw in enumerate(keywords): - if not kw.get("text"): - errors.append(f"Keyword {i + 1} has no text") - mt = (kw.get("match_type") or "").upper() - if mt not in _VALID_MATCH_TYPES: - errors.append( - f"Keyword {i + 1} has invalid match_type '{mt}' " - "(must be EXACT, PHRASE, or BROAD)" - ) - return errors + plan = ChangePlan( + operation="update_callout", + entity_type="asset", + entity_id=str(asset_id), + customer_id=customer_id, + changes={"asset_id": str(asset_id), "callout_text": text}, + ) + store_plan(plan) + return plan.to_preview() -def _preflight_ad_group_checks( +# --------------------------------------------------------------------------- +# link_asset_to_customer — promote existing assets to customer/account scope +# --------------------------------------------------------------------------- + +# AssetFieldType values that are valid for CustomerAsset (account-level). +# Asset types like SITELINK/CALLOUT/etc. are also valid here, but this tool +# is intended for "promote existing asset" use cases — typically images, +# logos, and business name assets that already exist in the account from +# legacy campaigns. +_VALID_CUSTOMER_ASSET_FIELD_TYPES = { + "SITELINK", "CALLOUT", "STRUCTURED_SNIPPET", "PROMOTION", "PRICE", + "CALL", "MOBILE_APP", "HOTEL_CALLOUT", "BUSINESS_LOGO", "BUSINESS_NAME", + "AD_IMAGE", "MARKETING_IMAGE", "SQUARE_MARKETING_IMAGE", + "PORTRAIT_MARKETING_IMAGE", "LOGO", "LANDSCAPE_LOGO", + "YOUTUBE_VIDEO", "MEDIA_BUNDLE", "BOOK_ON_GOOGLE", "LEAD_FORM", + "HEADLINE", "DESCRIPTION", "LONG_HEADLINE", +} + + +def link_asset_to_customer( config: AdLoopConfig, - customer_id: str, - campaign_id: str, - ad_group_name: str, - keywords: list[dict], - cpc_bid_micros: int, -) -> tuple[list[str], list[str]]: - """Run pre-flight checks before creating an ad group. + *, + customer_id: str = "", + links: list[dict] | None = None, +) -> dict: + """Link EXISTING assets to the customer (account) — returns a PREVIEW. - Returns (errors, warnings). Errors block the draft; warnings are informational. + Use this to "promote" assets that already exist in the account + (typically attached to legacy campaigns) so they apply at the account + level and inherit to every eligible campaign automatically. - Checks performed: - 1. Campaign must be a SEARCH campaign (error if not). - 2. Warn if cpc_bid_micros is set but campaign uses Smart Bidding (ignored). - 3. Warn if BROAD match keywords + non-Smart Bidding campaign. - 4. Warn if an ad group with the same name already exists in the campaign. + Unlike draft_image_assets / draft_callouts / etc., this tool does NOT + create new Asset rows — it only adds CustomerAsset link rows pointing + to assets you already have. Find candidate asset_ids via: + SELECT asset.id, asset.type, asset.name FROM asset + + Args: + links: list of dicts, each with: + - asset_id (str, required) — numeric asset ID + - field_type (str, required) — AssetFieldType, e.g. + BUSINESS_LOGO, AD_IMAGE, MARKETING_IMAGE, BUSINESS_NAME, + SITELINK, CALLOUT, CALL, PROMOTION, etc. + + Call confirm_and_apply with the returned plan_id to execute. """ - errors: list[str] = [] - warnings: list[str] = [] + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan try: - from adloop.ads.gaql import execute_query + check_blocked_operation("link_asset_to_customer", config.safety) + except SafetyViolation as e: + return {"error": str(e)} - # Query 1: campaign info (type, bidding, name) - campaign_query = f""" - SELECT campaign.advertising_channel_type, - campaign.bidding_strategy_type, - campaign.name - FROM campaign - WHERE campaign.id = {campaign_id} - """ - rows = execute_query(config, customer_id, campaign_query) - if not rows: + if not links: + return {"error": "At least one link is required"} + + errors: list[str] = [] + validated: list[dict] = [] + for i, item in enumerate(links): + if not isinstance(item, dict): + errors.append(f"Link {i + 1}: must be a dict, got {type(item).__name__}") + continue + asset_id = str(item.get("asset_id", "")).strip() + field_type = str(item.get("field_type", "")).strip().upper() + if not asset_id: + errors.append(f"Link {i + 1}: asset_id is required") + continue + if not asset_id.isdigit(): errors.append( - f"Campaign {campaign_id} not found. Verify the campaign ID " - "using get_campaign_performance." + f"Link {i + 1}: asset_id '{asset_id}' must be numeric" ) - return errors, warnings - - row = rows[0] - channel_type = row.get("campaign.advertising_channel_type", "") - bidding = row.get("campaign.bidding_strategy_type", "") - campaign_name = row.get("campaign.name", "") - - # Check 1: campaign type must be SEARCH - if channel_type and channel_type != "SEARCH": + continue + if not field_type: + errors.append(f"Link {i + 1}: field_type is required") + continue + if field_type not in _VALID_CUSTOMER_ASSET_FIELD_TYPES: errors.append( - f"Campaign '{campaign_name}' is a {channel_type} campaign. " - "draft_ad_group only supports SEARCH campaigns." + f"Link {i + 1}: field_type '{field_type}' is not valid for " + f"CustomerAsset; valid: " + f"{sorted(_VALID_CUSTOMER_ASSET_FIELD_TYPES)}" ) + continue + validated.append({"asset_id": asset_id, "field_type": field_type}) - # Check 2: cpc_bid_micros on Smart Bidding is ignored - if cpc_bid_micros and bidding in _SMART_BIDDING_STRATEGIES: - warnings.append( - f"Campaign '{campaign_name}' uses {bidding} (Smart Bidding). " - "The cpc_bid_micros value will be ignored — Smart Bidding " - "sets bids automatically." - ) - - # Check 3: BROAD match + non-Smart Bidding - has_broad = any( - (kw.get("match_type") or "").upper() == "BROAD" for kw in keywords - ) - if has_broad and bidding not in _SMART_BIDDING_STRATEGIES: - warnings.append( - f"DANGEROUS: Adding BROAD match keywords to campaign " - f"'{campaign_name}' which uses {bidding} bidding. " - f"Broad Match without Smart Bidding (tCPA/tROAS/Maximize " - f"Conversions) leads to irrelevant matches and wasted budget. " - f"Use PHRASE or EXACT match instead, or switch the campaign " - f"to Smart Bidding first." - ) - - # Check 4: existing ad groups (duplicate name check) - ag_query = f""" - SELECT ad_group.name - FROM ad_group - WHERE campaign.id = {campaign_id} - """ - ag_rows = execute_query(config, customer_id, ag_query) - existing_names = {r.get("ad_group.name", "") for r in ag_rows} - if ad_group_name in existing_names: - warnings.append( - f"An ad group named '{ad_group_name}' already exists in " - f"campaign '{campaign_name}'. This will create a duplicate. " - f"Consider using a different name to avoid confusion." - ) - - except Exception as exc: - # Surface preflight failures as warnings so users know checks - # were skipped, rather than silently producing a clean preview. - warnings.append( - f"Preflight checks could not complete ({exc}). " - "The draft will proceed, but some validations were skipped. " - "Full validation happens at confirm_and_apply time." - ) - - return errors, warnings - - -def _draft_status_change( - config: AdLoopConfig, - operation: str, - customer_id: str, - entity_type: str, - entity_id: str, - target_status: str, -) -> dict: - from adloop.safety.guards import SafetyViolation, check_blocked_operation - from adloop.safety.preview import ChangePlan, store_plan - - try: - check_blocked_operation(operation, config.safety) - except SafetyViolation as e: - return {"error": str(e)} - - errors = [] - if entity_type not in _VALID_ENTITY_TYPES: - errors.append( - f"entity_type must be one of {_VALID_ENTITY_TYPES}, got '{entity_type}'" - ) - if not entity_id: - errors.append("entity_id is required") if errors: return {"error": "Validation failed", "details": errors} plan = ChangePlan( - operation=operation, - entity_type=entity_type, - entity_id=entity_id, + operation="link_asset_to_customer", + entity_type="customer_asset", + entity_id=customer_id, customer_id=customer_id, - changes={"target_status": target_status}, + changes={"links": validated}, ) store_plan(plan) return plan.to_preview() # --------------------------------------------------------------------------- -# Execution — actual Google Ads API mutate calls +# confirm_and_apply — the only function that actually mutates Google Ads # --------------------------------------------------------------------------- -_MUTATE_RESPONSE_RESULT_FIELDS = [ - "campaign_budget_result", - "campaign_result", - "ad_group_result", - "ad_group_ad_result", - "ad_group_criterion_result", - "campaign_criterion_result", - "asset_result", - "campaign_asset_result", - "customer_asset_result", -] +def _extract_error_message(exc: Exception) -> str: + """Extract a meaningful error message from Google Ads API exceptions. + GoogleAdsException.__init__ doesn't call super().__init__(), so str(e) + returns ''. This function digs into the failure proto to surface the + actual error code, message, and trigger values. + """ + try: + from google.ads.googleads.errors import GoogleAdsException -def _extract_resource_name(resp: object) -> str: - """Extract the resource_name from a MutateOperationResponse. + if isinstance(exc, GoogleAdsException) and exc.failure: + parts = [] + for error in exc.failure.errors: + error_code = error.error_code + code_field = error_code.WhichOneof("error_code") + code_value = getattr(error_code, code_field) if code_field else "UNKNOWN" + line = f"[{code_field}={code_value.name if hasattr(code_value, 'name') else code_value}]" + if error.message: + line += f" {error.message}" + if error.trigger and error.trigger.string_value: + line += f" (trigger: {error.trigger.string_value})" + parts.append(line) + if parts: + msg = "; ".join(parts) + if exc.request_id: + msg += f" [request_id={exc.request_id}]" + return msg + except Exception: + pass - Uses direct field access instead of WhichOneof, which doesn't work on - proto-plus wrapped messages returned by the google-ads library. - """ - for field in _MUTATE_RESPONSE_RESULT_FIELDS: - try: - result = getattr(resp, field, None) - if result and result.resource_name: - return result.resource_name - except Exception: - continue - return "" + fallback = str(exc) + return fallback if fallback else repr(exc) -def _execute_plan(config: AdLoopConfig, plan: object) -> dict: - """Dispatch to the right Google Ads mutate call based on plan.operation.""" - from adloop.ads.client import get_ads_client, normalize_customer_id +def confirm_and_apply( + config: AdLoopConfig, + *, + plan_id: str = "", + dry_run: bool = True, +) -> dict: + """Execute a previously previewed change. - client = get_ads_client(config) - cid = normalize_customer_id(plan.customer_id) + Defaults to dry_run=True. The caller must explicitly pass dry_run=False + to make real changes. + """ + from adloop.safety.audit import log_mutation + from adloop.safety.preview import get_plan, remove_plan - dispatch = { - "create_campaign": _apply_create_campaign, - "create_ad_group": _apply_create_ad_group, - "update_campaign": _apply_update_campaign, - "update_ad_group": _apply_update_ad_group, - "create_responsive_search_ad": _apply_create_rsa, - "add_keywords": _apply_add_keywords, - "add_negative_keywords": _apply_add_negative_keywords, - "create_negative_keyword_list": _apply_create_negative_keyword_list, - "add_to_negative_keyword_list": _apply_add_to_negative_keyword_list, - "pause_entity": _apply_status_change, - "enable_entity": _apply_status_change, - "remove_entity": _apply_remove, - "create_callouts": _apply_create_callouts, - "create_structured_snippets": _apply_create_structured_snippets, - "create_image_assets": _apply_create_image_assets, - "create_sitelinks": _apply_create_sitelinks, - } + plan = get_plan(plan_id) + if plan is None: + return { + "error": f"No pending plan found with id '{plan_id}'. " + "Plans expire when the MCP server restarts.", + } - handler = dispatch.get(plan.operation) - if handler is None: - raise ValueError(f"Unknown operation: {plan.operation}") + forced_by_config = bool(config.safety.require_dry_run) and not dry_run + if config.safety.require_dry_run: + dry_run = True - if plan.operation in ("pause_entity", "enable_entity"): - return handler( - client, - cid, - plan.entity_type, - plan.entity_id, - plan.changes["target_status"], + if dry_run: + log_mutation( + config.safety.log_file, + operation=plan.operation, + customer_id=plan.customer_id, + entity_type=plan.entity_type, + entity_id=plan.entity_id, + changes=plan.changes, + dry_run=True, + result="dry_run_success", ) + response = { + "status": "DRY_RUN_SUCCESS", + "plan_id": plan.plan_id, + "operation": plan.operation, + "changes": plan.changes, + } + if forced_by_config: + # The caller passed dry_run=false but safety.require_dry_run + # forced it back on. Tell them exactly why and how to unlock + # real writes — without this, agents (e.g. Claude Code) retry + # in an infinite loop because the old message said to "call + # again with dry_run=false", which they already did. + config_path = config.source_path or "~/.adloop/config.yaml" + response["dry_run_forced_by"] = "config.safety.require_dry_run" + response["config_path"] = config_path + response["remediation"] = ( + f"Edit {config_path}, set 'require_dry_run: false' under " + "'safety:', then restart the AdLoop MCP server. Passing " + "dry_run=false on this tool will keep being overridden " + "until that flag is flipped." + ) + response["message"] = ( + f"dry_run=false was IGNORED because 'safety.require_dry_run: true' " + f"is set in {config_path}. No changes were made. To apply real " + f"changes, flip that flag to false and restart the AdLoop MCP " + f"server — retrying this tool with dry_run=false alone will " + f"never succeed while the flag is on." + ) + else: + response["message"] = ( + "Dry run completed — no changes were made to your Google Ads account. " + "To apply for real, call confirm_and_apply again with dry_run=false." + ) + return response - if plan.operation == "remove_entity": - return handler(client, cid, plan.entity_type, plan.entity_id) - - return handler(client, cid, plan.changes) + try: + result = _execute_plan(config, plan) + except Exception as e: + error_message = _extract_error_message(e) + log_mutation( + config.safety.log_file, + operation=plan.operation, + customer_id=plan.customer_id, + entity_type=plan.entity_type, + entity_id=plan.entity_id, + changes=plan.changes, + dry_run=False, + result="error", + error=error_message, + ) + return {"error": error_message, "plan_id": plan.plan_id} + log_mutation( + config.safety.log_file, + operation=plan.operation, + customer_id=plan.customer_id, + entity_type=plan.entity_type, + entity_id=plan.entity_id, + changes=plan.changes, + dry_run=False, + result="success", + ) + remove_plan(plan.plan_id) -def _apply_update_ad_group(client: object, cid: str, changes: dict) -> dict: - """Update an ad group's name and/or manual CPC bid.""" - from google.protobuf import field_mask_pb2 + return { + "status": "APPLIED", + "plan_id": plan.plan_id, + "operation": plan.operation, + "result": result, + } - service = client.get_service("AdGroupService") - operation = client.get_type("AdGroupOperation") - ad_group = operation.update - ad_group.resource_name = service.ad_group_path(cid, changes["ad_group_id"]) - field_paths = [] - if changes.get("ad_group_name"): - ad_group.name = changes["ad_group_name"] - field_paths.append("name") - if changes.get("max_cpc"): - ad_group.cpc_bid_micros = int(changes["max_cpc"] * 1_000_000) - field_paths.append("cpc_bid_micros") +# --------------------------------------------------------------------------- +# Internal validation helpers +# --------------------------------------------------------------------------- - operation.update_mask = field_mask_pb2.FieldMask(paths=field_paths) - response = service.mutate_ad_groups(customer_id=cid, operations=[operation]) - return {"resource_name": response.results[0].resource_name} +_VALID_MATCH_TYPES = {"EXACT", "PHRASE", "BROAD"} +_VALID_ENTITY_TYPES = {"campaign", "ad_group", "ad", "keyword"} +_REMOVABLE_ENTITY_TYPES = _VALID_ENTITY_TYPES | { + "negative_keyword", "campaign_asset", "asset", "customer_asset", + "shared_criterion", +} +_SMART_BIDDING_STRATEGIES = { + "MAXIMIZE_CONVERSIONS", + "MAXIMIZE_CONVERSION_VALUE", + "TARGET_CPA", + "TARGET_ROAS", +} -def _apply_create_campaign(client: object, cid: str, changes: dict) -> dict: - """Create campaign + budget + ad group + optional keywords atomically.""" - service = client.get_service("GoogleAdsService") - campaign_service = client.get_service("CampaignService") - budget_service = client.get_service("CampaignBudgetService") - ad_group_service = client.get_service("AdGroupService") - operations = [] +def _campaign_uses_manual_cpc( + config: AdLoopConfig, customer_id: str, campaign_id: str +) -> bool | None: + """Return True when the campaign exists and uses MANUAL_CPC.""" + bidding_strategy = _campaign_bidding_strategy(config, customer_id, campaign_id) + if bidding_strategy is None: + return None + return bidding_strategy == "MANUAL_CPC" - # 1. CampaignBudget (temp ID: -1) - budget_op = client.get_type("MutateOperation") - budget = budget_op.campaign_budget_operation.create - budget.resource_name = budget_service.campaign_budget_path(cid, "-1") - budget.name = f"Budget - {changes['campaign_name']}" - budget.amount_micros = int(changes["daily_budget"] * 1_000_000) - budget.delivery_method = client.enums.BudgetDeliveryMethodEnum.STANDARD - budget.explicitly_shared = False - operations.append(budget_op) - # 2. Campaign (temp ID: -2, references budget -1) - campaign_op = client.get_type("MutateOperation") - campaign = campaign_op.campaign_operation.create - campaign.resource_name = campaign_service.campaign_path(cid, "-2") - campaign.name = changes["campaign_name"] - campaign.campaign_budget = budget_service.campaign_budget_path(cid, "-1") - campaign.status = client.enums.CampaignStatusEnum.PAUSED +def _campaign_bidding_strategy( + config: AdLoopConfig, customer_id: str, campaign_id: str +) -> str | None: + """Return the bidding strategy type for the campaign, if it exists.""" + from adloop.ads.gaql import execute_query - channel = changes.get("channel_type", "SEARCH") - campaign.advertising_channel_type = getattr( - client.enums.AdvertisingChannelTypeEnum, channel - ) + query = f""" + SELECT campaign.bidding_strategy_type + FROM campaign + WHERE campaign.id = {campaign_id} + LIMIT 1 + """ + rows = execute_query(config, customer_id, query) + if not rows: + return None + return rows[0].get("campaign.bidding_strategy_type") - bs = changes["bidding_strategy"] - if bs == "MAXIMIZE_CONVERSIONS": - campaign.maximize_conversions.target_cpa_micros = 0 - if changes.get("target_cpa"): - campaign.maximize_conversions.target_cpa_micros = int( - changes["target_cpa"] * 1_000_000 + +def _ad_group_uses_manual_cpc( + config: AdLoopConfig, customer_id: str, ad_group_id: str +) -> bool | None: + """Return True when the ad group exists in a MANUAL_CPC campaign.""" + from adloop.ads.gaql import execute_query + + query = f""" + SELECT campaign.bidding_strategy_type + FROM ad_group + WHERE ad_group.id = {ad_group_id} + LIMIT 1 + """ + rows = execute_query(config, customer_id, query) + if not rows: + return None + return rows[0].get("campaign.bidding_strategy_type") == "MANUAL_CPC" + + +_VALID_DAYS_OF_WEEK = { + "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", + "SATURDAY", "SUNDAY", +} +_VALID_MINUTES = {0, 15, 30, 45} + + +def _validate_ad_schedule( + schedule: list[dict], +) -> tuple[list[dict], list[str]]: + """Validate ad schedule entries. Returns (validated, errors). + + Each entry: {day_of_week, start_hour, end_hour, start_minute=0, end_minute=0}. + Google Ads only accepts minutes in {0, 15, 30, 45}, hours in 0-24, and + requires end > start. Day-of-week strings are normalized to upper-case. + """ + errors = [] + validated = [] + for i, entry in enumerate(schedule or []): + if not isinstance(entry, dict): + errors.append(f"ad_schedule[{i}]: must be a dict") + continue + day = str(entry.get("day_of_week", "")).strip().upper() + if day not in _VALID_DAYS_OF_WEEK: + errors.append( + f"ad_schedule[{i}]: day_of_week must be one of {sorted(_VALID_DAYS_OF_WEEK)}" ) - elif bs == "TARGET_CPA": - campaign.maximize_conversions.target_cpa_micros = int( - changes["target_cpa"] * 1_000_000 - ) - elif bs == "MAXIMIZE_CONVERSION_VALUE": - campaign.maximize_conversion_value.target_roas = 0 - if changes.get("target_roas"): - campaign.maximize_conversion_value.target_roas = changes["target_roas"] - elif bs == "TARGET_ROAS": - campaign.maximize_conversion_value.target_roas = changes["target_roas"] - elif bs == "TARGET_SPEND": - campaign.target_spend.target_spend_micros = 0 - if changes.get("max_cpc"): - campaign.target_spend.cpc_bid_ceiling_micros = int( - changes["max_cpc"] * 1_000_000 + continue + try: + start_hour = int(entry.get("start_hour", -1)) + end_hour = int(entry.get("end_hour", -1)) + start_minute = int(entry.get("start_minute", 0)) + end_minute = int(entry.get("end_minute", 0)) + except (TypeError, ValueError): + errors.append(f"ad_schedule[{i}]: hour/minute values must be integers") + continue + if not (0 <= start_hour <= 23): + errors.append(f"ad_schedule[{i}]: start_hour must be in 0..23") + if not (0 <= end_hour <= 24): + errors.append(f"ad_schedule[{i}]: end_hour must be in 0..24") + if start_minute not in _VALID_MINUTES: + errors.append( + f"ad_schedule[{i}]: start_minute must be one of {sorted(_VALID_MINUTES)}" ) - elif bs == "MANUAL_CPC": - campaign.manual_cpc.enhanced_cpc_enabled = False + if end_minute not in _VALID_MINUTES: + errors.append( + f"ad_schedule[{i}]: end_minute must be one of {sorted(_VALID_MINUTES)}" + ) + if (end_hour, end_minute) <= (start_hour, start_minute): + errors.append( + f"ad_schedule[{i}]: end ({end_hour}:{end_minute:02d}) must be after " + f"start ({start_hour}:{start_minute:02d})" + ) + validated.append({ + "day_of_week": day, + "start_hour": start_hour, + "start_minute": start_minute, + "end_hour": end_hour, + "end_minute": end_minute, + }) + return validated, errors - campaign.network_settings.target_google_search = True - campaign.network_settings.target_search_network = changes.get( - "search_partners_enabled", False - ) - campaign.network_settings.target_content_network = changes.get( - "display_network_enabled", False - ) - # EU political advertising declaration — required for campaigns that may - # serve in EU countries. This is an ENUM, not a bool. Value 3 means - # "does not contain EU political advertising" (the default for most users). - # Setting False/0 maps to UNSPECIFIED which proto3 strips from the wire. - campaign.contains_eu_political_advertising = ( - client.enums.EuPoliticalAdvertisingStatusEnum.DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING - ) +def _validate_callouts( + callouts: list[str], +) -> tuple[list[str], list[str]]: + errors = [] + validated = [] - operations.append(campaign_op) + if not callouts: + errors.append("At least one callout is required") - # 3. AdGroup (temp ID: -3, references campaign -2) - ag_op = client.get_type("MutateOperation") - ad_group = ag_op.ad_group_operation.create - ad_group.resource_name = ad_group_service.ad_group_path(cid, "-3") - ad_group.name = changes.get("ad_group_name", changes["campaign_name"]) - ad_group.campaign = campaign_service.campaign_path(cid, "-2") - ad_group.status = client.enums.AdGroupStatusEnum.ENABLED - ad_group.type_ = client.enums.AdGroupTypeEnum.SEARCH_STANDARD - if bs == "MANUAL_CPC" and changes.get("max_cpc"): - ad_group.cpc_bid_micros = int(changes["max_cpc"] * 1_000_000) - operations.append(ag_op) + for index, callout in enumerate(callouts): + text = callout.strip() + if not text: + errors.append(f"Callout {index + 1}: text is required") + elif len(text) > 25: + errors.append( + f"Callout {index + 1}: '{text}' is {len(text)} chars (max 25)" + ) + else: + validated.append(text) - # 4. Keywords (reference ad_group -3) - kw_list = changes.get("keywords") or [] - for kw in kw_list: - kw_op = client.get_type("MutateOperation") - criterion = kw_op.ad_group_criterion_operation.create - criterion.ad_group = ad_group_service.ad_group_path(cid, "-3") - criterion.keyword.text = kw["text"] - criterion.keyword.match_type = getattr( - client.enums.KeywordMatchTypeEnum, kw["match_type"].upper() - ) - operations.append(kw_op) + return validated, errors - # 5. Geo targeting (CampaignCriterion referencing campaign -2) - for geo_id in changes.get("geo_target_ids") or []: - geo_op = client.get_type("MutateOperation") - geo_criterion = geo_op.campaign_criterion_operation.create - geo_criterion.campaign = campaign_service.campaign_path(cid, "-2") - geo_criterion.location.geo_target_constant = ( - f"geoTargetConstants/{geo_id}" - ) - operations.append(geo_op) - # 6. Language targeting (CampaignCriterion referencing campaign -2) - for lang_id in changes.get("language_ids") or []: - lang_op = client.get_type("MutateOperation") - lang_criterion = lang_op.campaign_criterion_operation.create - lang_criterion.campaign = campaign_service.campaign_path(cid, "-2") - lang_criterion.language.language_constant = ( - f"languageConstants/{lang_id}" - ) - operations.append(lang_op) +def _validate_structured_snippets( + snippets: list[dict], +) -> tuple[list[dict], list[str]]: + errors = [] + validated = [] + + if not snippets: + errors.append("At least one structured snippet is required") + + for index, snippet in enumerate(snippets): + header = snippet.get("header", "").strip() + values = [value.strip() for value in snippet.get("values", [])] + + if header not in _STRUCTURED_SNIPPET_HEADERS: + errors.append( + f"Structured snippet {index + 1}: header must be one of " + f"{sorted(_STRUCTURED_SNIPPET_HEADERS)}" + ) + if len(values) < 3 or len(values) > 10: + errors.append( + f"Structured snippet {index + 1}: values must contain 3-10 items" + ) + for value_index, value in enumerate(values): + if not value: + errors.append( + f"Structured snippet {index + 1}: value {value_index + 1} is required" + ) + elif len(value) > 25: + errors.append( + f"Structured snippet {index + 1}: value '{value}' is " + f"{len(value)} chars (max 25)" + ) + + validated.append({"header": header, "values": values}) + + return validated, errors + + +def _validate_image_assets( + image_paths: list[str], +) -> tuple[list[dict[str, object]], list[str]]: + errors = [] + validated = [] + + if not image_paths: + errors.append("At least one image path is required") + + for index, image_path in enumerate(image_paths): + try: + validated.append(_parse_image_metadata(image_path)) + except ValueError as exc: + errors.append(f"Image {index + 1}: {exc}") + + return validated, errors + + +def _check_broad_match_safety( + config: AdLoopConfig, + customer_id: str, + ad_group_id: str, + keywords: list[dict], +) -> list[str]: + """Warn if BROAD match keywords are being added to a non-Smart Bidding campaign.""" + has_broad = any( + (kw.get("match_type") or "").upper() == "BROAD" for kw in keywords + ) + if not has_broad: + return [] + + try: + from adloop.ads.gaql import execute_query + + query = f""" + SELECT campaign.bidding_strategy_type, campaign.name + FROM ad_group + WHERE ad_group.id = {ad_group_id} + """ + rows = execute_query(config, customer_id, query) + if not rows: + return [] + + bidding = rows[0].get("campaign.bidding_strategy_type", "") + campaign_name = rows[0].get("campaign.name", "") + + if bidding not in _SMART_BIDDING_STRATEGIES: + return [ + f"DANGEROUS: Adding BROAD match keywords to campaign " + f"'{campaign_name}' which uses {bidding} bidding. " + f"Broad Match without Smart Bidding (tCPA/tROAS/Maximize Conversions) " + f"leads to irrelevant matches and wasted budget. " + f"Use PHRASE or EXACT match instead, or switch the campaign " + f"to Smart Bidding first." + ] + except Exception: + pass + + return [] + + +def _validate_rsa( + ad_group_id: str, + headlines: list[dict], + descriptions: list[dict], + final_url: str, +) -> list[str]: + errors = [] + if not ad_group_id: + errors.append("ad_group_id is required") + if not final_url: + errors.append("final_url is required") + if len(headlines) < 3: + errors.append(f"Need at least 3 headlines, got {len(headlines)}") + if len(headlines) > 15: + errors.append(f"Maximum 15 headlines, got {len(headlines)}") + if len(descriptions) < 2: + errors.append(f"Need at least 2 descriptions, got {len(descriptions)}") + if len(descriptions) > 4: + errors.append(f"Maximum 4 descriptions, got {len(descriptions)}") + + headline_pin_counts: dict[str, int] = {} + for i, h in enumerate(headlines): + text = h["text"] + pin = h["pinned_field"] + if len(text) > 30: + errors.append( + f"Headline {i + 1} exceeds 30 chars ({len(text)}): '{text}'" + ) + if pin is not None: + if pin not in _VALID_HEADLINE_PINS: + errors.append( + f"Headline {i + 1} pinned_field '{pin}' invalid; " + f"must be one of {sorted(_VALID_HEADLINE_PINS)} or null" + ) + else: + headline_pin_counts[pin] = headline_pin_counts.get(pin, 0) + 1 + for pin, count in headline_pin_counts.items(): + if count > 2: + errors.append(f"At most 2 headlines may pin to {pin}; got {count}") + + description_pin_counts: dict[str, int] = {} + for i, d in enumerate(descriptions): + text = d["text"] + pin = d["pinned_field"] + if len(text) > 90: + errors.append( + f"Description {i + 1} exceeds 90 chars ({len(text)}): '{text}'" + ) + if pin is not None: + if pin not in _VALID_DESCRIPTION_PINS: + errors.append( + f"Description {i + 1} pinned_field '{pin}' invalid; " + f"must be one of {sorted(_VALID_DESCRIPTION_PINS)} or null" + ) + else: + description_pin_counts[pin] = description_pin_counts.get(pin, 0) + 1 + for pin, count in description_pin_counts.items(): + if count > 1: + errors.append(f"At most 1 description may pin to {pin}; got {count}") + + return errors + + +_VALID_BIDDING_STRATEGIES = { + "MAXIMIZE_CONVERSIONS", + "MAXIMIZE_CONVERSION_VALUE", + "TARGET_CPA", + "TARGET_ROAS", + "TARGET_SPEND", + "MANUAL_CPC", +} + +_VALID_CHANNEL_TYPES = {"SEARCH", "DISPLAY", "SHOPPING", "VIDEO", "PERFORMANCE_MAX"} + + +def _validate_campaign( + config: AdLoopConfig, + *, + campaign_name: str, + daily_budget: float, + bidding_strategy: str, + target_cpa: float, + target_roas: float, + channel_type: str, + keywords: list[dict] | None, + geo_target_ids: list[str] | None, + language_ids: list[str] | None, + customer_id: str = "", + search_partners_enabled: bool = False, + display_network_enabled: bool = False, + max_cpc: float = 0, +) -> tuple[list[str], list[str]]: + """Validate campaign draft inputs. Returns (errors, warnings).""" + errors = [] + warnings = [] + + if not campaign_name or not campaign_name.strip(): + errors.append("campaign_name is required") + if daily_budget <= 0: + errors.append("daily_budget must be greater than 0") + if not geo_target_ids: + errors.append( + "geo_target_ids is required — campaigns must target at least one " + "country/region (e.g. ['2276'] for Germany, ['2840'] for USA)" + ) + if not language_ids: + errors.append( + "language_ids is required — campaigns must target at least one " + "language (e.g. ['1001'] for German, ['1000'] for English)" + ) + + bs = bidding_strategy.upper() + if bs not in _VALID_BIDDING_STRATEGIES: + errors.append( + f"bidding_strategy must be one of {sorted(_VALID_BIDDING_STRATEGIES)}, " + f"got '{bidding_strategy}'" + ) + if bs == "TARGET_CPA" and not target_cpa: + errors.append("target_cpa is required when bidding_strategy is TARGET_CPA") + if bs == "TARGET_ROAS" and not target_roas: + errors.append("target_roas is required when bidding_strategy is TARGET_ROAS") + + ct = channel_type.upper() + if ct not in _VALID_CHANNEL_TYPES: + errors.append( + f"channel_type must be one of {sorted(_VALID_CHANNEL_TYPES)}, " + f"got '{channel_type}'" + ) + if ct != "SEARCH" and search_partners_enabled: + errors.append("search_partners_enabled is only supported for SEARCH campaigns") + if ct != "SEARCH" and display_network_enabled: + errors.append("display_network_enabled is only supported for SEARCH campaigns") + if max_cpc < 0: + errors.append("max_cpc cannot be negative") + if max_cpc and bs not in {"MANUAL_CPC", "TARGET_SPEND"}: + errors.append("max_cpc requires MANUAL_CPC or TARGET_SPEND bidding_strategy") + + if keywords: + has_broad = any( + (kw.get("match_type") or "").upper() == "BROAD" for kw in keywords + ) + if has_broad and bs not in _SMART_BIDDING_STRATEGIES: + errors.append( + f"BROAD match keywords require Smart Bidding " + f"(tCPA/tROAS/Maximize Conversions). " + f"'{bidding_strategy}' is not a Smart Bidding strategy. " + f"Use PHRASE or EXACT match instead." + ) + for i, kw in enumerate(keywords): + if not kw.get("text"): + errors.append(f"Keyword {i + 1} has no text") + mt = (kw.get("match_type") or "").upper() + if mt not in _VALID_MATCH_TYPES: + errors.append( + f"Keyword {i + 1} has invalid match_type '{mt}' " + "(must be EXACT, PHRASE, or BROAD)" + ) + + if target_cpa > 0 and daily_budget < 5 * target_cpa: + from adloop.ads.currency import format_currency, get_currency_code + currency_code = get_currency_code(config, customer_id) + warnings.append( + f"Daily budget {format_currency(daily_budget, currency_code)} is less than 5x target CPA " + f"{format_currency(target_cpa, currency_code)}. Google recommends at least 5x target CPA " + f"({format_currency(5 * target_cpa, currency_code)}/day) for sufficient learning data." + ) + + if bs == "MANUAL_CPC": + warnings.append( + "MANUAL_CPC bidding requires constant monitoring. Consider using " + "MAXIMIZE_CONVERSIONS or TARGET_CPA for automated optimization." + ) + + return errors, warnings + + +def _validate_keywords(ad_group_id: str, keywords: list[dict]) -> list[str]: + errors = [] + if not ad_group_id: + errors.append("ad_group_id is required") + if not keywords: + errors.append("At least one keyword is required") + for i, kw in enumerate(keywords): + if not kw.get("text"): + errors.append(f"Keyword {i + 1} has no text") + mt = (kw.get("match_type") or "").upper() + if mt not in _VALID_MATCH_TYPES: + errors.append( + f"Keyword {i + 1} has invalid match_type '{mt}' " + "(must be EXACT, PHRASE, or BROAD)" + ) + return errors + + +def _validate_ad_group( + *, + campaign_id: str, + ad_group_name: str, + keywords: list[dict] | None, + cpc_bid_micros: int, +) -> list[str]: + """Validate inputs for draft_ad_group.""" + errors = [] + if not campaign_id: + errors.append("campaign_id is required") + if not ad_group_name or not ad_group_name.strip(): + errors.append("ad_group_name is required") + if cpc_bid_micros < 0: + errors.append("cpc_bid_micros must be >= 0") + if keywords: + for i, kw in enumerate(keywords): + if not kw.get("text"): + errors.append(f"Keyword {i + 1} has no text") + mt = (kw.get("match_type") or "").upper() + if mt not in _VALID_MATCH_TYPES: + errors.append( + f"Keyword {i + 1} has invalid match_type '{mt}' " + "(must be EXACT, PHRASE, or BROAD)" + ) + return errors + + +def _preflight_ad_group_checks( + config: AdLoopConfig, + customer_id: str, + campaign_id: str, + ad_group_name: str, + keywords: list[dict], + cpc_bid_micros: int, +) -> tuple[list[str], list[str]]: + """Run pre-flight checks before creating an ad group. + + Returns (errors, warnings). Errors block the draft; warnings are informational. + + Checks performed: + 1. Campaign must be a SEARCH campaign (error if not). + 2. Warn if cpc_bid_micros is set but campaign uses Smart Bidding (ignored). + 3. Warn if BROAD match keywords + non-Smart Bidding campaign. + 4. Warn if an ad group with the same name already exists in the campaign. + """ + errors: list[str] = [] + warnings: list[str] = [] + + try: + from adloop.ads.gaql import execute_query + + # Query 1: campaign info (type, bidding, name) + campaign_query = f""" + SELECT campaign.advertising_channel_type, + campaign.bidding_strategy_type, + campaign.name + FROM campaign + WHERE campaign.id = {campaign_id} + """ + rows = execute_query(config, customer_id, campaign_query) + if not rows: + errors.append( + f"Campaign {campaign_id} not found. Verify the campaign ID " + "using get_campaign_performance." + ) + return errors, warnings + + row = rows[0] + channel_type = row.get("campaign.advertising_channel_type", "") + bidding = row.get("campaign.bidding_strategy_type", "") + campaign_name = row.get("campaign.name", "") + + # Check 1: campaign type must be SEARCH + if channel_type and channel_type != "SEARCH": + errors.append( + f"Campaign '{campaign_name}' is a {channel_type} campaign. " + "draft_ad_group only supports SEARCH campaigns." + ) + + # Check 2: cpc_bid_micros on Smart Bidding is ignored + if cpc_bid_micros and bidding in _SMART_BIDDING_STRATEGIES: + warnings.append( + f"Campaign '{campaign_name}' uses {bidding} (Smart Bidding). " + "The cpc_bid_micros value will be ignored — Smart Bidding " + "sets bids automatically." + ) + + # Check 3: BROAD match + non-Smart Bidding + has_broad = any( + (kw.get("match_type") or "").upper() == "BROAD" for kw in keywords + ) + if has_broad and bidding not in _SMART_BIDDING_STRATEGIES: + warnings.append( + f"DANGEROUS: Adding BROAD match keywords to campaign " + f"'{campaign_name}' which uses {bidding} bidding. " + f"Broad Match without Smart Bidding (tCPA/tROAS/Maximize " + f"Conversions) leads to irrelevant matches and wasted budget. " + f"Use PHRASE or EXACT match instead, or switch the campaign " + f"to Smart Bidding first." + ) + + # Check 4: existing ad groups (duplicate name check) + ag_query = f""" + SELECT ad_group.name + FROM ad_group + WHERE campaign.id = {campaign_id} + """ + ag_rows = execute_query(config, customer_id, ag_query) + existing_names = {r.get("ad_group.name", "") for r in ag_rows} + if ad_group_name in existing_names: + warnings.append( + f"An ad group named '{ad_group_name}' already exists in " + f"campaign '{campaign_name}'. This will create a duplicate. " + f"Consider using a different name to avoid confusion." + ) + + except Exception as exc: + # Surface preflight failures as warnings so users know checks + # were skipped, rather than silently producing a clean preview. + warnings.append( + f"Preflight checks could not complete ({exc}). " + "The draft will proceed, but some validations were skipped. " + "Full validation happens at confirm_and_apply time." + ) + + return errors, warnings + + +def _draft_status_change( + config: AdLoopConfig, + operation: str, + customer_id: str, + entity_type: str, + entity_id: str, + target_status: str, +) -> dict: + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation(operation, config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + errors = [] + if entity_type not in _VALID_ENTITY_TYPES: + errors.append( + f"entity_type must be one of {_VALID_ENTITY_TYPES}, got '{entity_type}'" + ) + if not entity_id: + errors.append("entity_id is required") + if errors: + return {"error": "Validation failed", "details": errors} + + plan = ChangePlan( + operation=operation, + entity_type=entity_type, + entity_id=entity_id, + customer_id=customer_id, + changes={"target_status": target_status}, + ) + store_plan(plan) + return plan.to_preview() + + +# --------------------------------------------------------------------------- +# Execution — actual Google Ads API mutate calls +# --------------------------------------------------------------------------- + + +_MUTATE_RESPONSE_RESULT_FIELDS = [ + "campaign_budget_result", + "campaign_result", + "ad_group_result", + "ad_group_ad_result", + "ad_group_criterion_result", + "campaign_criterion_result", + "asset_result", + "campaign_asset_result", + "customer_asset_result", +] + + +def _extract_resource_name(resp: object) -> str: + """Extract the resource_name from a MutateOperationResponse. + + Uses direct field access instead of WhichOneof, which doesn't work on + proto-plus wrapped messages returned by the google-ads library. + """ + for field in _MUTATE_RESPONSE_RESULT_FIELDS: + try: + result = getattr(resp, field, None) + if result and result.resource_name: + return result.resource_name + except Exception: + continue + return "" + + +def _execute_plan(config: AdLoopConfig, plan: object) -> dict: + """Dispatch to the right Google Ads mutate call based on plan.operation.""" + from adloop.ads.client import get_ads_client, normalize_customer_id + + client = get_ads_client(config) + cid = normalize_customer_id(plan.customer_id) + + dispatch = { + "create_campaign": _apply_create_campaign, + "create_ad_group": _apply_create_ad_group, + "update_campaign": _apply_update_campaign, + "update_ad_group": _apply_update_ad_group, + "create_responsive_search_ad": _apply_create_rsa, + "update_responsive_search_ad": _apply_update_rsa, + "add_keywords": _apply_add_keywords, + "add_negative_keywords": _apply_add_negative_keywords, + "create_negative_keyword_list": _apply_create_negative_keyword_list, + "add_to_negative_keyword_list": _apply_add_to_negative_keyword_list, + "pause_entity": _apply_status_change, + "enable_entity": _apply_status_change, + "remove_entity": _apply_remove, + "create_callouts": _apply_create_callouts, + "create_structured_snippets": _apply_create_structured_snippets, + "create_image_assets": _apply_create_image_assets, + "create_sitelinks": _apply_create_sitelinks, + "create_call_asset": _apply_create_call_asset, + "create_location_asset": _apply_create_location_asset, + "create_business_name_asset": _apply_create_business_name_asset, + "create_promotion": _apply_create_promotion, + "update_promotion": _apply_update_promotion, + "link_asset_to_customer": _apply_link_asset_to_customer, + "update_call_asset": _apply_update_call_asset, + "update_sitelink": _apply_update_sitelink, + "update_callout": _apply_update_callout, + "create_conversion_action": _apply_create_conversion_action_route, + "update_conversion_action": _apply_update_conversion_action_route, + "remove_conversion_action": _apply_remove_conversion_action_route, + "add_ad_schedule": _apply_add_ad_schedule, + "add_geo_exclusions": _apply_add_geo_exclusions, + } + + handler = dispatch.get(plan.operation) + if handler is None: + raise ValueError(f"Unknown operation: {plan.operation}") + + if plan.operation in ("pause_entity", "enable_entity"): + return handler( + client, + cid, + plan.entity_type, + plan.entity_id, + plan.changes["target_status"], + ) + + if plan.operation == "remove_entity": + return handler(client, cid, plan.entity_type, plan.entity_id) + + return handler(client, cid, plan.changes) + + +def _apply_update_ad_group(client: object, cid: str, changes: dict) -> dict: + """Update an ad group's name and/or manual CPC bid.""" + from google.protobuf import field_mask_pb2 + + service = client.get_service("AdGroupService") + operation = client.get_type("AdGroupOperation") + ad_group = operation.update + ad_group.resource_name = service.ad_group_path(cid, changes["ad_group_id"]) + + field_paths = [] + if changes.get("ad_group_name"): + ad_group.name = changes["ad_group_name"] + field_paths.append("name") + if changes.get("max_cpc"): + ad_group.cpc_bid_micros = int(changes["max_cpc"] * 1_000_000) + field_paths.append("cpc_bid_micros") + + operation.update_mask = field_mask_pb2.FieldMask(paths=field_paths) + response = service.mutate_ad_groups(customer_id=cid, operations=[operation]) + return {"resource_name": response.results[0].resource_name} + + +def _apply_create_campaign(client: object, cid: str, changes: dict) -> dict: + """Create campaign + budget + ad group + optional keywords atomically.""" + service = client.get_service("GoogleAdsService") + campaign_service = client.get_service("CampaignService") + budget_service = client.get_service("CampaignBudgetService") + ad_group_service = client.get_service("AdGroupService") + + operations = [] + + # 1. CampaignBudget (temp ID: -1) + budget_op = client.get_type("MutateOperation") + budget = budget_op.campaign_budget_operation.create + budget.resource_name = budget_service.campaign_budget_path(cid, "-1") + budget.name = f"Budget - {changes['campaign_name']}" + budget.amount_micros = int(changes["daily_budget"] * 1_000_000) + budget.delivery_method = client.enums.BudgetDeliveryMethodEnum.STANDARD + budget.explicitly_shared = False + operations.append(budget_op) + + # 2. Campaign (temp ID: -2, references budget -1) + campaign_op = client.get_type("MutateOperation") + campaign = campaign_op.campaign_operation.create + campaign.resource_name = campaign_service.campaign_path(cid, "-2") + campaign.name = changes["campaign_name"] + campaign.campaign_budget = budget_service.campaign_budget_path(cid, "-1") + campaign.status = client.enums.CampaignStatusEnum.PAUSED + + channel = changes.get("channel_type", "SEARCH") + campaign.advertising_channel_type = getattr( + client.enums.AdvertisingChannelTypeEnum, channel + ) + + bs = changes["bidding_strategy"] + if bs == "MAXIMIZE_CONVERSIONS": + campaign.maximize_conversions.target_cpa_micros = 0 + if changes.get("target_cpa"): + campaign.maximize_conversions.target_cpa_micros = int( + changes["target_cpa"] * 1_000_000 + ) + elif bs == "TARGET_CPA": + campaign.maximize_conversions.target_cpa_micros = int( + changes["target_cpa"] * 1_000_000 + ) + elif bs == "MAXIMIZE_CONVERSION_VALUE": + campaign.maximize_conversion_value.target_roas = 0 + if changes.get("target_roas"): + campaign.maximize_conversion_value.target_roas = changes["target_roas"] + elif bs == "TARGET_ROAS": + campaign.maximize_conversion_value.target_roas = changes["target_roas"] + elif bs == "TARGET_SPEND": + campaign.target_spend.target_spend_micros = 0 + if changes.get("max_cpc"): + campaign.target_spend.cpc_bid_ceiling_micros = int( + changes["max_cpc"] * 1_000_000 + ) + elif bs == "MANUAL_CPC": + campaign.manual_cpc.enhanced_cpc_enabled = False + + campaign.network_settings.target_google_search = True + campaign.network_settings.target_search_network = changes.get( + "search_partners_enabled", False + ) + campaign.network_settings.target_content_network = changes.get( + "display_network_enabled", False + ) + + # EU political advertising declaration — required for campaigns that may + # serve in EU countries. This is an ENUM, not a bool. Value 3 means + # "does not contain EU political advertising" (the default for most users). + # Setting False/0 maps to UNSPECIFIED which proto3 strips from the wire. + campaign.contains_eu_political_advertising = ( + client.enums.EuPoliticalAdvertisingStatusEnum.DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING + ) + + operations.append(campaign_op) + + # 3. AdGroup (temp ID: -3, references campaign -2) + ag_op = client.get_type("MutateOperation") + ad_group = ag_op.ad_group_operation.create + ad_group.resource_name = ad_group_service.ad_group_path(cid, "-3") + ad_group.name = changes.get("ad_group_name", changes["campaign_name"]) + ad_group.campaign = campaign_service.campaign_path(cid, "-2") + ad_group.status = client.enums.AdGroupStatusEnum.ENABLED + ad_group.type_ = client.enums.AdGroupTypeEnum.SEARCH_STANDARD + if bs == "MANUAL_CPC" and changes.get("max_cpc"): + ad_group.cpc_bid_micros = int(changes["max_cpc"] * 1_000_000) + operations.append(ag_op) + + # 4. Keywords (reference ad_group -3) + kw_list = changes.get("keywords") or [] + for kw in kw_list: + kw_op = client.get_type("MutateOperation") + criterion = kw_op.ad_group_criterion_operation.create + criterion.ad_group = ad_group_service.ad_group_path(cid, "-3") + criterion.keyword.text = kw["text"] + criterion.keyword.match_type = getattr( + client.enums.KeywordMatchTypeEnum, kw["match_type"].upper() + ) + operations.append(kw_op) + + # 5. Geo targeting (CampaignCriterion referencing campaign -2) + for geo_id in changes.get("geo_target_ids") or []: + geo_op = client.get_type("MutateOperation") + geo_criterion = geo_op.campaign_criterion_operation.create + geo_criterion.campaign = campaign_service.campaign_path(cid, "-2") + geo_criterion.location.geo_target_constant = ( + f"geoTargetConstants/{geo_id}" + ) + operations.append(geo_op) + + # 6. Language targeting (CampaignCriterion referencing campaign -2) + for lang_id in changes.get("language_ids") or []: + lang_op = client.get_type("MutateOperation") + lang_criterion = lang_op.campaign_criterion_operation.create + lang_criterion.campaign = campaign_service.campaign_path(cid, "-2") + lang_criterion.language.language_constant = ( + f"languageConstants/{lang_id}" + ) + operations.append(lang_op) + + # 7. Geo exclusions (negative CampaignCriterion location records) + for geo_id in changes.get("geo_exclude_ids") or []: + op = client.get_type("MutateOperation") + crit = op.campaign_criterion_operation.create + crit.campaign = campaign_service.campaign_path(cid, "-2") + crit.location.geo_target_constant = f"geoTargetConstants/{geo_id}" + crit.negative = True + operations.append(op) + + # 8. Ad schedule (CampaignCriterion AdScheduleInfo records) + for entry in changes.get("ad_schedule") or []: + op = client.get_type("MutateOperation") + crit = op.campaign_criterion_operation.create + crit.campaign = campaign_service.campaign_path(cid, "-2") + _populate_ad_schedule_info(client, crit.ad_schedule, entry) + operations.append(op) + + response = service.mutate(customer_id=cid, mutate_operations=operations) + + results = {} + num_keywords = len(kw_list) + num_geo = len(changes.get("geo_target_ids") or []) + num_lang = len(changes.get("language_ids") or []) + num_excl = len(changes.get("geo_exclude_ids") or []) + num_sched = len(changes.get("ad_schedule") or []) + for i, resp in enumerate(response.mutate_operation_responses): + rn = _extract_resource_name(resp) + if not rn: + continue + if i == 0: + results["campaign_budget"] = rn + elif i == 1: + results["campaign"] = rn + elif i == 2: + results["ad_group"] = rn + elif i < 3 + num_keywords: + results.setdefault("keywords", []).append(rn) + elif i < 3 + num_keywords + num_geo: + results.setdefault("geo_targets", []).append(rn) + elif i < 3 + num_keywords + num_geo + num_lang: + results.setdefault("language_targets", []).append(rn) + elif i < 3 + num_keywords + num_geo + num_lang + num_excl: + results.setdefault("geo_excludes", []).append(rn) + elif i < 3 + num_keywords + num_geo + num_lang + num_excl + num_sched: + results.setdefault("ad_schedule", []).append(rn) + + return results + + +def _apply_create_ad_group(client: object, cid: str, changes: dict) -> dict: + """Create ad group + optional keywords in an existing campaign atomically.""" + service = client.get_service("GoogleAdsService") + campaign_service = client.get_service("CampaignService") + ad_group_service = client.get_service("AdGroupService") + + operations: list = [] + + # 1. AdGroup (temp ID: -1, references existing campaign) + ag_op = client.get_type("MutateOperation") + ad_group = ag_op.ad_group_operation.create + ad_group.resource_name = ad_group_service.ad_group_path(cid, "-1") + ad_group.name = changes["ad_group_name"] + ad_group.campaign = campaign_service.campaign_path(cid, changes["campaign_id"]) + ad_group.status = client.enums.AdGroupStatusEnum.ENABLED + ad_group.type_ = client.enums.AdGroupTypeEnum.SEARCH_STANDARD + if changes.get("cpc_bid_micros"): + ad_group.cpc_bid_micros = changes["cpc_bid_micros"] + operations.append(ag_op) + + # 2. Keywords (reference ad_group -1) + kw_list = changes.get("keywords") or [] + for kw in kw_list: + kw_op = client.get_type("MutateOperation") + criterion = kw_op.ad_group_criterion_operation.create + criterion.ad_group = ad_group_service.ad_group_path(cid, "-1") + criterion.keyword.text = kw["text"] + criterion.keyword.match_type = getattr( + client.enums.KeywordMatchTypeEnum, kw["match_type"].upper() + ) + operations.append(kw_op) + + response = service.mutate(customer_id=cid, mutate_operations=operations) + + results: dict = {} + for i, resp in enumerate(response.mutate_operation_responses): + rn = _extract_resource_name(resp) + if rn: + if i == 0: + results["ad_group"] = rn + else: + results.setdefault("keywords", []).append(rn) + + return results + + +def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict: + """Update an existing campaign's settings.""" + from google.protobuf import field_mask_pb2 + + service = client.get_service("GoogleAdsService") + campaign_service = client.get_service("CampaignService") + operations = [] + field_paths = [] + + campaign_id = changes["campaign_id"] + resource_name = campaign_service.campaign_path(cid, campaign_id) + + # Bid strategy and campaign-level setting changes + bs = changes.get("bidding_strategy") + search_partners_enabled = changes.get("search_partners_enabled") + display_network_enabled = changes.get("display_network_enabled") + if ( + bs + or search_partners_enabled is not None + or display_network_enabled is not None + or changes.get("max_cpc") + ): + campaign_op = client.get_type("MutateOperation") + campaign = campaign_op.campaign_operation.update + campaign.resource_name = resource_name + + if bs == "MAXIMIZE_CONVERSIONS": + campaign.maximize_conversions.target_cpa_micros = 0 + if changes.get("target_cpa"): + campaign.maximize_conversions.target_cpa_micros = int( + changes["target_cpa"] * 1_000_000 + ) + field_paths.append("maximize_conversions.target_cpa_micros") + elif bs == "TARGET_CPA": + campaign.maximize_conversions.target_cpa_micros = int( + changes["target_cpa"] * 1_000_000 + ) + field_paths.append("maximize_conversions.target_cpa_micros") + elif bs == "MAXIMIZE_CONVERSION_VALUE": + campaign.maximize_conversion_value.target_roas = 0 + if changes.get("target_roas"): + campaign.maximize_conversion_value.target_roas = changes[ + "target_roas" + ] + field_paths.append("maximize_conversion_value.target_roas") + elif bs == "TARGET_ROAS": + campaign.maximize_conversion_value.target_roas = changes["target_roas"] + field_paths.append("maximize_conversion_value.target_roas") + elif bs == "TARGET_SPEND": + campaign.target_spend.target_spend_micros = 0 + field_paths.append("target_spend.target_spend_micros") + elif bs == "MANUAL_CPC": + campaign.manual_cpc.enhanced_cpc_enabled = False + field_paths.append("manual_cpc.enhanced_cpc_enabled") + + if changes.get("max_cpc"): + campaign.target_spend.cpc_bid_ceiling_micros = int( + changes["max_cpc"] * 1_000_000 + ) + field_paths.append("target_spend.cpc_bid_ceiling_micros") + + if search_partners_enabled is not None: + campaign.network_settings.target_search_network = search_partners_enabled + field_paths.append("network_settings.target_search_network") + if display_network_enabled is not None: + campaign.network_settings.target_content_network = display_network_enabled + field_paths.append("network_settings.target_content_network") + + if field_paths: + campaign_op.campaign_operation.update_mask.CopyFrom( + field_mask_pb2.FieldMask(paths=field_paths) + ) + operations.append(campaign_op) + + # Budget change — requires finding the budget resource name first + new_budget = changes.get("daily_budget") + if new_budget: + budget_query = f""" + SELECT campaign.campaign_budget + FROM campaign + WHERE campaign.id = {campaign_id} + """ + rows = list(service.search(customer_id=cid, query=budget_query)) + if not rows: + raise ValueError(f"Campaign {campaign_id} not found") + budget_rn = rows[0].campaign.campaign_budget + + budget_op = client.get_type("MutateOperation") + budget = budget_op.campaign_budget_operation.update + budget.resource_name = budget_rn + budget.amount_micros = int(new_budget * 1_000_000) + budget_op.campaign_budget_operation.update_mask.CopyFrom( + field_mask_pb2.FieldMask(paths=["amount_micros"]) + ) + operations.append(budget_op) + + # Geo targeting — remove existing, add new + geo_ids = changes.get("geo_target_ids") + if geo_ids is not None: + existing_geo = f""" + SELECT campaign_criterion.resource_name + FROM campaign_criterion + WHERE campaign.id = {campaign_id} + AND campaign_criterion.type = 'LOCATION' + """ + for row in service.search(customer_id=cid, query=existing_geo): + rm_op = client.get_type("MutateOperation") + rm_op.campaign_criterion_operation.remove = ( + row.campaign_criterion.resource_name + ) + operations.append(rm_op) + + for geo_id in geo_ids: + add_op = client.get_type("MutateOperation") + criterion = add_op.campaign_criterion_operation.create + criterion.campaign = resource_name + criterion.location.geo_target_constant = ( + f"geoTargetConstants/{geo_id}" + ) + operations.append(add_op) + + # Language targeting — remove existing, add new + lang_ids = changes.get("language_ids") + if lang_ids is not None: + existing_lang = f""" + SELECT campaign_criterion.resource_name + FROM campaign_criterion + WHERE campaign.id = {campaign_id} + AND campaign_criterion.type = 'LANGUAGE' + """ + for row in service.search(customer_id=cid, query=existing_lang): + rm_op = client.get_type("MutateOperation") + rm_op.campaign_criterion_operation.remove = ( + row.campaign_criterion.resource_name + ) + operations.append(rm_op) + + for lang_id in lang_ids: + add_op = client.get_type("MutateOperation") + criterion = add_op.campaign_criterion_operation.create + criterion.campaign = resource_name + criterion.language.language_constant = ( + f"languageConstants/{lang_id}" + ) + operations.append(add_op) + + # Geo exclusions — remove existing negative-location criteria, add new + excl_ids = changes.get("geo_exclude_ids") + if excl_ids is not None: + existing_excl = f""" + SELECT campaign_criterion.resource_name + FROM campaign_criterion + WHERE campaign.id = {campaign_id} + AND campaign_criterion.type = 'LOCATION' + AND campaign_criterion.negative = TRUE + """ + for row in service.search(customer_id=cid, query=existing_excl): + rm_op = client.get_type("MutateOperation") + rm_op.campaign_criterion_operation.remove = ( + row.campaign_criterion.resource_name + ) + operations.append(rm_op) + + for geo_id in excl_ids: + add_op = client.get_type("MutateOperation") + criterion = add_op.campaign_criterion_operation.create + criterion.campaign = resource_name + criterion.location.geo_target_constant = ( + f"geoTargetConstants/{geo_id}" + ) + criterion.negative = True + operations.append(add_op) + + # Ad schedule — remove existing schedule criteria, add new + schedule = changes.get("ad_schedule") + if schedule is not None: + existing_sched = f""" + SELECT campaign_criterion.resource_name + FROM campaign_criterion + WHERE campaign.id = {campaign_id} + AND campaign_criterion.type = 'AD_SCHEDULE' + """ + for row in service.search(customer_id=cid, query=existing_sched): + rm_op = client.get_type("MutateOperation") + rm_op.campaign_criterion_operation.remove = ( + row.campaign_criterion.resource_name + ) + operations.append(rm_op) + + for entry in schedule: + add_op = client.get_type("MutateOperation") + criterion = add_op.campaign_criterion_operation.create + criterion.campaign = resource_name + _populate_ad_schedule_info(client, criterion.ad_schedule, entry) + operations.append(add_op) + + if not operations: + return {"message": "No changes to apply"} + + response = service.mutate(customer_id=cid, mutate_operations=operations) + + results = {"updated": []} + for resp in response.mutate_operation_responses: + rn = _extract_resource_name(resp) + if rn: + results["updated"].append(rn) + return results + + +def _apply_create_rsa(client: object, cid: str, changes: dict) -> dict: + service = client.get_service("AdGroupAdService") + operation = client.get_type("AdGroupAdOperation") + ad_group_ad = operation.create + + ad_group_ad.ad_group = client.get_service("AdGroupService").ad_group_path( + cid, changes["ad_group_id"] + ) + # Create as PAUSED for safety — user can enable separately + ad_group_ad.status = client.enums.AdGroupAdStatusEnum.PAUSED + + ad = ad_group_ad.ad + ad.final_urls.append(changes["final_url"]) + + for entry in changes["headlines"]: + asset = client.get_type("AdTextAsset") + asset.text = entry["text"] + if entry.get("pinned_field"): + asset.pinned_field = client.enums.ServedAssetFieldTypeEnum[ + entry["pinned_field"] + ] + ad.responsive_search_ad.headlines.append(asset) + + for entry in changes["descriptions"]: + asset = client.get_type("AdTextAsset") + asset.text = entry["text"] + if entry.get("pinned_field"): + asset.pinned_field = client.enums.ServedAssetFieldTypeEnum[ + entry["pinned_field"] + ] + ad.responsive_search_ad.descriptions.append(asset) + + if changes.get("path1"): + ad.responsive_search_ad.path1 = changes["path1"] + if changes.get("path2"): + ad.responsive_search_ad.path2 = changes["path2"] + + response = service.mutate_ad_group_ads( + customer_id=cid, operations=[operation] + ) + return {"resource_name": response.results[0].resource_name} + + +def _apply_update_rsa(client: object, cid: str, changes: dict) -> dict: + """Update mutable fields on an existing RSA in place. + + Builds a sparse AdOperation.update with only the fields the caller asked + to change, attached to a FieldMask so Google Ads ignores everything else. + Verified mutable on RSAs in API v23: ``final_urls``, ``responsive_search_ad.path1``, + ``responsive_search_ad.path2``. + """ + from google.protobuf import field_mask_pb2 + + service = client.get_service("AdService") + operation = client.get_type("AdOperation") + ad = operation.update + ad.resource_name = service.ad_path(cid, changes["ad_id"]) + + field_paths: list[str] = [] + + if "final_url" in changes: + ad.final_urls.append(changes["final_url"]) + field_paths.append("final_urls") + + if "path1" in changes: + ad.responsive_search_ad.path1 = changes["path1"] + field_paths.append("responsive_search_ad.path1") + + if "path2" in changes: + ad.responsive_search_ad.path2 = changes["path2"] + field_paths.append("responsive_search_ad.path2") + + operation.update_mask = field_mask_pb2.FieldMask(paths=field_paths) + response = service.mutate_ads(customer_id=cid, operations=[operation]) + return {"resource_name": response.results[0].resource_name} + + +def _apply_add_keywords(client: object, cid: str, changes: dict) -> dict: + service = client.get_service("AdGroupCriterionService") + ad_group_path = client.get_service("AdGroupService").ad_group_path( + cid, changes["ad_group_id"] + ) + + operations = [] + for kw in changes["keywords"]: + operation = client.get_type("AdGroupCriterionOperation") + criterion = operation.create + criterion.ad_group = ad_group_path + criterion.keyword.text = kw["text"] + criterion.keyword.match_type = getattr( + client.enums.KeywordMatchTypeEnum, kw["match_type"].upper() + ) + operations.append(operation) + + response = service.mutate_ad_group_criteria( + customer_id=cid, operations=operations + ) + return {"resource_names": [r.resource_name for r in response.results]} + + +def _apply_add_negative_keywords(client: object, cid: str, changes: dict) -> dict: + service = client.get_service("CampaignCriterionService") + campaign_path = client.get_service("CampaignService").campaign_path( + cid, changes["campaign_id"] + ) + + operations = [] + for kw_text in changes["keywords"]: + operation = client.get_type("CampaignCriterionOperation") + criterion = operation.create + criterion.campaign = campaign_path + criterion.negative = True + criterion.keyword.text = kw_text + criterion.keyword.match_type = getattr( + client.enums.KeywordMatchTypeEnum, changes["match_type"] + ) + operations.append(operation) + + response = service.mutate_campaign_criteria( + customer_id=cid, operations=operations + ) + return {"resource_names": [r.resource_name for r in response.results]} + + +def _resolve_ad_entity_id(client: object, cid: str, entity_id: str) -> str: + """Ensure ad entity_id is in 'adGroupId~adId' composite format. + + If only a bare ad ID is given, queries the API to find the ad group. + """ + if "~" in entity_id: + return entity_id + + ga_service = client.get_service("GoogleAdsService") + query = ( + f"SELECT ad_group.id, ad_group_ad.ad.id " + f"FROM ad_group_ad " + f"WHERE ad_group_ad.ad.id = {entity_id} " + f"LIMIT 1" + ) + response = ga_service.search(customer_id=cid, query=query) + for row in response: + ag_id = row.ad_group.id + return f"{ag_id}~{entity_id}" + + raise ValueError( + f"Ad ID {entity_id} not found. Pass the composite ID as " + f"'adGroupId~adId' (e.g. '12345678~{entity_id}')." + ) + + +def _apply_remove( + client: object, + cid: str, + entity_type: str, + entity_id: str, +) -> dict: + """Remove an entity via the REMOVE mutate operation (irreversible).""" + if entity_type == "campaign": + service = client.get_service("CampaignService") + operation = client.get_type("CampaignOperation") + operation.remove = service.campaign_path(cid, entity_id) + response = service.mutate_campaigns( + customer_id=cid, operations=[operation] + ) + + elif entity_type == "ad_group": + service = client.get_service("AdGroupService") + operation = client.get_type("AdGroupOperation") + operation.remove = service.ad_group_path(cid, entity_id) + response = service.mutate_ad_groups( + customer_id=cid, operations=[operation] + ) + + elif entity_type == "ad": + resolved_id = _resolve_ad_entity_id(client, cid, entity_id) + service = client.get_service("AdGroupAdService") + operation = client.get_type("AdGroupAdOperation") + operation.remove = f"customers/{cid}/adGroupAds/{resolved_id}" + response = service.mutate_ad_group_ads( + customer_id=cid, operations=[operation] + ) + + elif entity_type == "keyword": + service = client.get_service("AdGroupCriterionService") + operation = client.get_type("AdGroupCriterionOperation") + operation.remove = f"customers/{cid}/adGroupCriteria/{entity_id}" + response = service.mutate_ad_group_criteria( + customer_id=cid, operations=[operation] + ) + + elif entity_type == "negative_keyword": + service = client.get_service("CampaignCriterionService") + operation = client.get_type("CampaignCriterionOperation") + operation.remove = f"customers/{cid}/campaignCriteria/{entity_id}" + response = service.mutate_campaign_criteria( + customer_id=cid, operations=[operation] + ) + + elif entity_type == "shared_criterion": + if "~" not in entity_id: + raise ValueError( + f"shared_criterion entity_id must be " + f"'sharedSetId~criterionId', got '{entity_id}'" + ) + service = client.get_service("SharedCriterionService") + operation = client.get_type("SharedCriterionOperation") + operation.remove = f"customers/{cid}/sharedCriteria/{entity_id}" + response = service.mutate_shared_criteria( + customer_id=cid, operations=[operation] + ) + + elif entity_type == "campaign_asset": + parts = entity_id.split("~") + if len(parts) != 3: + raise ValueError( + f"campaign_asset entity_id must be " + f"'campaignId~assetId~fieldType', got '{entity_id}'" + ) + resource_name = f"customers/{cid}/campaignAssets/{entity_id}" + ga_service = client.get_service("GoogleAdsService") + op = client.get_type("MutateOperation") + op.campaign_asset_operation.remove = resource_name + response = ga_service.mutate( + customer_id=cid, mutate_operations=[op] + ) + resp_inner = response.mutate_operation_responses[0] + if resp_inner.campaign_asset_result.resource_name: + return {"resource_name": resp_inner.campaign_asset_result.resource_name} + return {"resource_name": resource_name, "status": "removed"} + + elif entity_type == "asset": + service = client.get_service("AssetService") + operation = client.get_type("AssetOperation") + operation.remove = service.asset_path(cid, entity_id) + response = service.mutate_assets( + customer_id=cid, operations=[operation] + ) + + elif entity_type == "customer_asset": + parts = entity_id.split("~") + if len(parts) != 2: + raise ValueError( + f"customer_asset entity_id must be " + f"'assetId~fieldType', got '{entity_id}'" + ) + resource_name = f"customers/{cid}/customerAssets/{entity_id}" + ga_service = client.get_service("GoogleAdsService") + op = client.get_type("MutateOperation") + op.customer_asset_operation.remove = resource_name + response = ga_service.mutate( + customer_id=cid, mutate_operations=[op] + ) + resp_inner = response.mutate_operation_responses[0] + if resp_inner.customer_asset_result.resource_name: + return {"resource_name": resp_inner.customer_asset_result.resource_name} + return {"resource_name": resource_name, "status": "removed"} + + else: + raise ValueError(f"Cannot remove entity_type: {entity_type}") + + return {"resource_name": response.results[0].resource_name} + + +def _apply_status_change( + client: object, + cid: str, + entity_type: str, + entity_id: str, + status: str, +) -> dict: + """Update the status of a campaign, ad group, ad, or keyword.""" + if entity_type == "campaign": + service = client.get_service("CampaignService") + operation = client.get_type("CampaignOperation") + entity = operation.update + entity.resource_name = service.campaign_path(cid, entity_id) + entity.status = getattr(client.enums.CampaignStatusEnum, status) + mutate = service.mutate_campaigns + + elif entity_type == "ad_group": + service = client.get_service("AdGroupService") + operation = client.get_type("AdGroupOperation") + entity = operation.update + entity.resource_name = service.ad_group_path(cid, entity_id) + entity.status = getattr(client.enums.AdGroupStatusEnum, status) + mutate = service.mutate_ad_groups + + elif entity_type == "ad": + resolved_id = _resolve_ad_entity_id(client, cid, entity_id) + service = client.get_service("AdGroupAdService") + operation = client.get_type("AdGroupAdOperation") + entity = operation.update + entity.resource_name = f"customers/{cid}/adGroupAds/{resolved_id}" + entity.status = getattr(client.enums.AdGroupAdStatusEnum, status) + mutate = service.mutate_ad_group_ads + + elif entity_type == "keyword": + service = client.get_service("AdGroupCriterionService") + operation = client.get_type("AdGroupCriterionOperation") + entity = operation.update + entity.resource_name = f"customers/{cid}/adGroupCriteria/{entity_id}" + entity.status = getattr( + client.enums.AdGroupCriterionStatusEnum, status + ) + mutate = service.mutate_ad_group_criteria + + else: + raise ValueError(f"Unknown entity_type: {entity_type}") + + # Build field mask for the status field only + from google.protobuf import field_mask_pb2 + + operation.update_mask = field_mask_pb2.FieldMask(paths=["status"]) + + response = mutate(customer_id=cid, operations=[operation]) + return {"resource_name": response.results[0].resource_name} + + +def _apply_campaign_assets( + client: object, + cid: str, + campaign_id: str, + assets: list[dict], + field_type: object, + populate_asset: object, +) -> dict: + """Create assets and link them to a campaign via CampaignAsset (legacy alias).""" + return _apply_assets( + client, + cid, + assets, + field_type, + populate_asset, + scope="campaign", + campaign_id=campaign_id, + ) + + +def _apply_assets( + client: object, + cid: str, + assets: list[dict], + field_type: object, + populate_asset: object, + *, + scope: str = "campaign", + campaign_id: str = "", +) -> dict: + """Create Asset rows + link them at campaign or customer scope. + + scope: + - "campaign" → CampaignAsset (requires campaign_id) + - "customer" → CustomerAsset (account-level, applies to all eligible + campaigns by default) + """ + asset_service = client.get_service("AssetService") + googleads_service = client.get_service("GoogleAdsService") + operations = [] + + for i, payload in enumerate(assets): + op = client.get_type("MutateOperation") + asset = op.asset_operation.create + asset.resource_name = asset_service.asset_path(cid, str(-(i + 1))) + populate_asset(asset, payload) + operations.append(op) - response = service.mutate(customer_id=cid, mutate_operations=operations) + if scope == "campaign": + if not campaign_id: + raise ValueError("campaign_id is required for campaign-scope assets") + for i in range(len(assets)): + op = client.get_type("MutateOperation") + ca = op.campaign_asset_operation.create + ca.asset = asset_service.asset_path(cid, str(-(i + 1))) + ca.campaign = googleads_service.campaign_path(cid, campaign_id) + ca.field_type = field_type + operations.append(op) + elif scope == "customer": + for i in range(len(assets)): + op = client.get_type("MutateOperation") + cust_asset = op.customer_asset_operation.create + cust_asset.asset = asset_service.asset_path(cid, str(-(i + 1))) + cust_asset.field_type = field_type + operations.append(op) + else: + raise ValueError(f"Unknown asset scope: {scope}") - results = {} - num_keywords = len(kw_list) - num_geo = len(changes.get("geo_target_ids") or []) - num_lang = len(changes.get("language_ids") or []) + response = googleads_service.mutate( + customer_id=cid, mutate_operations=operations + ) + + if scope == "campaign": + results = {"assets": [], "campaign_assets": []} + link_key = "campaign_assets" + else: + results = {"assets": [], "customer_assets": []} + link_key = "customer_assets" + + num_assets = len(assets) for i, resp in enumerate(response.mutate_operation_responses): - rn = _extract_resource_name(resp) - if rn: - if i == 0: - results["campaign_budget"] = rn - elif i == 1: - results["campaign"] = rn - elif i == 2: - results["ad_group"] = rn - elif i < 3 + num_keywords: - results.setdefault("keywords", []).append(rn) - elif i < 3 + num_keywords + num_geo: - results.setdefault("geo_targets", []).append(rn) + resource = None + if resp.asset_result.resource_name: + resource = resp.asset_result.resource_name + elif scope == "campaign" and resp.campaign_asset_result.resource_name: + resource = resp.campaign_asset_result.resource_name + elif scope == "customer" and resp.customer_asset_result.resource_name: + resource = resp.customer_asset_result.resource_name + + if resource: + if i < num_assets: + results["assets"].append(resource) else: - results.setdefault("language_targets", []).append(rn) + results[link_key].append(resource) return results -def _apply_create_ad_group(client: object, cid: str, changes: dict) -> dict: - """Create ad group + optional keywords in an existing campaign atomically.""" - service = client.get_service("GoogleAdsService") - campaign_service = client.get_service("CampaignService") - ad_group_service = client.get_service("AdGroupService") +def _apply_create_callouts(client: object, cid: str, changes: dict) -> dict: + """Create callout assets at customer or campaign scope.""" - operations: list = [] + def populate(asset: object, payload: dict) -> None: + asset.callout_asset.callout_text = payload["callout_text"] - # 1. AdGroup (temp ID: -1, references existing campaign) - ag_op = client.get_type("MutateOperation") - ad_group = ag_op.ad_group_operation.create - ad_group.resource_name = ad_group_service.ad_group_path(cid, "-1") - ad_group.name = changes["ad_group_name"] - ad_group.campaign = campaign_service.campaign_path(cid, changes["campaign_id"]) - ad_group.status = client.enums.AdGroupStatusEnum.ENABLED - ad_group.type_ = client.enums.AdGroupTypeEnum.SEARCH_STANDARD - if changes.get("cpc_bid_micros"): - ad_group.cpc_bid_micros = changes["cpc_bid_micros"] - operations.append(ag_op) + assets = [{"callout_text": text} for text in changes["callouts"]] + return _apply_assets( + client, + cid, + assets, + client.enums.AssetFieldTypeEnum.CALLOUT, + populate, + scope=changes.get("scope", "campaign"), + campaign_id=changes.get("campaign_id", ""), + ) - # 2. Keywords (reference ad_group -1) - kw_list = changes.get("keywords") or [] - for kw in kw_list: - kw_op = client.get_type("MutateOperation") - criterion = kw_op.ad_group_criterion_operation.create - criterion.ad_group = ad_group_service.ad_group_path(cid, "-1") - criterion.keyword.text = kw["text"] - criterion.keyword.match_type = getattr( - client.enums.KeywordMatchTypeEnum, kw["match_type"].upper() - ) - operations.append(kw_op) - response = service.mutate(customer_id=cid, mutate_operations=operations) +def _apply_create_structured_snippets( + client: object, cid: str, changes: dict +) -> dict: + """Create structured snippet assets at customer or campaign scope.""" - results: dict = {} - for i, resp in enumerate(response.mutate_operation_responses): - rn = _extract_resource_name(resp) - if rn: - if i == 0: - results["ad_group"] = rn - else: - results.setdefault("keywords", []).append(rn) + def populate(asset: object, payload: dict) -> None: + asset.structured_snippet_asset.header = payload["header"] + asset.structured_snippet_asset.values.extend(payload["values"]) - return results + return _apply_assets( + client, + cid, + changes["snippets"], + client.enums.AssetFieldTypeEnum.STRUCTURED_SNIPPET, + populate, + scope=changes.get("scope", "campaign"), + campaign_id=changes.get("campaign_id", ""), + ) -def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict: - """Update an existing campaign's settings.""" - from google.protobuf import field_mask_pb2 +_VALID_IMAGE_FIELD_TYPES = { + "MARKETING_IMAGE", + "SQUARE_MARKETING_IMAGE", + "PORTRAIT_MARKETING_IMAGE", + "TALL_PORTRAIT_MARKETING_IMAGE", + "LOGO", + "LANDSCAPE_LOGO", + "BUSINESS_LOGO", +} - service = client.get_service("GoogleAdsService") - campaign_service = client.get_service("CampaignService") - operations = [] - field_paths = [] - campaign_id = changes["campaign_id"] - resource_name = campaign_service.campaign_path(cid, campaign_id) +def _detect_image_field_type(payload: dict) -> str: + """Pick the best AssetFieldType (string) for an image based on aspect + ratio and a filename hint. - # Bid strategy and campaign-level setting changes - bs = changes.get("bidding_strategy") - search_partners_enabled = changes.get("search_partners_enabled") - display_network_enabled = changes.get("display_network_enabled") - if ( - bs - or search_partners_enabled is not None - or display_network_enabled is not None - or changes.get("max_cpc") - ): - campaign_op = client.get_type("MutateOperation") - campaign = campaign_op.campaign_operation.update - campaign.resource_name = resource_name + Google rejects ``AD_IMAGE`` for direct campaign/customer asset links — + image extensions need ``MARKETING_IMAGE``, ``SQUARE_MARKETING_IMAGE``, + or one of the LOGO variants. This helper picks the field type the + asset link service will actually accept. - if bs == "MAXIMIZE_CONVERSIONS": - campaign.maximize_conversions.target_cpa_micros = 0 - if changes.get("target_cpa"): - campaign.maximize_conversions.target_cpa_micros = int( - changes["target_cpa"] * 1_000_000 - ) - field_paths.append("maximize_conversions.target_cpa_micros") - elif bs == "TARGET_CPA": - campaign.maximize_conversions.target_cpa_micros = int( - changes["target_cpa"] * 1_000_000 + payload may include an explicit ``"field_type"`` to override detection. + """ + explicit = payload.get("field_type") + if explicit: + upper = str(explicit).upper() + if upper not in _VALID_IMAGE_FIELD_TYPES: + raise ValueError( + f"field_type '{explicit}' is not a supported image asset " + f"field type. Valid: {sorted(_VALID_IMAGE_FIELD_TYPES)}" ) - field_paths.append("maximize_conversions.target_cpa_micros") - elif bs == "MAXIMIZE_CONVERSION_VALUE": - campaign.maximize_conversion_value.target_roas = 0 - if changes.get("target_roas"): - campaign.maximize_conversion_value.target_roas = changes[ - "target_roas" - ] - field_paths.append("maximize_conversion_value.target_roas") - elif bs == "TARGET_ROAS": - campaign.maximize_conversion_value.target_roas = changes["target_roas"] - field_paths.append("maximize_conversion_value.target_roas") - elif bs == "TARGET_SPEND": - campaign.target_spend.target_spend_micros = 0 - field_paths.append("target_spend.target_spend_micros") - elif bs == "MANUAL_CPC": - campaign.manual_cpc.enhanced_cpc_enabled = False - field_paths.append("manual_cpc.enhanced_cpc_enabled") + return upper - if changes.get("max_cpc"): - campaign.target_spend.cpc_bid_ceiling_micros = int( - changes["max_cpc"] * 1_000_000 - ) - field_paths.append("target_spend.cpc_bid_ceiling_micros") + width = int(payload.get("width", 0)) + height = int(payload.get("height", 0)) + name_lower = str(payload.get("name", "")).lower() + path_lower = str(payload.get("path", "")).lower() + is_logo_hint = "logo" in name_lower or "logo" in path_lower - if search_partners_enabled is not None: - campaign.network_settings.target_search_network = search_partners_enabled - field_paths.append("network_settings.target_search_network") - if display_network_enabled is not None: - campaign.network_settings.target_content_network = display_network_enabled - field_paths.append("network_settings.target_content_network") + if width <= 0 or height <= 0: + return "MARKETING_IMAGE" - if field_paths: - campaign_op.campaign_operation.update_mask.CopyFrom( - field_mask_pb2.FieldMask(paths=field_paths) - ) - operations.append(campaign_op) + ratio = width / height - # Budget change — requires finding the budget resource name first - new_budget = changes.get("daily_budget") - if new_budget: - budget_query = f""" - SELECT campaign.campaign_budget - FROM campaign - WHERE campaign.id = {campaign_id} - """ - rows = list(service.search(customer_id=cid, query=budget_query)) - if not rows: - raise ValueError(f"Campaign {campaign_id} not found") - budget_rn = rows[0].campaign.campaign_budget + if 0.95 <= ratio <= 1.05: + return "BUSINESS_LOGO" if is_logo_hint else "SQUARE_MARKETING_IMAGE" + if 3.5 <= ratio <= 4.5 and is_logo_hint: + return "LANDSCAPE_LOGO" + if 1.65 <= ratio <= 2.15: + return "MARKETING_IMAGE" + if 0.7 <= ratio <= 0.85: + return "PORTRAIT_MARKETING_IMAGE" + if 0.4 <= ratio < 0.7: + return "TALL_PORTRAIT_MARKETING_IMAGE" + # Fallback: treat anything wider than tall as marketing image + return "MARKETING_IMAGE" if ratio >= 1.0 else "PORTRAIT_MARKETING_IMAGE" - budget_op = client.get_type("MutateOperation") - budget = budget_op.campaign_budget_operation.update - budget.resource_name = budget_rn - budget.amount_micros = int(new_budget * 1_000_000) - budget_op.campaign_budget_operation.update_mask.CopyFrom( - field_mask_pb2.FieldMask(paths=["amount_micros"]) + +def _apply_create_image_assets(client: object, cid: str, changes: dict) -> dict: + """Create image assets from local files and link them at customer or + campaign scope. + + Field type is auto-detected per image from aspect ratio (with a 'logo' + filename hint), or you can override per-image via ``payload['field_type']``. + """ + asset_service = client.get_service("AssetService") + googleads_service = client.get_service("GoogleAdsService") + images = changes["images"] + scope = changes.get("scope", "campaign") + campaign_id = changes.get("campaign_id", "") + + if scope == "campaign" and not campaign_id: + raise ValueError("campaign_id is required for campaign-scope image assets") + + operations: list = [] + + # Phase 1 — create Asset rows + for i, payload in enumerate(images): + op = client.get_type("MutateOperation") + asset = op.asset_operation.create + asset.resource_name = asset_service.asset_path(cid, str(-(i + 1))) + image_path = Path(str(payload["path"])) + image_bytes = image_path.read_bytes() + mime_type_name = _VALID_IMAGE_MIME_TYPES[str(payload["mime_type"])] + asset.name = str( + payload.get("name") or _build_image_asset_name(image_path, image_bytes) ) - operations.append(budget_op) + asset.type_ = client.enums.AssetTypeEnum.IMAGE + asset.image_asset.data = image_bytes + asset.image_asset.mime_type = getattr( + client.enums.MimeTypeEnum, mime_type_name + ) + asset.image_asset.full_size.width_pixels = int(payload["width"]) + asset.image_asset.full_size.height_pixels = int(payload["height"]) + operations.append(op) - # Geo targeting — remove existing, add new - geo_ids = changes.get("geo_target_ids") - if geo_ids is not None: - existing_geo = f""" - SELECT campaign_criterion.resource_name - FROM campaign_criterion - WHERE campaign.id = {campaign_id} - AND campaign_criterion.type = 'LOCATION' - """ - for row in service.search(customer_id=cid, query=existing_geo): - rm_op = client.get_type("MutateOperation") - rm_op.campaign_criterion_operation.remove = ( - row.campaign_criterion.resource_name - ) - operations.append(rm_op) + # Phase 2 — link each asset with its detected/explicit field type + for i, payload in enumerate(images): + ft_name = _detect_image_field_type(payload) + ft_enum = getattr(client.enums.AssetFieldTypeEnum, ft_name) + op = client.get_type("MutateOperation") + if scope == "campaign": + link = op.campaign_asset_operation.create + link.asset = asset_service.asset_path(cid, str(-(i + 1))) + link.campaign = googleads_service.campaign_path(cid, campaign_id) + link.field_type = ft_enum + elif scope == "customer": + link = op.customer_asset_operation.create + link.asset = asset_service.asset_path(cid, str(-(i + 1))) + link.field_type = ft_enum + else: + raise ValueError(f"Unknown asset scope: {scope}") + operations.append(op) - for geo_id in geo_ids: - add_op = client.get_type("MutateOperation") - criterion = add_op.campaign_criterion_operation.create - criterion.campaign = resource_name - criterion.location.geo_target_constant = ( - f"geoTargetConstants/{geo_id}" - ) - operations.append(add_op) + response = googleads_service.mutate( + customer_id=cid, mutate_operations=operations + ) - # Language targeting — remove existing, add new - lang_ids = changes.get("language_ids") - if lang_ids is not None: - existing_lang = f""" - SELECT campaign_criterion.resource_name - FROM campaign_criterion - WHERE campaign.id = {campaign_id} - AND campaign_criterion.type = 'LANGUAGE' - """ - for row in service.search(customer_id=cid, query=existing_lang): - rm_op = client.get_type("MutateOperation") - rm_op.campaign_criterion_operation.remove = ( - row.campaign_criterion.resource_name - ) - operations.append(rm_op) + results: dict = { + "assets": [], + "campaign_assets": [] if scope == "campaign" else None, + "customer_assets": [] if scope == "customer" else None, + "field_types": [_detect_image_field_type(p) for p in images], + } + # Drop the None side + if scope == "campaign": + results.pop("customer_assets", None) + else: + results.pop("campaign_assets", None) + + num_images = len(images) + link_key = "campaign_assets" if scope == "campaign" else "customer_assets" + for i, resp in enumerate(response.mutate_operation_responses): + resource = None + if resp.asset_result.resource_name: + resource = resp.asset_result.resource_name + elif scope == "campaign" and resp.campaign_asset_result.resource_name: + resource = resp.campaign_asset_result.resource_name + elif scope == "customer" and resp.customer_asset_result.resource_name: + resource = resp.customer_asset_result.resource_name + if resource: + if i < num_images: + results["assets"].append(resource) + else: + results[link_key].append(resource) + return results - for lang_id in lang_ids: - add_op = client.get_type("MutateOperation") - criterion = add_op.campaign_criterion_operation.create - criterion.campaign = resource_name - criterion.language.language_constant = ( - f"languageConstants/{lang_id}" + +def draft_location_asset( + config: AdLoopConfig, + *, + customer_id: str = "", + business_profile_account_id: str = "", + asset_set_name: str = "", + campaign_id: str = "", + label_filters: list[str] | None = None, + listing_id_filters: list[str] | None = None, +) -> dict: + """Draft a Google Business Profile-backed location AssetSet — PREVIEW. + + Creates an ``AssetSet`` of type LOCATION_SYNC that pulls locations from a + linked Google Business Profile and exposes them as location assets. The + set is attached at the customer level (so all eligible campaigns get it + by default). Optionally also creates a ``CampaignAssetSet`` link to a + specific campaign. + + Required preflight: the Google Business Profile must already be linked + in Google Ads → Tools → Linked accounts → Business Profile. + + business_profile_account_id: numeric Business Profile (LBC) account ID, + e.g. "1234567890". Find via GBP admin. + asset_set_name: optional name for the AssetSet. Defaults to + "GBP Locations - ". + label_filters: optional list of GBP location labels to limit sync. + listing_id_filters: optional list of GBP listing IDs to limit sync. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("create_location_asset", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + if not business_profile_account_id: + return { + "error": ( + "business_profile_account_id is required (numeric GBP/LBC account ID). " + "Find it in Google Business Profile admin." ) - operations.append(add_op) + } - if not operations: - return {"message": "No changes to apply"} + name = asset_set_name or f"GBP Locations - {business_profile_account_id}" + warnings = [ + "The Google Business Profile must already be linked at Tools → Linked " + "accounts → Business Profile in Google Ads. If it isn't, this tool " + "will fail at apply time." + ] - response = service.mutate(customer_id=cid, mutate_operations=operations) + plan = ChangePlan( + operation="create_location_asset", + entity_type="asset_set", + entity_id=customer_id, + customer_id=customer_id, + changes={ + "scope": "campaign" if campaign_id else "customer", + "campaign_id": campaign_id, + "business_profile_account_id": str(business_profile_account_id), + "asset_set_name": name, + "label_filters": list(label_filters or []), + "listing_id_filters": [str(x) for x in (listing_id_filters or [])], + }, + ) + store_plan(plan) + preview = plan.to_preview() + preview["warnings"] = warnings + return preview - results = {"updated": []} - for resp in response.mutate_operation_responses: - rn = _extract_resource_name(resp) - if rn: - results["updated"].append(rn) - return results +def _apply_create_location_asset( + client: object, cid: str, changes: dict +) -> dict: + """Create a LOCATION_SYNC AssetSet linked to a Google Business Profile. -def _apply_create_rsa(client: object, cid: str, changes: dict) -> dict: - service = client.get_service("AdGroupAdService") - operation = client.get_type("AdGroupAdOperation") - ad_group_ad = operation.create + Steps: + 1. Create AssetSet (type=LOCATION_SYNC, + location_set.business_profile_location_set.business_account_id=). + 2. Create CustomerAssetSet linking the set to the customer (customer scope). + 3. Or create CampaignAssetSet linking it to one campaign (campaign scope). - ad_group_ad.ad_group = client.get_service("AdGroupService").ad_group_path( - cid, changes["ad_group_id"] + Field model: ``LOCATION_SYNC`` AssetSets carry a ``location_set`` oneof. + For Google Business Profile, the ``business_profile_location_set`` variant + holds the GBP/LBC account ID and optional listing/label filters. + """ + asset_set_service = client.get_service("AssetSetService") + + # Step 1 — create AssetSet + set_op = client.get_type("AssetSetOperation") + asset_set = set_op.create + asset_set.name = changes["asset_set_name"] + asset_set.type_ = client.enums.AssetSetTypeEnum.LOCATION_SYNC + bpls = asset_set.location_set.business_profile_location_set + # business_account_id is exposed as STRING by proto-plus even though the + # value is a numeric GBP/LBC account id. + bpls.business_account_id = str(changes["business_profile_account_id"]) + for label in changes.get("label_filters") or []: + bpls.label_filters.append(label) + for listing_id in changes.get("listing_id_filters") or []: + bpls.listing_id_filters.append(int(listing_id)) + + set_response = asset_set_service.mutate_asset_sets( + customer_id=cid, operations=[set_op] ) - # Create as PAUSED for safety — user can enable separately - ad_group_ad.status = client.enums.AdGroupAdStatusEnum.PAUSED + asset_set_resource = set_response.results[0].resource_name + + result = {"asset_set": asset_set_resource} + + # Step 2/3 — link to customer or campaign + scope = changes.get("scope", "customer") + if scope == "customer": + cas_service = client.get_service("CustomerAssetSetService") + cas_op = client.get_type("CustomerAssetSetOperation") + cas_op.create.asset_set = asset_set_resource + cas_response = cas_service.mutate_customer_asset_sets( + customer_id=cid, operations=[cas_op] + ) + result["customer_asset_set"] = cas_response.results[0].resource_name + elif scope == "campaign": + if not changes.get("campaign_id"): + raise ValueError("campaign_id required for campaign-scope location asset") + campaign_service = client.get_service("CampaignService") + cas_service = client.get_service("CampaignAssetSetService") + cas_op = client.get_type("CampaignAssetSetOperation") + cas_op.create.asset_set = asset_set_resource + cas_op.create.campaign = campaign_service.campaign_path( + cid, changes["campaign_id"] + ) + cas_response = cas_service.mutate_campaign_asset_sets( + customer_id=cid, operations=[cas_op] + ) + result["campaign_asset_set"] = cas_response.results[0].resource_name + else: + raise ValueError(f"Unknown scope: {scope}") - ad = ad_group_ad.ad - ad.final_urls.append(changes["final_url"]) + return result - for entry in changes["headlines"]: - asset = client.get_type("AdTextAsset") - asset.text = entry["text"] - if entry.get("pinned_field"): - asset.pinned_field = client.enums.ServedAssetFieldTypeEnum[ - entry["pinned_field"] - ] - ad.responsive_search_ad.headlines.append(asset) - for entry in changes["descriptions"]: - asset = client.get_type("AdTextAsset") - asset.text = entry["text"] - if entry.get("pinned_field"): - asset.pinned_field = client.enums.ServedAssetFieldTypeEnum[ - entry["pinned_field"] - ] - ad.responsive_search_ad.descriptions.append(asset) +def draft_business_name_asset( + config: AdLoopConfig, + *, + customer_id: str = "", + campaign_id: str = "", + business_name: str = "", +) -> dict: + """Draft a business-name asset — returns a PREVIEW. - if changes.get("path1"): - ad.responsive_search_ad.path1 = changes["path1"] - if changes.get("path2"): - ad.responsive_search_ad.path2 = changes["path2"] + Creates a TEXT asset and links it as ``BUSINESS_NAME`` at customer or + campaign scope. Google shows the business name alongside ads (and on + image-rich placements like the maps card / local pack) so users can + recognize the brand at a glance. - response = service.mutate_ad_group_ads( - customer_id=cid, operations=[operation] - ) - return {"resource_name": response.results[0].resource_name} + Scope: + - If ``campaign_id`` is empty (default), the asset is linked at the + customer/account level via CustomerAsset. + - If ``campaign_id`` is provided, the asset is scoped to that + single campaign via CampaignAsset. + business_name: max 25 characters per Google Ads policy. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan -def _apply_add_keywords(client: object, cid: str, changes: dict) -> dict: - service = client.get_service("AdGroupCriterionService") - ad_group_path = client.get_service("AdGroupService").ad_group_path( - cid, changes["ad_group_id"] + try: + check_blocked_operation("create_business_name_asset", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + text = (business_name or "").strip() + if not text: + return {"error": "business_name is required"} + if len(text) > 25: + return { + "error": "Validation failed", + "details": [ + f"business_name '{text}' is {len(text)} chars (max 25)" + ], + } + + scope = "campaign" if campaign_id else "customer" + plan = ChangePlan( + operation="create_business_name_asset", + entity_type="campaign_asset" if scope == "campaign" else "customer_asset", + entity_id=campaign_id or customer_id, + customer_id=customer_id, + changes={ + "scope": scope, + "campaign_id": campaign_id, + "business_name": text, + }, ) + store_plan(plan) + return plan.to_preview() - operations = [] - for kw in changes["keywords"]: - operation = client.get_type("AdGroupCriterionOperation") - criterion = operation.create - criterion.ad_group = ad_group_path - criterion.keyword.text = kw["text"] - criterion.keyword.match_type = getattr( - client.enums.KeywordMatchTypeEnum, kw["match_type"].upper() - ) - operations.append(operation) - response = service.mutate_ad_group_criteria( - customer_id=cid, operations=operations +def _apply_create_business_name_asset( + client: object, cid: str, changes: dict +) -> dict: + """Create a TEXT asset and link as BUSINESS_NAME at customer or campaign scope.""" + asset_service = client.get_service("AssetService") + googleads_service = client.get_service("GoogleAdsService") + operations: list = [] + + # 1) Create Asset (TEXT) + op = client.get_type("MutateOperation") + asset = op.asset_operation.create + asset.resource_name = asset_service.asset_path(cid, "-1") + asset.type_ = client.enums.AssetTypeEnum.TEXT + asset.text_asset.text = changes["business_name"] + operations.append(op) + + # 2) Link as BUSINESS_NAME + scope = changes.get("scope", "customer") + link_op = client.get_type("MutateOperation") + if scope == "campaign": + if not changes.get("campaign_id"): + raise ValueError("campaign_id required for campaign-scope business_name asset") + link = link_op.campaign_asset_operation.create + link.asset = asset_service.asset_path(cid, "-1") + link.campaign = googleads_service.campaign_path(cid, changes["campaign_id"]) + link.field_type = client.enums.AssetFieldTypeEnum.BUSINESS_NAME + elif scope == "customer": + link = link_op.customer_asset_operation.create + link.asset = asset_service.asset_path(cid, "-1") + link.field_type = client.enums.AssetFieldTypeEnum.BUSINESS_NAME + else: + raise ValueError(f"Unknown scope: {scope}") + operations.append(link_op) + + response = googleads_service.mutate( + customer_id=cid, mutate_operations=operations ) - return {"resource_names": [r.resource_name for r in response.results]} + result = {"asset": "", "link": ""} + for resp in response.mutate_operation_responses: + if resp.asset_result.resource_name and not result["asset"]: + result["asset"] = resp.asset_result.resource_name + elif scope == "campaign" and resp.campaign_asset_result.resource_name: + result["link"] = resp.campaign_asset_result.resource_name + elif scope == "customer" and resp.customer_asset_result.resource_name: + result["link"] = resp.customer_asset_result.resource_name + return result -def _apply_add_negative_keywords(client: object, cid: str, changes: dict) -> dict: - service = client.get_service("CampaignCriterionService") - campaign_path = client.get_service("CampaignService").campaign_path( - cid, changes["campaign_id"] + +def add_geo_exclusions( + config: AdLoopConfig, + *, + customer_id: str = "", + campaign_id: str = "", + geo_target_ids: list[str] | None = None, +) -> dict: + """Draft negative geo CampaignCriterion records — returns a PREVIEW. + + Adds excluded locations so the campaign does not serve to users in those + geos, even if they would otherwise match an included geo. + + geo_target_ids: list of geoTargetConstant IDs (e.g. ["1014962"] for + Los Angeles). Look up IDs via geo_target_constant in run_gaql. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("add_geo_exclusions", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + if not campaign_id: + return {"error": "campaign_id is required"} + cleaned = [str(g).strip() for g in (geo_target_ids or []) if str(g).strip()] + if not cleaned: + return {"error": "At least one geo_target_id is required"} + + plan = ChangePlan( + operation="add_geo_exclusions", + entity_type="campaign_criterion", + entity_id=campaign_id, + customer_id=customer_id, + changes={ + "campaign_id": campaign_id, + "geo_target_ids": cleaned, + }, ) + store_plan(plan) + return plan.to_preview() - operations = [] - for kw_text in changes["keywords"]: - operation = client.get_type("CampaignCriterionOperation") - criterion = operation.create - criterion.campaign = campaign_path - criterion.negative = True - criterion.keyword.text = kw_text - criterion.keyword.match_type = getattr( - client.enums.KeywordMatchTypeEnum, changes["match_type"] - ) - operations.append(operation) - response = service.mutate_campaign_criteria( +def _apply_add_geo_exclusions(client: object, cid: str, changes: dict) -> dict: + """Add negative location CampaignCriterion records to a campaign.""" + campaign_service = client.get_service("CampaignService") + crit_service = client.get_service("CampaignCriterionService") + operations = [] + for geo_id in changes["geo_target_ids"]: + op = client.get_type("CampaignCriterionOperation") + crit = op.create + crit.campaign = campaign_service.campaign_path(cid, changes["campaign_id"]) + crit.location.geo_target_constant = f"geoTargetConstants/{geo_id}" + crit.negative = True + operations.append(op) + response = crit_service.mutate_campaign_criteria( customer_id=cid, operations=operations ) - return {"resource_names": [r.resource_name for r in response.results]} + return { + "campaign_criteria": [r.resource_name for r in response.results], + } -def _resolve_ad_entity_id(client: object, cid: str, entity_id: str) -> str: - """Ensure ad entity_id is in 'adGroupId~adId' composite format. +def add_ad_schedule( + config: AdLoopConfig, + *, + customer_id: str = "", + campaign_id: str = "", + schedule: list[dict] | None = None, +) -> dict: + """Draft ad schedule additions for a campaign — returns a PREVIEW. - If only a bare ad ID is given, queries the API to find the ad group. + Creates ``CampaignCriterion`` records of type AD_SCHEDULE so the + campaign only serves during the specified hours/days. + + schedule: list of dicts with keys: + - day_of_week: MONDAY..SUNDAY + - start_hour: 0..23 + - end_hour: 0..24 (must be > start) + - start_minute / end_minute: 0, 15, 30, or 45 (default 0) + + Note: ad-schedule hours follow the account's configured time zone. + Adding a schedule is additive — it does NOT replace existing schedule + criteria. Pause/remove existing schedule entries first if you want a + clean slate. """ - if "~" in entity_id: - return entity_id + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan - ga_service = client.get_service("GoogleAdsService") - query = ( - f"SELECT ad_group.id, ad_group_ad.ad.id " - f"FROM ad_group_ad " - f"WHERE ad_group_ad.ad.id = {entity_id} " - f"LIMIT 1" - ) - response = ga_service.search(customer_id=cid, query=query) - for row in response: - ag_id = row.ad_group.id - return f"{ag_id}~{entity_id}" + try: + check_blocked_operation("add_ad_schedule", config.safety) + except SafetyViolation as e: + return {"error": str(e)} - raise ValueError( - f"Ad ID {entity_id} not found. Pass the composite ID as " - f"'adGroupId~adId' (e.g. '12345678~{entity_id}')." + if not campaign_id: + return {"error": "campaign_id is required"} + validated, errors = _validate_ad_schedule(schedule or []) + if errors: + return {"error": "Validation failed", "details": errors} + if not validated: + return {"error": "At least one schedule entry is required"} + + plan = ChangePlan( + operation="add_ad_schedule", + entity_type="campaign_criterion", + entity_id=campaign_id, + customer_id=customer_id, + changes={ + "campaign_id": campaign_id, + "schedule": validated, + }, ) + store_plan(plan) + return plan.to_preview() -def _apply_remove( - client: object, - cid: str, - entity_type: str, - entity_id: str, -) -> dict: - """Remove an entity via the REMOVE mutate operation (irreversible).""" - if entity_type == "campaign": - service = client.get_service("CampaignService") - operation = client.get_type("CampaignOperation") - operation.remove = service.campaign_path(cid, entity_id) - response = service.mutate_campaigns( - customer_id=cid, operations=[operation] +def _apply_add_ad_schedule(client: object, cid: str, changes: dict) -> dict: + """Add AdScheduleInfo CampaignCriterion records to a campaign.""" + campaign_service = client.get_service("CampaignService") + crit_service = client.get_service("CampaignCriterionService") + operations = [] + for entry in changes["schedule"]: + op = client.get_type("CampaignCriterionOperation") + crit = op.create + crit.campaign = campaign_service.campaign_path( + cid, changes["campaign_id"] ) + _populate_ad_schedule_info(client, crit.ad_schedule, entry) + operations.append(op) + response = crit_service.mutate_campaign_criteria( + customer_id=cid, operations=operations + ) + return { + "campaign_criteria": [r.resource_name for r in response.results], + } + + +_AD_SCHEDULE_DAY_ENUM = { + "MONDAY": "MONDAY", + "TUESDAY": "TUESDAY", + "WEDNESDAY": "WEDNESDAY", + "THURSDAY": "THURSDAY", + "FRIDAY": "FRIDAY", + "SATURDAY": "SATURDAY", + "SUNDAY": "SUNDAY", +} +_MINUTE_TO_ENUM = {0: "ZERO", 15: "FIFTEEN", 30: "THIRTY", 45: "FORTY_FIVE"} + + +def _populate_ad_schedule_info(client: object, info: object, entry: dict) -> None: + """Set fields on an AdScheduleInfo proto from a validated entry.""" + info.day_of_week = getattr( + client.enums.DayOfWeekEnum, _AD_SCHEDULE_DAY_ENUM[entry["day_of_week"]] + ) + info.start_hour = int(entry["start_hour"]) + info.end_hour = int(entry["end_hour"]) + info.start_minute = getattr( + client.enums.MinuteOfHourEnum, _MINUTE_TO_ENUM[int(entry["start_minute"])] + ) + info.end_minute = getattr( + client.enums.MinuteOfHourEnum, _MINUTE_TO_ENUM[int(entry["end_minute"])] + ) - elif entity_type == "ad_group": - service = client.get_service("AdGroupService") - operation = client.get_type("AdGroupOperation") - operation.remove = service.ad_group_path(cid, entity_id) - response = service.mutate_ad_groups( - customer_id=cid, operations=[operation] - ) - elif entity_type == "ad": - resolved_id = _resolve_ad_entity_id(client, cid, entity_id) - service = client.get_service("AdGroupAdService") - operation = client.get_type("AdGroupAdOperation") - operation.remove = f"customers/{cid}/adGroupAds/{resolved_id}" - response = service.mutate_ad_group_ads( - customer_id=cid, operations=[operation] - ) +def _apply_create_call_asset(client: object, cid: str, changes: dict) -> dict: + """Create a CallAsset at customer or campaign scope.""" + asset_service = client.get_service("AssetService") + googleads_service = client.get_service("GoogleAdsService") + operations = [] - elif entity_type == "keyword": - service = client.get_service("AdGroupCriterionService") - operation = client.get_type("AdGroupCriterionOperation") - operation.remove = f"customers/{cid}/adGroupCriteria/{entity_id}" - response = service.mutate_ad_group_criteria( - customer_id=cid, operations=[operation] + op = client.get_type("MutateOperation") + asset = op.asset_operation.create + asset.resource_name = asset_service.asset_path(cid, "-1") + asset.call_asset.country_code = changes["country_code"] + asset.call_asset.phone_number = changes["phone_number"] + if changes.get("call_conversion_action_id"): + ca_service = client.get_service("ConversionActionService") + asset.call_asset.call_conversion_action = ca_service.conversion_action_path( + cid, str(changes["call_conversion_action_id"]) ) - - elif entity_type == "negative_keyword": - service = client.get_service("CampaignCriterionService") - operation = client.get_type("CampaignCriterionOperation") - operation.remove = f"customers/{cid}/campaignCriteria/{entity_id}" - response = service.mutate_campaign_criteria( - customer_id=cid, operations=[operation] + asset.call_asset.call_conversion_reporting_state = ( + client.enums.CallConversionReportingStateEnum.USE_RESOURCE_LEVEL_CALL_CONVERSION_ACTION ) + for entry in changes.get("ad_schedule") or []: + info = client.get_type("AdScheduleInfo") + _populate_ad_schedule_info(client, info, entry) + asset.call_asset.ad_schedule_targets.append(info) + operations.append(op) + + scope = changes.get("scope", "campaign") + if scope == "campaign": + if not changes.get("campaign_id"): + raise ValueError("campaign_id required for campaign-scope call asset") + link_op = client.get_type("MutateOperation") + ca = link_op.campaign_asset_operation.create + ca.asset = asset_service.asset_path(cid, "-1") + ca.campaign = googleads_service.campaign_path(cid, changes["campaign_id"]) + ca.field_type = client.enums.AssetFieldTypeEnum.CALL + operations.append(link_op) + elif scope == "customer": + link_op = client.get_type("MutateOperation") + cust_asset = link_op.customer_asset_operation.create + cust_asset.asset = asset_service.asset_path(cid, "-1") + cust_asset.field_type = client.enums.AssetFieldTypeEnum.CALL + operations.append(link_op) + else: + raise ValueError(f"Unknown scope: {scope}") - elif entity_type == "shared_criterion": - if "~" not in entity_id: - raise ValueError( - f"shared_criterion entity_id must be " - f"'sharedSetId~criterionId', got '{entity_id}'" - ) - service = client.get_service("SharedCriterionService") - operation = client.get_type("SharedCriterionOperation") - operation.remove = f"customers/{cid}/sharedCriteria/{entity_id}" - response = service.mutate_shared_criteria( - customer_id=cid, operations=[operation] - ) + response = googleads_service.mutate( + customer_id=cid, mutate_operations=operations + ) - elif entity_type == "campaign_asset": - parts = entity_id.split("~") - if len(parts) != 3: - raise ValueError( - f"campaign_asset entity_id must be " - f"'campaignId~assetId~fieldType', got '{entity_id}'" - ) - resource_name = f"customers/{cid}/campaignAssets/{entity_id}" - ga_service = client.get_service("GoogleAdsService") - op = client.get_type("MutateOperation") - op.campaign_asset_operation.remove = resource_name - response = ga_service.mutate( - customer_id=cid, mutate_operations=[op] - ) - resp_inner = response.mutate_operation_responses[0] - if resp_inner.campaign_asset_result.resource_name: - return {"resource_name": resp_inner.campaign_asset_result.resource_name} - return {"resource_name": resource_name, "status": "removed"} + result = {"asset": "", "link": ""} + for resp in response.mutate_operation_responses: + if resp.asset_result.resource_name and not result["asset"]: + result["asset"] = resp.asset_result.resource_name + elif scope == "campaign" and resp.campaign_asset_result.resource_name: + result["link"] = resp.campaign_asset_result.resource_name + elif scope == "customer" and resp.customer_asset_result.resource_name: + result["link"] = resp.customer_asset_result.resource_name + return result - elif entity_type == "asset": - service = client.get_service("AssetService") - operation = client.get_type("AssetOperation") - operation.remove = service.asset_path(cid, entity_id) - response = service.mutate_assets( - customer_id=cid, operations=[operation] - ) - elif entity_type == "customer_asset": - parts = entity_id.split("~") - if len(parts) != 2: - raise ValueError( - f"customer_asset entity_id must be " - f"'assetId~fieldType', got '{entity_id}'" - ) - resource_name = f"customers/{cid}/customerAssets/{entity_id}" - ga_service = client.get_service("GoogleAdsService") - op = client.get_type("MutateOperation") - op.customer_asset_operation.remove = resource_name - response = ga_service.mutate( - customer_id=cid, mutate_operations=[op] - ) - resp_inner = response.mutate_operation_responses[0] - if resp_inner.customer_asset_result.resource_name: - return {"resource_name": resp_inner.customer_asset_result.resource_name} - return {"resource_name": resource_name, "status": "removed"} +def _apply_create_conversion_action_route(client, cid, changes): + from adloop.ads.conversion_actions import _apply_create_conversion_action + return _apply_create_conversion_action(client, cid, changes) - else: - raise ValueError(f"Cannot remove entity_type: {entity_type}") - return {"resource_name": response.results[0].resource_name} +def _apply_update_conversion_action_route(client, cid, changes): + from adloop.ads.conversion_actions import _apply_update_conversion_action + return _apply_update_conversion_action(client, cid, changes) -def _apply_status_change( - client: object, - cid: str, - entity_type: str, - entity_id: str, - status: str, -) -> dict: - """Update the status of a campaign, ad group, ad, or keyword.""" - if entity_type == "campaign": - service = client.get_service("CampaignService") - operation = client.get_type("CampaignOperation") - entity = operation.update - entity.resource_name = service.campaign_path(cid, entity_id) - entity.status = getattr(client.enums.CampaignStatusEnum, status) - mutate = service.mutate_campaigns +def _apply_remove_conversion_action_route(client, cid, changes): + from adloop.ads.conversion_actions import _apply_remove_conversion_action + return _apply_remove_conversion_action(client, cid, changes) - elif entity_type == "ad_group": - service = client.get_service("AdGroupService") - operation = client.get_type("AdGroupOperation") - entity = operation.update - entity.resource_name = service.ad_group_path(cid, entity_id) - entity.status = getattr(client.enums.AdGroupStatusEnum, status) - mutate = service.mutate_ad_groups - elif entity_type == "ad": - resolved_id = _resolve_ad_entity_id(client, cid, entity_id) - service = client.get_service("AdGroupAdService") - operation = client.get_type("AdGroupAdOperation") - entity = operation.update - entity.resource_name = f"customers/{cid}/adGroupAds/{resolved_id}" - entity.status = getattr(client.enums.AdGroupAdStatusEnum, status) - mutate = service.mutate_ad_group_ads +def _apply_update_call_asset(client: object, cid: str, changes: dict) -> dict: + """In-place update of an existing CallAsset.""" + from google.protobuf import field_mask_pb2 - elif entity_type == "keyword": - service = client.get_service("AdGroupCriterionService") - operation = client.get_type("AdGroupCriterionOperation") - entity = operation.update - entity.resource_name = f"customers/{cid}/adGroupCriteria/{entity_id}" - entity.status = getattr( - client.enums.AdGroupCriterionStatusEnum, status + asset_service = client.get_service("AssetService") + op = client.get_type("AssetOperation") + asset = op.update + asset.resource_name = asset_service.asset_path(cid, changes["asset_id"]) + + paths: list[str] = [] + if "phone_number" in changes: + asset.call_asset.phone_number = changes["phone_number"] + paths.append("call_asset.phone_number") + if "country_code" in changes: + asset.call_asset.country_code = changes["country_code"] + paths.append("call_asset.country_code") + if "call_conversion_action_id" in changes: + ca_service = client.get_service("ConversionActionService") + asset.call_asset.call_conversion_action = ca_service.conversion_action_path( + cid, changes["call_conversion_action_id"] ) - mutate = service.mutate_ad_group_criteria + paths.append("call_asset.call_conversion_action") + if "call_conversion_reporting_state" in changes: + asset.call_asset.call_conversion_reporting_state = getattr( + client.enums.CallConversionReportingStateEnum, + changes["call_conversion_reporting_state"], + ) + paths.append("call_asset.call_conversion_reporting_state") + if "ad_schedule" in changes: + # Replace the schedule list entirely + for entry in changes["ad_schedule"]: + info = client.get_type("AdScheduleInfo") + _populate_ad_schedule_info(client, info, entry) + asset.call_asset.ad_schedule_targets.append(info) + paths.append("call_asset.ad_schedule_targets") + + op.update_mask.CopyFrom(field_mask_pb2.FieldMask(paths=paths)) + response = asset_service.mutate_assets(customer_id=cid, operations=[op]) + return {"resource_name": response.results[0].resource_name} - else: - raise ValueError(f"Unknown entity_type: {entity_type}") - # Build field mask for the status field only +def _apply_update_sitelink(client: object, cid: str, changes: dict) -> dict: + """In-place update of an existing SitelinkAsset.""" from google.protobuf import field_mask_pb2 - operation.update_mask = field_mask_pb2.FieldMask(paths=["status"]) - - response = mutate(customer_id=cid, operations=[operation]) + asset_service = client.get_service("AssetService") + op = client.get_type("AssetOperation") + asset = op.update + asset.resource_name = asset_service.asset_path(cid, changes["asset_id"]) + + paths: list[str] = [] + if "link_text" in changes: + asset.sitelink_asset.link_text = changes["link_text"] + paths.append("sitelink_asset.link_text") + if "description1" in changes: + asset.sitelink_asset.description1 = changes["description1"] + paths.append("sitelink_asset.description1") + if "description2" in changes: + asset.sitelink_asset.description2 = changes["description2"] + paths.append("sitelink_asset.description2") + if "final_url" in changes: + asset.final_urls.append(changes["final_url"]) + paths.append("final_urls") + + op.update_mask.CopyFrom(field_mask_pb2.FieldMask(paths=paths)) + response = asset_service.mutate_assets(customer_id=cid, operations=[op]) return {"resource_name": response.results[0].resource_name} -def _apply_campaign_assets( - client: object, - cid: str, - campaign_id: str, - assets: list[dict], - field_type: object, - populate_asset: object, -) -> dict: - """Create assets and link them to a campaign via CampaignAsset.""" +def _apply_update_callout(client: object, cid: str, changes: dict) -> dict: + """In-place update of an existing CalloutAsset's text.""" + from google.protobuf import field_mask_pb2 + asset_service = client.get_service("AssetService") - googleads_service = client.get_service("GoogleAdsService") - operations = [] + op = client.get_type("AssetOperation") + asset = op.update + asset.resource_name = asset_service.asset_path(cid, changes["asset_id"]) + asset.callout_asset.callout_text = changes["callout_text"] + op.update_mask.CopyFrom(field_mask_pb2.FieldMask( + paths=["callout_asset.callout_text"] + )) + response = asset_service.mutate_assets(customer_id=cid, operations=[op]) + return {"resource_name": response.results[0].resource_name} - for i, payload in enumerate(assets): - op = client.get_type("MutateOperation") - asset = op.asset_operation.create - asset.resource_name = asset_service.asset_path(cid, str(-(i + 1))) - populate_asset(asset, payload) - operations.append(op) - for i in range(len(assets)): - op = client.get_type("MutateOperation") - ca = op.campaign_asset_operation.create - ca.asset = asset_service.asset_path(cid, str(-(i + 1))) - ca.campaign = googleads_service.campaign_path(cid, campaign_id) - ca.field_type = field_type - operations.append(op) +def _populate_promotion_asset(client: object, asset: object, promo: dict) -> None: + """Fill an Asset proto with PromotionAsset fields from a normalized dict.""" + p = asset.promotion_asset + p.promotion_target = promo["promotion_target"] + if promo.get("money_off"): + p.money_amount_off.amount_micros = int( + float(promo["money_off"]) * 1_000_000 + ) + p.money_amount_off.currency_code = promo["currency_code"] + elif promo.get("percent_off"): + p.percent_off = int(float(promo["percent_off"]) * 1_000_000) - response = googleads_service.mutate( - customer_id=cid, mutate_operations=operations - ) + if promo.get("promotion_code"): + p.promotion_code = promo["promotion_code"] - results = {"assets": [], "campaign_assets": []} - num_assets = len(assets) - for i, resp in enumerate(response.mutate_operation_responses): - resource = None - if resp.asset_result.resource_name: - resource = resp.asset_result.resource_name - elif resp.campaign_asset_result.resource_name: - resource = resp.campaign_asset_result.resource_name + if promo.get("orders_over_amount"): + p.orders_over_amount.amount_micros = int( + float(promo["orders_over_amount"]) * 1_000_000 + ) + p.orders_over_amount.currency_code = promo["currency_code"] - if resource: - if i < num_assets: - results["assets"].append(resource) - else: - results["campaign_assets"].append(resource) + if promo.get("occasion"): + p.occasion = getattr( + client.enums.PromotionExtensionOccasionEnum, promo["occasion"] + ) - return results + if promo.get("discount_modifier"): + p.discount_modifier = getattr( + client.enums.PromotionExtensionDiscountModifierEnum, + promo["discount_modifier"], + ) + p.language_code = promo.get("language_code") or "en" + if promo.get("start_date"): + p.start_date = promo["start_date"] + if promo.get("end_date"): + p.end_date = promo["end_date"] + if promo.get("redemption_start_date"): + p.redemption_start_date = promo["redemption_start_date"] + if promo.get("redemption_end_date"): + p.redemption_end_date = promo["redemption_end_date"] -def _apply_create_callouts(client: object, cid: str, changes: dict) -> dict: - """Create callout assets and link them to a campaign.""" + for entry in promo.get("ad_schedule") or []: + info = client.get_type("AdScheduleInfo") + _populate_ad_schedule_info(client, info, entry) + p.ad_schedule_targets.append(info) + + asset.final_urls.append(promo["final_url"]) + + +def _apply_create_promotion(client: object, cid: str, changes: dict) -> dict: + """Create a PromotionAsset and link it at customer or campaign scope.""" def populate(asset: object, payload: dict) -> None: - asset.callout_asset.callout_text = payload["callout_text"] + _populate_promotion_asset(client, asset, payload) - assets = [{"callout_text": text} for text in changes["callouts"]] - return _apply_campaign_assets( + return _apply_assets( client, cid, - changes["campaign_id"], - assets, - client.enums.AssetFieldTypeEnum.CALLOUT, + [changes["promotion"]], + client.enums.AssetFieldTypeEnum.PROMOTION, populate, + scope=changes.get("scope", "campaign"), + campaign_id=changes.get("campaign_id", ""), ) -def _apply_create_structured_snippets( - client: object, cid: str, changes: dict -) -> dict: - """Create structured snippet assets and link them to a campaign.""" +def _apply_update_promotion(client: object, cid: str, changes: dict) -> dict: + """Swap a PromotionAsset: create new + link, then unlink old. - def populate(asset: object, payload: dict) -> None: - asset.structured_snippet_asset.header = payload["header"] - asset.structured_snippet_asset.values.extend(payload["values"]) + Steps (each is its own MutateOperation, batched into one mutate call): + 1. Create a new Asset with the new promotion fields. + 2. Link the new Asset (CampaignAsset or CustomerAsset). + 3. Remove the old link (CampaignAsset/CustomerAsset matching old asset_id). + 4. Optionally remove the old Asset row. + """ + asset_service = client.get_service("AssetService") + googleads_service = client.get_service("GoogleAdsService") + campaign_service = client.get_service("CampaignService") - return _apply_campaign_assets( - client, - cid, - changes["campaign_id"], - changes["snippets"], - client.enums.AssetFieldTypeEnum.STRUCTURED_SNIPPET, - populate, + scope = changes.get("scope", "campaign") + campaign_id = changes.get("campaign_id", "") + old_asset_id = str(changes["old_asset_id"]) + promo = changes["promotion"] + + operations = [] + + # 1. Create new Asset + create_op = client.get_type("MutateOperation") + new_asset = create_op.asset_operation.create + new_asset.resource_name = asset_service.asset_path(cid, "-1") + _populate_promotion_asset(client, new_asset, promo) + operations.append(create_op) + + # 2. Link the new asset + if scope == "campaign": + if not campaign_id: + raise ValueError("campaign_id required for campaign-scope update") + link_op = client.get_type("MutateOperation") + ca = link_op.campaign_asset_operation.create + ca.asset = asset_service.asset_path(cid, "-1") + ca.campaign = campaign_service.campaign_path(cid, campaign_id) + ca.field_type = client.enums.AssetFieldTypeEnum.PROMOTION + operations.append(link_op) + elif scope == "customer": + link_op = client.get_type("MutateOperation") + cust = link_op.customer_asset_operation.create + cust.asset = asset_service.asset_path(cid, "-1") + cust.field_type = client.enums.AssetFieldTypeEnum.PROMOTION + operations.append(link_op) + else: + raise ValueError(f"Unknown scope: {scope}") + + response = googleads_service.mutate( + customer_id=cid, mutate_operations=operations ) + new_asset_resource = "" + new_link_resource = "" + for resp in response.mutate_operation_responses: + if resp.asset_result.resource_name and not new_asset_resource: + new_asset_resource = resp.asset_result.resource_name + elif scope == "campaign" and resp.campaign_asset_result.resource_name: + new_link_resource = resp.campaign_asset_result.resource_name + elif scope == "customer" and resp.customer_asset_result.resource_name: + new_link_resource = resp.customer_asset_result.resource_name + + # 3. Find the old link and remove it + old_link_resource = _find_promotion_link( + client, cid, old_asset_id, scope, campaign_id + ) + old_link_removed = "" + if old_link_resource: + if scope == "campaign": + ca_service = client.get_service("CampaignAssetService") + rm_op = client.get_type("CampaignAssetOperation") + rm_op.remove = old_link_resource + ca_service.mutate_campaign_assets(customer_id=cid, operations=[rm_op]) + else: + cust_service = client.get_service("CustomerAssetService") + rm_op = client.get_type("CustomerAssetOperation") + rm_op.remove = old_link_resource + cust_service.mutate_customer_assets(customer_id=cid, operations=[rm_op]) + old_link_removed = old_link_resource -def _apply_create_image_assets(client: object, cid: str, changes: dict) -> dict: - """Create image assets from local files and link them to a campaign.""" + return { + "new_asset": new_asset_resource, + "new_link": new_link_resource, + "old_link_removed": old_link_removed, + } - def populate(asset: object, payload: dict) -> None: - image_path = Path(str(payload["path"])) - image_bytes = image_path.read_bytes() - mime_type_name = _VALID_IMAGE_MIME_TYPES[str(payload["mime_type"])] - asset.name = str(payload.get("name") or _build_image_asset_name(image_path, image_bytes)) - asset.type_ = client.enums.AssetTypeEnum.IMAGE - asset.image_asset.data = image_bytes - asset.image_asset.mime_type = getattr(client.enums.MimeTypeEnum, mime_type_name) - asset.image_asset.full_size.width_pixels = int(payload["width"]) - asset.image_asset.full_size.height_pixels = int(payload["height"]) - return _apply_campaign_assets( - client, - cid, - changes["campaign_id"], - changes["images"], - client.enums.AssetFieldTypeEnum.AD_IMAGE, - populate, +def _find_promotion_link( + client: object, + cid: str, + asset_id: str, + scope: str, + campaign_id: str, +) -> str: + """Look up the CampaignAsset or CustomerAsset link for a given asset_id.""" + googleads_service = client.get_service("GoogleAdsService") + asset_service = client.get_service("AssetService") + asset_resource = asset_service.asset_path(cid, asset_id) + + if scope == "campaign": + if not campaign_id: + return "" + query = ( + "SELECT campaign_asset.resource_name " + "FROM campaign_asset " + f"WHERE campaign_asset.asset = '{asset_resource}' " + f" AND campaign_asset.campaign = " + f" '{client.get_service('CampaignService').campaign_path(cid, campaign_id)}' " + " AND campaign_asset.field_type = 'PROMOTION'" + ) + else: + query = ( + "SELECT customer_asset.resource_name " + "FROM customer_asset " + f"WHERE customer_asset.asset = '{asset_resource}' " + " AND customer_asset.field_type = 'PROMOTION'" + ) + + response = googleads_service.search(customer_id=cid, query=query) + for row in response: + if scope == "campaign": + return row.campaign_asset.resource_name + return row.customer_asset.resource_name + return "" + + +def _apply_link_asset_to_customer( + client: object, cid: str, changes: dict +) -> dict: + """Create CustomerAsset link rows pointing to existing Asset rows. + + Does NOT create new Asset rows — only the link. Use this to promote + existing assets (e.g. images, logos that were uploaded to a legacy + campaign) so they apply at the account level. + """ + asset_service = client.get_service("AssetService") + cust_service = client.get_service("CustomerAssetService") + + operations = [] + for link in changes["links"]: + op = client.get_type("CustomerAssetOperation") + ca = op.create + ca.asset = asset_service.asset_path(cid, link["asset_id"]) + ca.field_type = getattr( + client.enums.AssetFieldTypeEnum, link["field_type"] + ) + operations.append(op) + + response = cust_service.mutate_customer_assets( + customer_id=cid, operations=operations ) + return { + "customer_assets": [r.resource_name for r in response.results], + "linked_count": len(response.results), + } def _apply_create_sitelinks(client: object, cid: str, changes: dict) -> dict: - """Create sitelink assets and link them to a campaign.""" + """Create sitelink assets at customer or campaign scope.""" def populate(asset: object, payload: dict) -> None: asset.sitelink_asset.link_text = payload["link_text"] @@ -2727,13 +4852,14 @@ def populate(asset: object, payload: dict) -> None: if payload.get("description2"): asset.sitelink_asset.description2 = payload["description2"] - return _apply_campaign_assets( + return _apply_assets( client, cid, - changes["campaign_id"], changes["sitelinks"], client.enums.AssetFieldTypeEnum.SITELINK, populate, + scope=changes.get("scope", "campaign"), + campaign_id=changes.get("campaign_id", ""), ) diff --git a/src/adloop/server.py b/src/adloop/server.py index fb9035a..17c6d74 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -713,6 +713,260 @@ def attribution_check( ) +@mcp.tool(annotations=_READONLY) +@_safe +def audit_event_coverage( + expected_events: list[str], + gtm_account_id: str, + gtm_container_id: str, + property_id: str = "", + date_range_start: str = "", + date_range_end: str = "", +) -> dict: + """Three-way audit: codebase events ↔ GTM tags ↔ GA4 actual fires. + + First, search the user's codebase for gtag('event', ...) and + dataLayer.push({event: ...}) calls and extract every distinct event name. + Pass that list as `expected_events`. The tool fetches the LIVE GTM + container, joins it against GA4 event counts for the date range, and + returns a per-event matrix with one of these statuses: + ok — tag active and event firing + ok_auto_collected — GA4 Enhanced Measurement event, no tag needed + no_tag_no_fire — codebase event, no GTM tag, never fires + tag_paused — GTM tag exists but is paused + tag_active_but_not_firing — tag is active but no GA4 hits + gtm_only_firing — GA4 event from a tag, not in codebase + gtm_only_not_firing — tag exists, not in codebase, no fires + ga4_only — fires in GA4, no tag, no codebase ref + ga4_fires_no_tag — codebase event firing without a GTM tag + auto_event_only — Enhanced Measurement event with no codebase ref + + Also surfaces dynamic-event tags ({{Event}} variables) and Custom HTML + tags that the audit cannot interpret automatically. + + GTM IDs come from Tag Manager UI → Admin → Container Settings. + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.crossref import audit_event_coverage as _impl + + return _impl( + _config, + expected_events=expected_events, + gtm_account_id=gtm_account_id, + gtm_container_id=gtm_container_id, + property_id=property_id or _config.ga4.property_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def list_gtm_accounts() -> dict: + """List all GTM accounts the AdLoop service account / OAuth user can read. + + Use this for first-time discovery before calling audit_event_coverage — + you need the account_id from here. If this returns an empty list, the + service account hasn't been added to any GTM container with at least + Read permission. + """ + from adloop.gtm.read import list_accounts as _impl + + return _impl(_config) + + +@mcp.tool(annotations=_READONLY) +@_safe +def list_gtm_containers(gtm_account_id: str) -> dict: + """List all containers under a GTM account. + + Returns container_id (the numeric ID needed by audit_event_coverage), + public_id (the GTM-XXXXXXX string shown in the UI), name, and usage + context (web / iOS / Android / amp / server). + """ + from adloop.gtm.read import list_containers as _impl + + return _impl(_config, account_id=gtm_account_id) + + +@mcp.tool(annotations=_READONLY) +@_safe +def list_gtm_tags(gtm_account_id: str, gtm_container_id: str) -> dict: + """List every tag in the LIVE GTM container. + + Each tag includes type, status, parsed parameters, the GA4 event name + (for GA4 event tags), and resolved firing/blocking trigger names. + Use after audit_event_coverage to inspect specific tags. + """ + from adloop.gtm.read import list_tags as _impl + + return _impl( + _config, account_id=gtm_account_id, container_id=gtm_container_id + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_gtm_tag( + gtm_account_id: str, gtm_container_id: str, tag_id: str +) -> dict: + """Get the full RAW configuration for a single GTM tag. + + Includes every parameter, firing/blocking triggers (with their filter + conditions resolved to text), priority, pause status, sampling, and + monitoring metadata. Use to inspect a tag flagged by audit_event_coverage. + """ + from adloop.gtm.read import get_tag as _impl + + return _impl( + _config, + account_id=gtm_account_id, + container_id=gtm_container_id, + tag_id=tag_id, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def list_gtm_triggers(gtm_account_id: str, gtm_container_id: str) -> dict: + """List every trigger in the LIVE GTM container. + + Each trigger has its filter conditions parsed to readable text + (e.g. "{{Page Path}} matches RegExp ^/service-promotions/"). Use to + diagnose why a tag fires or doesn't fire on specific pages. + """ + from adloop.gtm.read import list_triggers as _impl + + return _impl( + _config, account_id=gtm_account_id, container_id=gtm_container_id + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_gtm_trigger( + gtm_account_id: str, gtm_container_id: str, trigger_id: str +) -> dict: + """Get the full RAW configuration for a single GTM trigger. + + Includes filters, auto-event filters, custom-event filters, validation + settings, and a list of every tag that uses this trigger. Use to + diagnose why a tag with a specific trigger ID does or doesn't fire. + """ + from adloop.gtm.read import get_trigger as _impl + + return _impl( + _config, + account_id=gtm_account_id, + container_id=gtm_container_id, + trigger_id=trigger_id, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def list_gtm_variables(gtm_account_id: str, gtm_container_id: str) -> dict: + """List GTM variables — both custom and enabled built-in. + + Custom variables come from the live container. Built-in variables + (Page URL, Click Element, Form ID, etc.) come from the workspace's + enabled-built-ins list. Variables matter because triggers reference + them — if a trigger uses {{Form ID}} but Form ID isn't enabled, the + trigger never matches. + """ + from adloop.gtm.read import list_variables as _impl + + return _impl( + _config, account_id=gtm_account_id, container_id=gtm_container_id + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def list_gtm_workspaces(gtm_account_id: str, gtm_container_id: str) -> dict: + """List workspaces (drafts) under a GTM container. + + Workspace IDs are needed for `get_gtm_workspace_diff`. Most containers + have a single Default Workspace; multiple workspaces appear when the + team uses parallel drafts. + """ + from adloop.gtm.read import list_workspaces as _impl + + return _impl( + _config, account_id=gtm_account_id, container_id=gtm_container_id + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_gtm_workspace_diff( + gtm_account_id: str, gtm_container_id: str, workspace_id: str +) -> dict: + """Show drafted-but-not-published changes in a GTM workspace. + + Returns the list of entities (tags, triggers, variables) added, + modified, or deleted relative to the live published version, plus + any merge conflicts. Common cause of "I edited a tag but nothing + happened" — the workspace was never published. is_clean=true means + no pending changes and no conflicts. + """ + from adloop.gtm.read import get_workspace_diff as _impl + + return _impl( + _config, + account_id=gtm_account_id, + container_id=gtm_container_id, + workspace_id=workspace_id, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def list_gtm_versions( + gtm_account_id: str, gtm_container_id: str, page_size: int = 50 +) -> dict: + """List published GTM version history (newest first). + + Version headers include version_id, name, and entity counts. Use to + correlate a metric drop with a recent publish: fetch versions, find + one with timestamps near the drop date, then call get_gtm_version + for full content + author info. + """ + from adloop.gtm.read import list_versions as _impl + + return _impl( + _config, + account_id=gtm_account_id, + container_id=gtm_container_id, + page_size=page_size, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_gtm_version( + gtm_account_id: str, gtm_container_id: str, container_version_id: str +) -> dict: + """Get full metadata + entity counts for a single GTM container version. + + Returns name, description, fingerprint, and lists of tag/trigger/ + variable names at that point in time. Use after list_gtm_versions + when correlating a metric drop with a specific publish. + """ + from adloop.gtm.read import get_version as _impl + + return _impl( + _config, + account_id=gtm_account_id, + container_id=gtm_container_id, + container_version_id=container_version_id, + ) + + +# --------------------------------------------------------------------------- +# GTM Write Tools +# --------------------------------------------------------------------------- + @mcp.tool(annotations=_READONLY) @_safe def run_gaql( @@ -738,514 +992,1828 @@ def run_gaql( # --------------------------------------------------------------------------- -# Google Ads Write Tools (Safety Layer) +# ServiceTitan Read Tools # --------------------------------------------------------------------------- -@mcp.tool(annotations=_WRITE) +@mcp.tool(annotations=_READONLY) @_safe -def draft_campaign( - campaign_name: str, - daily_budget: float, - bidding_strategy: str, - geo_target_ids: list[str], - language_ids: list[str], - customer_id: str = "", - target_cpa: float = 0, - target_roas: float = 0, - channel_type: str = "SEARCH", - ad_group_name: str = "", - keywords: list[dict] | None = None, - search_partners_enabled: bool = False, - display_network_enabled: bool | None = None, - display_expansion_enabled: bool | None = None, - max_cpc: float = 0, +def health_check_servicetitan() -> dict: + """Verify ServiceTitan auth + tenant access. + + Tests OAuth client_credentials flow against auth.servicetitan.io and a + tenant-scoped read against the configured tenant_id. Returns auth_ok, + tenant_ok, and a count of business units the app can see. + + Run this first if any ServiceTitan tool is failing. + """ + from adloop.servicetitan.read import health_check as _impl + + return _impl(_config) + + +@mcp.tool(annotations=_READONLY) +@_safe +def list_st_business_units() -> dict: + """List business units in the ServiceTitan tenant. + + Most ST endpoints accept a businessUnitId filter — use this to discover + the IDs you'll need (e.g. separate residential vs commercial BUs). + """ + from adloop.servicetitan.read import list_business_units as _impl + + return _impl(_config) + + +@mcp.tool(annotations=_READONLY) +@_safe +def list_st_campaigns(active_only: bool = False) -> dict: + """List ServiceTitan marketing campaigns (channel-level). + + These are the channels ST uses to attribute leads/jobs (e.g. "Google PPC", + "Direct Mail", "Yelp"). Campaign IDs returned here are the values to pass + to get_st_calls / get_st_leads / get_st_jobs as `campaign_id`. + + Set active_only=True to filter to currently-active campaigns. + """ + from adloop.servicetitan.read import list_campaigns as _impl + + return _impl(_config, active_only=active_only) + + +@mcp.tool(annotations=_READONLY) +@_safe +def list_st_campaign_categories() -> dict: + """List ServiceTitan marketing campaign categories (parent groupings).""" + from adloop.servicetitan.read import list_campaign_categories as _impl + + return _impl(_config) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_st_calls( + date_range_start: str = "", + date_range_end: str = "", + campaign_id: int | None = None, + business_unit_id: int | None = None, + direction: str = "", + has_recording: bool | None = None, + max_results: int = 500, ) -> dict: - """Draft a full campaign structure — returns a PREVIEW, does NOT create anything. + """Pull ServiceTitan calls — duration, customer, campaign, recording flag. - Creates: CampaignBudget + Campaign (PAUSED) + AdGroup + optional Keywords - + geo targeting + language targeting. - Ads are NOT included — use draft_responsive_search_ad after the campaign exists. + date_range_start / date_range_end accept ISO-8601 (e.g. "2026-04-01T00:00:00Z"). + Defaults to last 30 days when omitted. - bidding_strategy: MAXIMIZE_CONVERSIONS | TARGET_CPA | TARGET_ROAS | - MAXIMIZE_CONVERSION_VALUE | TARGET_SPEND | MANUAL_CPC - target_cpa: required if bidding_strategy is TARGET_CPA (in account currency) - target_roas: required if bidding_strategy is TARGET_ROAS - keywords: list of {"text": "keyword", "match_type": "EXACT|PHRASE|BROAD"} - search_partners_enabled: include ads on Search partners - display_network_enabled: enable Search campaign display expansion - display_expansion_enabled: alias for display_network_enabled - max_cpc: manual CPC bid for the initial ad group when bidding_strategy is - MANUAL_CPC, or the Maximize Clicks CPC cap when bidding_strategy is - TARGET_SPEND - geo_target_ids: REQUIRED list of geo target constant IDs - Common: "2276" Germany, "2040" Austria, "2756" Switzerland, "2840" USA, - "2826" UK, "2250" France. Full list: Google Ads API geo target constants. - language_ids: REQUIRED list of language constant IDs - Common: "1001" German, "1000" English, "1002" French, "1004" Spanish, - "1014" Portuguese. Full list: Google Ads API language constants. + Filters: campaign_id (from list_st_campaigns), business_unit_id, direction + ("Inbound" or "Outbound"), has_recording (True/False/None). - Call confirm_and_apply with the returned plan_id to execute. + Returns calls with `recording_id` populated when audio is available — feed + that ID to get_st_call_recording_url to fetch the audio for transcription. """ - from adloop.ads.write import draft_campaign as _impl + from adloop.servicetitan.read import get_calls as _impl return _impl( _config, - customer_id=customer_id or _config.ads.customer_id, - campaign_name=campaign_name, - daily_budget=daily_budget, - bidding_strategy=bidding_strategy, - target_cpa=target_cpa, - target_roas=target_roas, - channel_type=channel_type, - ad_group_name=ad_group_name, - keywords=keywords, - geo_target_ids=geo_target_ids, - language_ids=language_ids, - search_partners_enabled=search_partners_enabled, - display_network_enabled=display_network_enabled, - display_expansion_enabled=display_expansion_enabled, - max_cpc=max_cpc, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + campaign_id=campaign_id, + business_unit_id=business_unit_id, + direction=direction or None, + has_recording=has_recording, + max_results=max_results, ) -@mcp.tool(annotations=_WRITE) +@mcp.tool(annotations=_READONLY) @_safe -def draft_ad_group( - campaign_id: str, - ad_group_name: str, - keywords: list[dict] | None = None, - customer_id: str = "", - cpc_bid_micros: int = 0, -) -> dict: - """Draft a new ad group within an existing campaign — returns a PREVIEW, does NOT create. +def get_st_call_recording_url(call_id: int) -> dict: + """Get a downloadable URL for a ServiceTitan call recording. - Creates an ad group (ENABLED, type SEARCH_STANDARD) in the specified campaign. - Optionally includes keywords in the same atomic operation. + Returns the recording payload (URL or signed redirect) for the given call. + Pass call_id from get_st_calls (where has_recording=true). - campaign_id: The campaign to add the ad group to (get from get_campaign_performance). - ad_group_name: Name for the new ad group. - keywords: Optional list of {"text": "keyword", "match_type": "EXACT|PHRASE|BROAD"}. - cpc_bid_micros: Optional ad group CPC bid in micros (only for MANUAL_CPC campaigns). + Note: requires the Call Recording API add-on on your ST tenant. + """ + from adloop.servicetitan.read import get_call_recording_url as _impl - Call confirm_and_apply with the returned plan_id to execute. + return _impl(_config, call_id=call_id) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_st_leads( + date_range_start: str = "", + date_range_end: str = "", + status: str = "", + campaign_id: int | None = None, + max_results: int = 500, +) -> dict: + """Pull ServiceTitan leads — status, campaign, and GCLID extraction from notes. + + Each lead is scanned for GCLID-shaped strings in the `summary` field + (since ST has no native GCLID field, web form integrations sometimes + push it into Notes). The response includes `leads_with_gclid_in_notes` + and per-lead `gclids_in_notes` arrays. + + Defaults to last 30 days. Filter by status or campaign_id as needed. """ - from adloop.ads.write import draft_ad_group as _impl + from adloop.servicetitan.read import get_leads as _impl return _impl( _config, - customer_id=customer_id or _config.ads.customer_id, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + status=status or None, campaign_id=campaign_id, - ad_group_name=ad_group_name, - keywords=keywords, - cpc_bid_micros=cpc_bid_micros, + max_results=max_results, ) -@mcp.tool(annotations=_WRITE) +@mcp.tool(annotations=_READONLY) @_safe -def update_campaign( - campaign_id: str, - customer_id: str = "", +def get_st_jobs( + date_range_start: str = "", + date_range_end: str = "", + job_status: str = "", + campaign_id: int | None = None, + business_unit_id: int | None = None, + max_results: int = 500, +) -> dict: + """Pull ServiceTitan jobs — status, campaign, BU, originating lead, total revenue. + + Defaults to last 30 days. Use `total` to compute average job value for + static conversion-value calibration in Google Ads. + """ + from adloop.servicetitan.read import get_jobs as _impl + + return _impl( + _config, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + job_status=job_status or None, + campaign_id=campaign_id, + business_unit_id=business_unit_id, + max_results=max_results, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def find_gclid_in_st( + date_range_start: str = "", + date_range_end: str = "", + max_results: int = 500, +) -> dict: + """Scan recent ServiceTitan leads + jobs for GCLID-shaped strings in notes. + + ServiceTitan has no native GCLID field. This tool checks if your form + integration pushes the GCLID into Notes/Summary so it can be uploaded + to Google Ads as an offline conversion. + + Returns counts and per-entity matches plus an actionable insight if + nothing was found (i.e. the form integration needs to be updated). + """ + from adloop.servicetitan.read import find_gclid_in_st as _impl + + return _impl( + _config, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + max_results=max_results, + ) + + +# --------------------------------------------------------------------------- +# ServiceTitan Analytics — value calibration + funnel + cross-system +# --------------------------------------------------------------------------- + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_compute_avg_job_value_by_campaign( + date_range_start: str = "", + date_range_end: str = "", + business_unit_id: int | None = None, + min_jobs: int = 5, +) -> dict: + """Average job revenue per ST campaign over a window (default last 90d). + + Returns per-campaign avg + overall avg. Use the per-campaign averages to + set differentiated Google Ads conversion values — PPC may have a different + avg ticket than Direct Mail or Yelp. Combine with st_compute_close_rate + to compute: conversion_value = avg_job_value × close_rate. + + Insights flag campaigns whose avg deviates ≥40% from overall — those + deserve their own conversion value rather than the global default. + """ + from adloop.servicetitan.analytics import compute_avg_job_value_by_campaign as _impl + + return _impl( + _config, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + business_unit_id=business_unit_id, + min_jobs=min_jobs, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_compute_close_rate_by_campaign( + date_range_start: str = "", + date_range_end: str = "", + min_leads: int = 10, +) -> dict: + """Lead → paying-job close rate per ST campaign (default last 90d). + + Highlights best/worst close-rate campaigns. Low close rate at high lead + volume usually indicates a dispatch/sales process problem rather than a + Google Ads keyword problem — investigate before scaling spend. + """ + from adloop.servicetitan.analytics import compute_close_rate_by_campaign as _impl + + return _impl( + _config, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + min_leads=min_leads, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_compute_lead_to_revenue_funnel( + date_range_start: str = "", + date_range_end: str = "", + campaign_id: int | None = None, +) -> dict: + """Funnel: leads → bookings → jobs → completed → paying, per campaign. + + Reveals at which stage leads die. If a Google Ads campaign produces leads + that never book, the campaign is fine — the dispatch process is broken. + """ + from adloop.servicetitan.analytics import compute_lead_to_revenue_funnel as _impl + + return _impl( + _config, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + campaign_id=campaign_id, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_correlate_ads_to_revenue( + date_range_start: str = "", + date_range_end: str = "", + name_overrides: dict | None = None, +) -> dict: + """Join Google Ads campaigns to ServiceTitan revenue by name match. + + Computes true_cpa (ads_cost / st_jobs_paid) and true_roas (st_revenue / + ads_cost) — the actual numbers that should drive bidding decisions. Pass + `name_overrides={ads_name: st_name}` for campaigns whose names don't + fuzzy-match. + """ + from adloop.servicetitan.analytics import correlate_ads_to_st_revenue as _impl + + return _impl( + _config, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + name_overrides=name_overrides, + ) + + +# --------------------------------------------------------------------------- +# ServiceTitan Exports — Google Ads-ready CSVs +# --------------------------------------------------------------------------- + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_export_offline_conversions( + conversion_action_name: str, + date_range_start: str = "", + date_range_end: str = "", + use_avg_value: float | None = None, + currency: str = "USD", +) -> dict: + """GCLID-based offline conversion CSV for upload to Google Ads. + + Scans completed jobs whose originating lead has a GCLID in its notes. + Writes a Google Ads-ready CSV under ~/.adloop/exports/. Returns the + rows-written count and absolute path. + + `conversion_action_name` MUST exactly match an existing conversion + action in Google Ads. Pass `use_avg_value` to override per-job revenue + with a static value (e.g. for early-stage value calibration). + """ + from adloop.servicetitan.exports import export_offline_conversions_for_ads_upload as _impl + + return _impl( + _config, + conversion_action_name=conversion_action_name, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + use_avg_value=use_avg_value, + currency=currency, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_export_phone_conversions( + conversion_action_name: str, + date_range_start: str = "", + date_range_end: str = "", + use_avg_value: float | None = None, + currency: str = "USD", + business_unit_id: int | None = None, +) -> dict: + """Phone-based call-conversion CSV for upload to Google Ads. + + Works WITHOUT GCLID — this is the right tool for accounts that don't + yet capture GCLID through the web form. Matches inbound calls (lead-call) + to Google Ads call extensions via Caller ID + Call Start Time. + + `conversion_action_name` MUST exactly match a call-conversion action + in Google Ads. + """ + from adloop.servicetitan.exports import export_phone_conversions_for_ads_upload as _impl + + return _impl( + _config, + conversion_action_name=conversion_action_name, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + use_avg_value=use_avg_value, + currency=currency, + business_unit_id=business_unit_id, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_export_enhanced_conversions_for_leads( + conversion_action_name: str, + date_range_start: str = "", + date_range_end: str = "", + use_avg_value: float | None = None, + currency: str = "USD", +) -> dict: + """Enhanced Conversions for Leads — hashed PII upload CSV. + + Recovers attribution for users who blocked GCLID (consent rejection, + iOS, etc) by matching SHA-256 hashed email/phone/name to logged-in + Google users. Pairs with the EC for Leads tag (awud) you set up in GTM. + """ + from adloop.servicetitan.exports import export_enhanced_conversions_for_leads as _impl + + return _impl( + _config, + conversion_action_name=conversion_action_name, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + use_avg_value=use_avg_value, + currency=currency, + ) + + +# --------------------------------------------------------------------------- +# ServiceTitan Transcription + Classification +# --------------------------------------------------------------------------- + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_transcribe_call(call_id: int, force_refresh: bool = False) -> dict: + """Transcribe a ServiceTitan call recording with speaker diarization. + + Uses Google Cloud Speech-to-Text (reuses the GA4/Ads service account). + Cached at ~/.adloop/st_transcripts/{call_id}.json — pass force_refresh=True + to re-transcribe. + + Requires Speech-to-Text API enabled on the GCP project and the service + account to have `roles/speech.editor` (or "Cloud Speech-to-Text User"). + """ + from adloop.servicetitan.transcribe import transcribe_st_call as _impl + + return _impl(_config, call_id=call_id, force_refresh=force_refresh) + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_classify_call_outcome(call_id: int) -> dict: + """Classify a call as booked/quoted/no_show/wrong_number/sales_call/spam/info_only. + + Auto-transcribes if needed. Rule-based — predictable + free. Returns the + matched rule + a snippet for verification. + """ + from adloop.servicetitan.classify import classify_call_outcome as _impl + + return _impl(_config, call_id=call_id) + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_extract_call_intent(call_id: int) -> dict: + """Extract service intents from a call (drain, water heater, leak, emergency...). + + Multiple intents per call are possible. Use to map call patterns to ad + groups: if 70% of "Plumbing PPC" calls ask for water heaters but you bid + on drain cleaning, the budget allocation is wrong. + """ + from adloop.servicetitan.classify import extract_call_intent as _impl + + return _impl(_config, call_id=call_id) + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_extract_negative_keywords_from_calls( + date_range_start: str = "", + date_range_end: str = "", + campaign_id: int | None = None, + min_calls_for_review: int = 5, + max_calls: int = 100, + only_with_recording: bool = True, +) -> dict: + """Mine call transcripts for negative-keyword candidates. + + Walks recent calls, transcribes each (cached), and aggregates "do you + also do X" / "I'm looking for X" phrases. Returns ranked candidates with + occurrence counts and example snippets. + + NOTE: transcribing 100 calls can take several minutes and cost ~$1 in + Google STT charges. Lower max_calls for a faster sample. + """ + from adloop.servicetitan.classify import extract_negative_keywords_from_calls as _impl + + return _impl( + _config, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + campaign_id=campaign_id, + min_calls_for_review=min_calls_for_review, + max_calls=max_calls, + only_with_recording=only_with_recording, + ) + + +# --------------------------------------------------------------------------- +# ServiceTitan Customer Match exports +# --------------------------------------------------------------------------- + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_export_customer_match_list(max_results: int = 5000) -> dict: + """Full ST customer base as a hashed Google Ads Customer Match CSV. + + Output written to ~/.adloop/exports/. Email/Phone/First/Last hashed + SHA-256 lowercase per Google spec. Upload in Google Ads → Audience + manager → Your data segments. + """ + from adloop.servicetitan.audiences import export_customer_match_list as _impl + + return _impl(_config, max_results=max_results) + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_export_lapsed_customer_audience( + months_inactive: int = 12, max_results: int = 5000 +) -> dict: + """Customers with no completed job in the last N months — reactivation audience. + + Use for low-CPM Display reactivation campaigns. Repeat-customer revenue + is much cheaper than net-new acquisition. + """ + from adloop.servicetitan.audiences import export_lapsed_customer_audience as _impl + + return _impl(_config, months_inactive=months_inactive, max_results=max_results) + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_export_high_value_seed_audience( + top_pct: float = 0.05, lookback_months: int = 24, max_results: int = 5000 +) -> dict: + """Top X% of customers by lifetime revenue — Customer Match + PMax seed. + + Exports a hashed Customer Match CSV. Use as the seed for Similar-Audience + targeting and as a PMax audience signal (PMax signals are hints — these + are your strongest hints). + """ + from adloop.servicetitan.audiences import export_high_value_seed_audience as _impl + + return _impl( + _config, + top_pct=top_pct, + lookback_months=lookback_months, + max_results=max_results, + ) + + +# --------------------------------------------------------------------------- +# ServiceTitan Demand + Attribution Decay +# --------------------------------------------------------------------------- + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_geo_demand_analysis( + date_range_start: str = "", + date_range_end: str = "", + business_unit_id: int | None = None, + group_by: str = "zip", + min_jobs: int = 5, +) -> dict: + """Job revenue + count grouped by ZIP / city / state (default last 365d). + + Drives geo bid adjustments. Areas with above-average ticket size deserve + positive bid adjustments; areas below average deserve negative or + exclusion. Insights flag both ends with concrete adjustment recommendations. + """ + from adloop.servicetitan.demand import geo_demand_analysis as _impl + + return _impl( + _config, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + business_unit_id=business_unit_id, + group_by=group_by, + min_jobs=min_jobs, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_seasonal_demand_curve( + date_range_start: str = "", + date_range_end: str = "", + business_unit_id: int | None = None, + group_by: str = "week", +) -> dict: + """Jobs by week-of-year (or month) — drives ad scheduling + budget pacing. + + Identifies peak and trough periods. Ramp Google Ads budget the period + BEFORE peak demand, not when leads are already pouring in. + """ + from adloop.servicetitan.demand import seasonal_demand_curve as _impl + + return _impl( + _config, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + business_unit_id=business_unit_id, + group_by=group_by, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_attribution_decay_report( + date_range_start: str = "", + date_range_end: str = "", + campaign_id: int | None = None, +) -> dict: + """P50/P90/P95 days from job creation to completion + histogram. + + Use to set the conversion window in Google Ads. If P90 is 45 days, the + default 30-day window will MISS 10%+ of true conversions and break Smart + Bidding's optimization signal. + """ + from adloop.servicetitan.demand import attribution_decay_report as _impl + + return _impl( + _config, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + campaign_id=campaign_id, + ) + + +# --------------------------------------------------------------------------- +# Google Ads Write Tools (Safety Layer) +# --------------------------------------------------------------------------- + + +@mcp.tool(annotations=_WRITE) +@_safe +def draft_campaign( + campaign_name: str, + daily_budget: float, + bidding_strategy: str, + geo_target_ids: list[str], + language_ids: list[str], + customer_id: str = "", + target_cpa: float = 0, + target_roas: float = 0, + channel_type: str = "SEARCH", + ad_group_name: str = "", + keywords: list[dict] | None = None, + search_partners_enabled: bool = False, + display_network_enabled: bool | None = None, + display_expansion_enabled: bool | None = None, + max_cpc: float = 0, + geo_exclude_ids: list[str] | None = None, + ad_schedule: list[dict] | None = None, +) -> dict: + """Draft a full campaign structure — returns a PREVIEW, does NOT create anything. + + Creates: CampaignBudget + Campaign (PAUSED) + AdGroup + optional Keywords + + geo targeting + language targeting. + Ads are NOT included — use draft_responsive_search_ad after the campaign exists. + + bidding_strategy: MAXIMIZE_CONVERSIONS | TARGET_CPA | TARGET_ROAS | + MAXIMIZE_CONVERSION_VALUE | TARGET_SPEND | MANUAL_CPC + target_cpa: required if bidding_strategy is TARGET_CPA (in account currency) + target_roas: required if bidding_strategy is TARGET_ROAS + keywords: list of {"text": "keyword", "match_type": "EXACT|PHRASE|BROAD"} + search_partners_enabled: include ads on Search partners + display_network_enabled: enable Search campaign display expansion + display_expansion_enabled: alias for display_network_enabled + max_cpc: manual CPC bid for the initial ad group when bidding_strategy is + MANUAL_CPC, or the Maximize Clicks CPC cap when bidding_strategy is + TARGET_SPEND + geo_target_ids: REQUIRED list of geo target constant IDs + Common: "2276" Germany, "2040" Austria, "2756" Switzerland, "2840" USA, + "2826" UK, "2250" France. Full list: Google Ads API geo target constants. + geo_exclude_ids: optional list of geo target constant IDs to EXCLUDE + (negative location criteria). Useful when targeting a broad + region while suppressing specific sub-geos. + language_ids: REQUIRED list of language constant IDs + Common: "1001" German, "1000" English, "1002" French, "1004" Spanish, + "1014" Portuguese. Full list: Google Ads API language constants. + ad_schedule: optional list of {day_of_week, start_hour, end_hour, + start_minute, end_minute} entries restricting when the campaign + serves. day_of_week: MONDAY..SUNDAY. minutes: 0/15/30/45. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.write import draft_campaign as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + campaign_name=campaign_name, + daily_budget=daily_budget, + bidding_strategy=bidding_strategy, + target_cpa=target_cpa, + target_roas=target_roas, + channel_type=channel_type, + ad_group_name=ad_group_name, + keywords=keywords, + geo_target_ids=geo_target_ids, + geo_exclude_ids=geo_exclude_ids, + language_ids=language_ids, + search_partners_enabled=search_partners_enabled, + display_network_enabled=display_network_enabled, + display_expansion_enabled=display_expansion_enabled, + max_cpc=max_cpc, + ad_schedule=ad_schedule, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def draft_ad_group( + campaign_id: str, + ad_group_name: str, + keywords: list[dict] | None = None, + customer_id: str = "", + cpc_bid_micros: int = 0, +) -> dict: + """Draft a new ad group within an existing campaign — returns a PREVIEW, does NOT create. + + Creates an ad group (ENABLED, type SEARCH_STANDARD) in the specified campaign. + Optionally includes keywords in the same atomic operation. + + campaign_id: The campaign to add the ad group to (get from get_campaign_performance). + ad_group_name: Name for the new ad group. + keywords: Optional list of {"text": "keyword", "match_type": "EXACT|PHRASE|BROAD"}. + cpc_bid_micros: Optional ad group CPC bid in micros (only for MANUAL_CPC campaigns). + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.write import draft_ad_group as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + campaign_id=campaign_id, + ad_group_name=ad_group_name, + keywords=keywords, + cpc_bid_micros=cpc_bid_micros, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def update_campaign( + campaign_id: str, + customer_id: str = "", bidding_strategy: str = "", target_cpa: float = 0, target_roas: float = 0, daily_budget: float = 0, geo_target_ids: list[str] | None = None, + geo_exclude_ids: list[str] | None = None, language_ids: list[str] | None = None, search_partners_enabled: bool | None = None, display_network_enabled: bool | None = None, display_expansion_enabled: bool | None = None, max_cpc: float = 0, + ad_schedule: list[dict] | None = None, +) -> dict: + """Draft an update to an existing campaign — returns a PREVIEW, does NOT apply. + + Only include the parameters you want to change. Omit the rest. List-typed + fields (geo_target_ids, geo_exclude_ids, language_ids, ad_schedule) follow + REPLACE semantics: when provided, all existing entries of that type are + removed and the new list is added. Pass an empty list (e.g. + ``geo_exclude_ids=[]``) to clear that field. + + campaign_id: the numeric ID of the campaign to update (required) + bidding_strategy: MAXIMIZE_CONVERSIONS | TARGET_CPA | TARGET_ROAS | + MAXIMIZE_CONVERSION_VALUE | TARGET_SPEND | MANUAL_CPC + target_cpa: required if bidding_strategy is TARGET_CPA (in account currency) + target_roas: required if bidding_strategy is TARGET_ROAS + daily_budget: new daily budget in account currency + geo_target_ids: REPLACES all geo targets. + geo_exclude_ids: REPLACES all negative-location geo criteria. + language_ids: REPLACES all language targets. + search_partners_enabled: include ads on Search partners + display_network_enabled: enable Search campaign display expansion + display_expansion_enabled: alias for display_network_enabled + max_cpc: Maximize Clicks CPC cap when bidding_strategy is TARGET_SPEND + ad_schedule: REPLACES all schedule criteria. Each entry: {day_of_week, + start_hour, end_hour, start_minute, end_minute}. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.write import update_campaign as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + campaign_id=campaign_id, + bidding_strategy=bidding_strategy, + target_cpa=target_cpa, + target_roas=target_roas, + daily_budget=daily_budget, + geo_target_ids=geo_target_ids, + geo_exclude_ids=geo_exclude_ids, + language_ids=language_ids, + search_partners_enabled=search_partners_enabled, + display_network_enabled=display_network_enabled, + display_expansion_enabled=display_expansion_enabled, + max_cpc=max_cpc, + ad_schedule=ad_schedule, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def draft_responsive_search_ad( + ad_group_id: str, + headlines: list[str | dict], + descriptions: list[str | dict], + final_url: str, + customer_id: str = "", + path1: str = "", + path2: str = "", +) -> dict: + """Draft a Responsive Search Ad — returns a PREVIEW, does NOT create the ad. + + Provide 3-15 headlines (max 30 chars each) and 2-4 descriptions (max 90 chars each). + The preview shows exactly what will be created. Call confirm_and_apply to execute. + + Each headline/description entry may be either: + + - a plain string (unpinned), or + - a dict ``{"text": "...", "pinned_field": "HEADLINE_1"}`` (pinned). + + Valid pin values: + headlines: HEADLINE_1, HEADLINE_2, HEADLINE_3 + descriptions: DESCRIPTION_1, DESCRIPTION_2 + + Google caps: at most 2 headlines per pin slot, at most 1 description per pin + slot. Mixed plain-string and dict entries are allowed within a single call + (e.g. brand pinned to HEADLINE_1, the rest unpinned). + """ + from adloop.ads.write import draft_responsive_search_ad as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + ad_group_id=ad_group_id, + headlines=headlines, + descriptions=descriptions, + final_url=final_url, + path1=path1, + path2=path2, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def update_responsive_search_ad( + ad_id: str, + customer_id: str = "", + final_url: str = "", + path1: str = "", + path2: str = "", + clear_path1: bool = False, + clear_path2: bool = False, +) -> dict: + """Update mutable fields on an existing RSA — returns a PREVIEW. + + In-place edit of an RSA without creating a new ad (preserves serving + history and avoids the learning-period reset that pause-old + create-new + triggers). Google Ads API v23 lets you mutate ``final_urls``, ``path1``, + and ``path2`` on existing RSAs; ``headlines`` and ``descriptions`` remain + immutable — for those you still need draft_responsive_search_ad + + pause_entity on the old ad. + + Argument semantics: + - ``final_url``: empty -> no change; non-empty -> replaces final URL + - ``path1`` / ``path2``: empty -> no change; non-empty -> sets value + - ``clear_path1`` / ``clear_path2``: True -> set to empty string + + At least one mutation must be requested. Call confirm_and_apply with the + returned plan_id to execute. + """ + from adloop.ads.write import update_responsive_search_ad as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + ad_id=ad_id, + final_url=final_url, + path1=path1, + path2=path2, + clear_path1=clear_path1, + clear_path2=clear_path2, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def draft_keywords( + ad_group_id: str, + keywords: list[dict], + customer_id: str = "", +) -> dict: + """Draft keyword additions — returns a PREVIEW, does NOT add keywords. + + keywords: list of {"text": "keyword phrase", "match_type": "EXACT|PHRASE|BROAD"} + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.write import draft_keywords as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + ad_group_id=ad_group_id, + keywords=keywords, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def add_negative_keywords( + campaign_id: str, + keywords: list[str], + customer_id: str = "", + match_type: str = "EXACT", +) -> dict: + """Draft negative keyword additions — returns a PREVIEW. + + Negative keywords prevent your ads from showing for irrelevant searches. + match_type: "EXACT", "PHRASE", or "BROAD" + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.write import add_negative_keywords as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + campaign_id=campaign_id, + keywords=keywords, + match_type=match_type, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def propose_negative_keyword_list( + campaign_id: str, + list_name: str, + keywords: list[str], + customer_id: str = "", + match_type: str = "EXACT", +) -> dict: + """Draft a shared negative keyword list and attach it to a campaign — returns a PREVIEW. + + Creates a reusable negative keyword list that can later be applied to multiple + campaigns, unlike add_negative_keywords which adds directly to one campaign. + match_type: "EXACT", "PHRASE", or "BROAD" + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.write import propose_negative_keyword_list as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + campaign_id=campaign_id, + list_name=list_name, + keywords=keywords, + match_type=match_type, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def add_to_negative_keyword_list( + shared_set_id: str, + keywords: list[str], + customer_id: str = "", + match_type: str = "EXACT", +) -> dict: + """Append keywords to an EXISTING shared negative keyword list — returns a PREVIEW. + + Use this when a suitable list already exists and only needs more keywords + (instead of propose_negative_keyword_list, which creates a new list). + Always call get_negative_keyword_lists first to find the right shared_set_id + and get_negative_keyword_list_keywords to avoid duplicating existing terms. + + shared_set_id: numeric ID from get_negative_keyword_lists (shared_set.id). + keywords: list of keyword strings to append (duplicates in the input list + are collapsed). + match_type: "EXACT", "PHRASE", or "BROAD" + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.write import add_to_negative_keyword_list as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + shared_set_id=shared_set_id, + keywords=keywords, + match_type=match_type, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def update_ad_group( + ad_group_id: str, + customer_id: str = "", + ad_group_name: str = "", + max_cpc: float = 0, ) -> dict: - """Draft an update to an existing campaign — returns a PREVIEW, does NOT apply. + """Draft an ad group update for name and/or manual CPC bid.""" + from adloop.ads.write import update_ad_group as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + ad_group_id=ad_group_id, + ad_group_name=ad_group_name, + max_cpc=max_cpc, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def draft_callouts( + callouts: list[str], + campaign_id: str = "", + customer_id: str = "", +) -> dict: + """Draft callout assets — returns a PREVIEW. + + If ``campaign_id`` is empty, callouts attach at the customer/account + level (CustomerAsset) and apply to all eligible campaigns. Pass a + campaign_id to scope them to one campaign instead. + """ + from adloop.ads.write import draft_callouts as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + campaign_id=campaign_id, + callouts=callouts, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def draft_structured_snippets( + snippets: list[dict], + campaign_id: str = "", + customer_id: str = "", +) -> dict: + """Draft structured snippet assets — returns a PREVIEW. + + If ``campaign_id`` is empty, snippets attach at the customer/account + level. Pass a campaign_id to scope to one campaign. + """ + from adloop.ads.write import draft_structured_snippets as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + campaign_id=campaign_id, + snippets=snippets, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def draft_image_assets( + image_paths: list[str], + campaign_id: str = "", + customer_id: str = "", + field_types: list[str] | None = None, +) -> dict: + """Draft image assets from local PNG, JPEG, or GIF files — returns a PREVIEW. + + Scope: + - If ``campaign_id`` is empty (default), images attach at the + customer/account level (CustomerAsset). + - If ``campaign_id`` is provided, images attach at that campaign + (CampaignAsset). + + Field type: + Each image's AssetFieldType is auto-detected from its aspect + ratio (with a 'logo' filename hint): + 1:1 → SQUARE_MARKETING_IMAGE (or BUSINESS_LOGO if 'logo' in name) + 1.91:1 → MARKETING_IMAGE + 4:1 → LANDSCAPE_LOGO (logo hint required) + 4:5 → PORTRAIT_MARKETING_IMAGE + Pass ``field_types`` (one entry per image, same order as + image_paths) to override. Valid override values: + MARKETING_IMAGE, SQUARE_MARKETING_IMAGE, + PORTRAIT_MARKETING_IMAGE, TALL_PORTRAIT_MARKETING_IMAGE, + LOGO, LANDSCAPE_LOGO, BUSINESS_LOGO. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.write import draft_image_assets as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + campaign_id=campaign_id, + image_paths=image_paths, + field_types=field_types, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def draft_business_name_asset( + business_name: str, + campaign_id: str = "", + customer_id: str = "", +) -> dict: + """Draft a BUSINESS_NAME text asset — returns a PREVIEW. + + Creates a TEXT asset with the business name and links it as + ``BUSINESS_NAME`` so Google can show the brand name alongside ads. + + Scope: + - If ``campaign_id`` is empty (default), the asset attaches at the + customer/account level (CustomerAsset) and applies to all + eligible campaigns. + - If ``campaign_id`` is provided, it scopes to that one campaign. + + business_name: max 25 characters per Google Ads policy. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.write import draft_business_name_asset as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + campaign_id=campaign_id, + business_name=business_name, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def pause_entity( + entity_type: str, + entity_id: str, + customer_id: str = "", +) -> dict: + """Draft pausing a campaign, ad group, ad, or keyword — returns a PREVIEW. + + entity_type: "campaign", "ad_group", "ad", or "keyword" + entity_id format by type: + - campaign: campaign ID (e.g. "12345678") + - ad_group: ad group ID (e.g. "12345678") + - ad: "adGroupId~adId" (e.g. "12345678~987654") + - keyword: "adGroupId~criterionId" (e.g. "12345678~987654") + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.write import pause_entity as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + entity_type=entity_type, + entity_id=entity_id, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def enable_entity( + entity_type: str, + entity_id: str, + customer_id: str = "", +) -> dict: + """Draft enabling a paused campaign, ad group, ad, or keyword — returns a PREVIEW. + + entity_type: "campaign", "ad_group", "ad", or "keyword" + entity_id format by type: + - campaign: campaign ID (e.g. "12345678") + - ad_group: ad group ID (e.g. "12345678") + - ad: "adGroupId~adId" (e.g. "12345678~987654") + - keyword: "adGroupId~criterionId" (e.g. "12345678~987654") + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.write import enable_entity as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + entity_type=entity_type, + entity_id=entity_id, + ) + + +@mcp.tool(annotations=_DESTRUCTIVE) +@_safe +def remove_entity( + entity_type: str, + entity_id: str, + customer_id: str = "", +) -> dict: + """Draft REMOVING an entity — returns a PREVIEW. This is IRREVERSIBLE. + + entity_type: "campaign", "ad_group", "ad", "keyword", "negative_keyword", + "shared_criterion", "campaign_asset", "asset", or "customer_asset" + entity_id: The resource ID. + For keywords: "adGroupId~criterionId" + For negative_keywords: "campaignId~criterionId" + (use the resource_id field from get_negative_keywords) + For shared_criterion: "sharedSetId~criterionId" + (use the resource_id field from get_negative_keyword_list_keywords) + For campaign_asset: "campaignId~assetId~fieldType" + For asset: simple asset ID + For customer_asset: "assetId~fieldType" + + WARNING: Removed entities cannot be re-enabled. Use pause_entity instead + if you just want to temporarily disable something. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.write import remove_entity as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + entity_type=entity_type, + entity_id=entity_id, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def draft_sitelinks( + sitelinks: list[dict], + campaign_id: str = "", + customer_id: str = "", +) -> dict: + """Draft sitelink extensions — returns a PREVIEW. + + Sitelinks appear as additional links below your ad, increasing click area + and directing users to specific pages. - Only include the parameters you want to change. Omit the rest. + Scope: + - If ``campaign_id`` is empty, sitelinks attach at the customer/account + level (CustomerAsset) and apply to all eligible campaigns. + - If ``campaign_id`` is provided, sitelinks attach at the campaign level + (CampaignAsset). - campaign_id: the numeric ID of the campaign to update (required) - bidding_strategy: MAXIMIZE_CONVERSIONS | TARGET_CPA | TARGET_ROAS | - MAXIMIZE_CONVERSION_VALUE | TARGET_SPEND | MANUAL_CPC - target_cpa: required if bidding_strategy is TARGET_CPA (in account currency) - target_roas: required if bidding_strategy is TARGET_ROAS - daily_budget: new daily budget in account currency - geo_target_ids: REPLACES all geo targets. Common IDs: "2276" Germany, - "2040" Austria, "2756" Switzerland, "2840" USA, "2826" UK - language_ids: REPLACES all language targets. Common IDs: "1001" German, - "1000" English, "1002" French, "1004" Spanish - search_partners_enabled: include ads on Search partners - display_network_enabled: enable Search campaign display expansion - display_expansion_enabled: alias for display_network_enabled - max_cpc: Maximize Clicks CPC cap when bidding_strategy is TARGET_SPEND, or - when the existing campaign already uses TARGET_SPEND + sitelinks: list of dicts, each with: + - link_text (str, required, max 25 chars) — the clickable text shown + - final_url (str, required) — destination URL for this sitelink + - description1 (str, optional, max 35 chars) — first description line + - description2 (str, optional, max 35 chars) — second description line + + Google recommends at least 4 sitelinks. Fewer than 2 may not show. Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.ads.write import update_campaign as _impl + from adloop.ads.write import draft_sitelinks as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, campaign_id=campaign_id, - bidding_strategy=bidding_strategy, - target_cpa=target_cpa, - target_roas=target_roas, - daily_budget=daily_budget, - geo_target_ids=geo_target_ids, - language_ids=language_ids, - search_partners_enabled=search_partners_enabled, - display_network_enabled=display_network_enabled, - display_expansion_enabled=display_expansion_enabled, - max_cpc=max_cpc, + sitelinks=sitelinks, ) @mcp.tool(annotations=_WRITE) @_safe -def draft_responsive_search_ad( - ad_group_id: str, - headlines: list[str | dict], - descriptions: list[str | dict], - final_url: str, +def draft_call_asset( + phone_number: str, + country_code: str = "US", + campaign_id: str = "", customer_id: str = "", - path1: str = "", - path2: str = "", + call_conversion_action_id: str = "", + ad_schedule: list[dict] | None = None, ) -> dict: - """Draft a Responsive Search Ad — returns a PREVIEW, does NOT create the ad. - - Provide 3-15 headlines (max 30 chars each) and 2-4 descriptions (max 90 chars each). - The preview shows exactly what will be created. Call confirm_and_apply to execute. - - Each headline/description entry may be either: - - - a plain string (unpinned), or - - a dict ``{"text": "...", "pinned_field": "HEADLINE_1"}`` (pinned). - - Valid pin values: - headlines: HEADLINE_1, HEADLINE_2, HEADLINE_3 - descriptions: DESCRIPTION_1, DESCRIPTION_2 + """Draft a call asset (phone extension) — returns a PREVIEW. + + Scope: + - If ``campaign_id`` is empty, the call asset is added at the + customer/account level via CustomerAsset. + - If ``campaign_id`` is provided, the call asset is scoped to that + single campaign via CampaignAsset. + + phone_number: human-formatted or E.164 (e.g. "(916) 339-3676" or + "+19163393676"). Auto-normalized to E.164 using country_code when + no leading '+' is present. + country_code: ISO-3166 alpha-2 (default "US"). Used only for E.164 + normalization when phone_number lacks a leading '+'. + call_conversion_action_id: optional Google Ads conversion action ID to + use for call-conversion counting (e.g. count calls ≥60 sec). + ad_schedule: optional list limiting hours when the call asset shows. + Each entry: {day_of_week: MONDAY..SUNDAY, start_hour: 0-23, + end_hour: 0-24, start_minute: 0/15/30/45, end_minute: 0/15/30/45}. + + NOTE: Google Ads requires manual phone-number verification before the + call asset can serve. The asset is created via API but won't show in + ads until verification is completed in Tools → Assets → Calls. - Google caps: at most 2 headlines per pin slot, at most 1 description per pin - slot. Mixed plain-string and dict entries are allowed within a single call - (e.g. brand pinned to HEADLINE_1, the rest unpinned). + Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.ads.write import draft_responsive_search_ad as _impl + from adloop.ads.write import draft_call_asset as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, - ad_group_id=ad_group_id, - headlines=headlines, - descriptions=descriptions, - final_url=final_url, - path1=path1, - path2=path2, + phone_number=phone_number, + country_code=country_code, + campaign_id=campaign_id, + call_conversion_action_id=call_conversion_action_id, + ad_schedule=ad_schedule, ) @mcp.tool(annotations=_WRITE) @_safe -def draft_keywords( - ad_group_id: str, - keywords: list[dict], +def draft_location_asset( + business_profile_account_id: str, + asset_set_name: str = "", + campaign_id: str = "", customer_id: str = "", + label_filters: list[str] | None = None, + listing_id_filters: list[str] | None = None, ) -> dict: - """Draft keyword additions — returns a PREVIEW, does NOT add keywords. + """Draft a Google Business Profile-backed location AssetSet — PREVIEW. + + Creates a LOCATION_SYNC AssetSet that pulls business locations from a + linked Google Business Profile (GBP) and exposes them as location assets + in ads (used by location extensions and the local map pin). + + business_profile_account_id: numeric GBP/LBC account ID. Find it in + the Google Business Profile admin URL or settings. + asset_set_name: optional human-readable name. Defaults to + "GBP Locations - ". + campaign_id: empty (default) for customer/account-level scope; pass a + campaign ID to limit the location assets to one campaign. + label_filters: optional list of GBP location labels to limit sync. + listing_id_filters: optional list of GBP listing IDs to limit sync. + + REQUIRED PRECONDITION: the Google Business Profile must already be + linked at Tools → Linked accounts → Business Profile in Google Ads. + If it isn't, this tool will fail at apply time with a clear error. - keywords: list of {"text": "keyword phrase", "match_type": "EXACT|PHRASE|BROAD"} Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.ads.write import draft_keywords as _impl + from adloop.ads.write import draft_location_asset as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, - ad_group_id=ad_group_id, - keywords=keywords, + business_profile_account_id=business_profile_account_id, + asset_set_name=asset_set_name, + campaign_id=campaign_id, + label_filters=label_filters, + listing_id_filters=listing_id_filters, ) @mcp.tool(annotations=_WRITE) @_safe -def add_negative_keywords( - campaign_id: str, - keywords: list[str], +def draft_promotion( + promotion_target: str, + final_url: str, + money_off: float = 0, + percent_off: float = 0, + currency_code: str = "USD", + promotion_code: str = "", + orders_over_amount: float = 0, + occasion: str = "", + discount_modifier: str = "", + language_code: str = "en", + start_date: str = "", + end_date: str = "", + redemption_start_date: str = "", + redemption_end_date: str = "", + campaign_id: str = "", customer_id: str = "", - match_type: str = "EXACT", + ad_schedule: list[dict] | None = None, ) -> dict: - """Draft negative keyword additions — returns a PREVIEW. + """Draft a promotion extension asset — returns a PREVIEW. + + Creates a PromotionAsset and links it at campaign or customer scope. + Exactly one of money_off / percent_off must be provided. + + Scope: + - campaign_id provided → CampaignAsset link. + - campaign_id empty → CustomerAsset link (account-level, applies + to every eligible campaign automatically). + + Required: + promotion_target: what the promotion is for, e.g. "Window Tint" + (max 20 chars; this is the label Google shows in the ad). + final_url: landing page (must return 2xx/3xx — validated). + money_off OR percent_off: the discount amount. + + Optional: + currency_code: ISO 4217 (default USD). + promotion_code: optional coupon code (max 15 chars). + orders_over_amount: minimum order amount that unlocks the promo. + occasion: optional event tag — BLACK_FRIDAY, CYBER_MONDAY, + CHRISTMAS, NEW_YEARS, MOTHERS_DAY, FATHERS_DAY, BACK_TO_SCHOOL, + HALLOWEEN, SUMMER_SALE, WINTER_SALE, etc. Leave blank for + always-on. + discount_modifier: "UP_TO" surfaces "Up to $X off" instead of + "$X off". Leave blank for plain. + language_code: BCP-47 (default "en"). + start_date / end_date: YYYY-MM-DD. + redemption_start_date / redemption_end_date: YYYY-MM-DD. + ad_schedule: optional list — see add_ad_schedule for shape. - Negative keywords prevent your ads from showing for irrelevant searches. - match_type: "EXACT", "PHRASE", or "BROAD" Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.ads.write import add_negative_keywords as _impl + from adloop.ads.write import draft_promotion as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, + promotion_target=promotion_target, + final_url=final_url, + money_off=money_off, + percent_off=percent_off, + currency_code=currency_code, + promotion_code=promotion_code, + orders_over_amount=orders_over_amount, + occasion=occasion, + discount_modifier=discount_modifier, + language_code=language_code, + start_date=start_date, + end_date=end_date, + redemption_start_date=redemption_start_date, + redemption_end_date=redemption_end_date, campaign_id=campaign_id, - keywords=keywords, - match_type=match_type, + ad_schedule=ad_schedule, ) @mcp.tool(annotations=_WRITE) @_safe -def propose_negative_keyword_list( - campaign_id: str, - list_name: str, - keywords: list[str], +def update_promotion( + asset_id: str, + promotion_target: str, + final_url: str, + money_off: float = 0, + percent_off: float = 0, + currency_code: str = "USD", + promotion_code: str = "", + orders_over_amount: float = 0, + occasion: str = "", + discount_modifier: str = "", + language_code: str = "en", + start_date: str = "", + end_date: str = "", + redemption_start_date: str = "", + redemption_end_date: str = "", + campaign_id: str = "", customer_id: str = "", - match_type: str = "EXACT", + ad_schedule: list[dict] | None = None, ) -> dict: - """Draft a shared negative keyword list and attach it to a campaign — returns a PREVIEW. + """Update a promotion via swap — returns a PREVIEW. + + PromotionAsset fields are immutable once created, so "update" is a SWAP: + 1. Create a new PromotionAsset with the new values. + 2. Link it at the same scope. + 3. Unlink the old asset. + + The old Asset row stays in the account (orphaned) — Google Ads API + does not support hard-deleting Asset rows. + + asset_id: numeric ID of the existing PromotionAsset (find via + SELECT asset.id, asset.promotion_asset.promotion_target FROM asset + WHERE asset.type = 'PROMOTION'). + campaign_id: pass to scope BOTH the new and old links to that + campaign. Empty for customer/account-level scope. + + All other parameters: see draft_promotion. - Creates a reusable negative keyword list that can later be applied to multiple - campaigns, unlike add_negative_keywords which adds directly to one campaign. - match_type: "EXACT", "PHRASE", or "BROAD" Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.ads.write import propose_negative_keyword_list as _impl + from adloop.ads.write import update_promotion as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, + asset_id=asset_id, campaign_id=campaign_id, - list_name=list_name, - keywords=keywords, - match_type=match_type, + promotion_target=promotion_target, + final_url=final_url, + money_off=money_off, + percent_off=percent_off, + currency_code=currency_code, + promotion_code=promotion_code, + orders_over_amount=orders_over_amount, + occasion=occasion, + discount_modifier=discount_modifier, + language_code=language_code, + start_date=start_date, + end_date=end_date, + redemption_start_date=redemption_start_date, + redemption_end_date=redemption_end_date, + ad_schedule=ad_schedule, ) @mcp.tool(annotations=_WRITE) @_safe -def add_to_negative_keyword_list( - shared_set_id: str, - keywords: list[str], +def link_asset_to_customer( + links: list[dict], customer_id: str = "", - match_type: str = "EXACT", ) -> dict: - """Append keywords to an EXISTING shared negative keyword list — returns a PREVIEW. + """Link EXISTING assets to the customer (account) — returns a PREVIEW. - Use this when a suitable list already exists and only needs more keywords - (instead of propose_negative_keyword_list, which creates a new list). - Always call get_negative_keyword_lists first to find the right shared_set_id - and get_negative_keyword_list_keywords to avoid duplicating existing terms. + Use this to "promote" assets that already exist in the account + (typically attached to legacy campaigns) so they apply at the account + level and inherit to every eligible campaign automatically. - shared_set_id: numeric ID from get_negative_keyword_lists (shared_set.id). - keywords: list of keyword strings to append (duplicates in the input list - are collapsed). - match_type: "EXACT", "PHRASE", or "BROAD" + Unlike draft_image_assets / draft_callouts / etc., this does NOT + create new Asset rows — it only adds CustomerAsset link rows + pointing to assets you already have. + + Find candidate asset_ids via run_gaql: + SELECT asset.id, asset.type, asset.name FROM asset + + links: list of dicts, each with: + - asset_id (str, required) — numeric asset ID + - field_type (str, required) — AssetFieldType enum value, e.g. + BUSINESS_LOGO, AD_IMAGE, MARKETING_IMAGE, SQUARE_MARKETING_IMAGE, + BUSINESS_NAME, SITELINK, CALLOUT, CALL, PROMOTION Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.ads.write import add_to_negative_keyword_list as _impl + from adloop.ads.write import link_asset_to_customer as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, - shared_set_id=shared_set_id, - keywords=keywords, - match_type=match_type, + links=links, ) +# --------------------------------------------------------------------------- +# Conversion Actions (create / update / remove) +# --------------------------------------------------------------------------- + + @mcp.tool(annotations=_WRITE) @_safe -def update_ad_group( - ad_group_id: str, +def draft_create_conversion_action( + name: str, + type_: str, + category: str = "DEFAULT", + default_value: float = 0, + currency_code: str = "USD", + always_use_default_value: bool = False, + counting_type: str = "ONE_PER_CLICK", + phone_call_duration_seconds: int = 0, + primary_for_goal: bool = True, + include_in_conversions_metric: bool = True, + click_through_window_days: int = 0, + view_through_window_days: int = 0, + attribution_model: str = "", customer_id: str = "", - ad_group_name: str = "", - max_cpc: float = 0, ) -> dict: - """Draft an ad group update for name and/or manual CPC bid.""" - from adloop.ads.write import update_ad_group as _impl + """Draft a new ConversionAction — returns a PREVIEW. + + type_ values: AD_CALL, WEBSITE_CALL, WEBPAGE, WEBPAGE_CODELESS, + GOOGLE_ANALYTICS_4_CUSTOM, GOOGLE_ANALYTICS_4_PURCHASE, + UPLOAD_CALLS, UPLOAD_CLICKS, FLOODLIGHT_ACTION, STORE_VISITS, + STORE_SALES_DIRECT_UPLOAD. + + category: DEFAULT, PHONE_CALL_LEAD, SUBMIT_LEAD_FORM, PURCHASE, + SIGNUP, LEAD, CONTACT, GET_DIRECTIONS, ENGAGEMENT, etc. + + For WEBSITE_CALL with GFN, set: + type_="WEBSITE_CALL", category="PHONE_CALL_LEAD", + phone_call_duration_seconds=90, default_value=250 + + For AD_CALL (calls from Call assets in ads), set: + type_="AD_CALL", category="PHONE_CALL_LEAD", + default_value=250, counting_type="ONE_PER_CLICK" + (the duration threshold for AD_CALL lives on the Call ASSET, + not on the conversion action — see update_call_asset) + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.conversion_actions import draft_create_conversion_action as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, - ad_group_id=ad_group_id, - ad_group_name=ad_group_name, - max_cpc=max_cpc, + name=name, + type_=type_, + category=category, + default_value=default_value, + currency_code=currency_code, + always_use_default_value=always_use_default_value, + counting_type=counting_type, + phone_call_duration_seconds=phone_call_duration_seconds, + primary_for_goal=primary_for_goal, + include_in_conversions_metric=include_in_conversions_metric, + click_through_window_days=click_through_window_days, + view_through_window_days=view_through_window_days, + attribution_model=attribution_model, ) @mcp.tool(annotations=_WRITE) @_safe -def draft_callouts( - campaign_id: str, - callouts: list[str], +def draft_update_conversion_action( + conversion_action_id: str, + name: str = "", + primary_for_goal: bool | None = None, + default_value: float = 0, + currency_code: str = "", + always_use_default_value: bool | None = None, + counting_type: str = "", + phone_call_duration_seconds: int = 0, + include_in_conversions_metric: bool | None = None, + click_through_window_days: int = 0, + view_through_window_days: int = 0, + attribution_model: str = "", customer_id: str = "", ) -> dict: - """Draft campaign callout assets — returns a PREVIEW.""" - from adloop.ads.write import draft_callouts as _impl + """Draft a partial UPDATE of an existing ConversionAction — PREVIEW. + + Pass only the fields you want to change. Empty strings/0/None mean + "do not change this field". + + Common workflows: + - Rename: name="Calls from Ads (>=90s)" + - Demote to Secondary: primary_for_goal=False + - Set value: default_value=250, currency_code="USD", + always_use_default_value=True + - Set call duration threshold: phone_call_duration_seconds=90 + - Switch counting: counting_type="ONE_PER_CLICK" + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.conversion_actions import draft_update_conversion_action as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, - campaign_id=campaign_id, - callouts=callouts, + conversion_action_id=conversion_action_id, + name=name, + primary_for_goal=primary_for_goal, + default_value=default_value, + currency_code=currency_code, + always_use_default_value=always_use_default_value, + counting_type=counting_type, + phone_call_duration_seconds=phone_call_duration_seconds, + include_in_conversions_metric=include_in_conversions_metric, + click_through_window_days=click_through_window_days, + view_through_window_days=view_through_window_days, + attribution_model=attribution_model, ) -@mcp.tool(annotations=_WRITE) +@mcp.tool(annotations=_DESTRUCTIVE) @_safe -def draft_structured_snippets( - campaign_id: str, - snippets: list[dict], +def draft_remove_conversion_action( + conversion_action_id: str, customer_id: str = "", ) -> dict: - """Draft campaign structured snippet assets — returns a PREVIEW.""" - from adloop.ads.write import draft_structured_snippets as _impl + """Draft a REMOVAL of a ConversionAction — returns PREVIEW (irreversible). + + Removed conversion actions stop counting. Historical data is preserved. + + Note: SMART_CAMPAIGN_* and GOOGLE_HOSTED types reject removal with + MUTATE_NOT_ALLOWED — those are auto-managed by Google. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.conversion_actions import draft_remove_conversion_action as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, - campaign_id=campaign_id, - snippets=snippets, + conversion_action_id=conversion_action_id, ) +# --------------------------------------------------------------------------- +# Asset in-place updates (call asset, sitelink, callout) +# --------------------------------------------------------------------------- + + @mcp.tool(annotations=_WRITE) @_safe -def draft_image_assets( - campaign_id: str, - image_paths: list[str], +def update_call_asset( + asset_id: str, + phone_number: str = "", + country_code: str = "", + call_conversion_action_id: str = "", + call_conversion_reporting_state: str = "", + ad_schedule: list[dict] | None = None, customer_id: str = "", ) -> dict: - """Draft campaign image assets from local PNG, JPEG, or GIF files.""" - from adloop.ads.write import draft_image_assets as _impl + """Update an existing CallAsset in place — returns a PREVIEW. + + Common use case: re-point a Call asset at a specific conversion action + (e.g. 'Calls from Ads (>=90s)') with USE_RESOURCE_LEVEL. + + Fields: + phone_number: human or E.164 (auto-normalized) + country_code: ISO-3166 alpha-2 (default US when normalizing) + call_conversion_action_id: numeric conversion action ID + call_conversion_reporting_state: DISABLED | + USE_ACCOUNT_LEVEL_CALL_CONVERSION_ACTION | + USE_RESOURCE_LEVEL_CALL_CONVERSION_ACTION + ad_schedule: optional schedule list (replaces existing) + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.write import update_call_asset as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, - campaign_id=campaign_id, - image_paths=image_paths, + asset_id=asset_id, + phone_number=phone_number, + country_code=country_code, + call_conversion_action_id=call_conversion_action_id, + call_conversion_reporting_state=call_conversion_reporting_state, + ad_schedule=ad_schedule, ) @mcp.tool(annotations=_WRITE) @_safe -def pause_entity( - entity_type: str, - entity_id: str, +def update_sitelink( + asset_id: str, + link_text: str = "", + final_url: str = "", + description1: str = "", + description2: str = "", customer_id: str = "", ) -> dict: - """Draft pausing a campaign, ad group, ad, or keyword — returns a PREVIEW. - - entity_type: "campaign", "ad_group", "ad", or "keyword" - entity_id format by type: - - campaign: campaign ID (e.g. "12345678") - - ad_group: ad group ID (e.g. "12345678") - - ad: "adGroupId~adId" (e.g. "12345678~987654") - - keyword: "adGroupId~criterionId" (e.g. "12345678~987654") + """Update an existing SitelinkAsset in place — returns a PREVIEW. - Call confirm_and_apply with the returned plan_id to execute. + Pass only the fields you want to change. Empty string = "do not change". + URL is validated for reachability when provided. """ - from adloop.ads.write import pause_entity as _impl + from adloop.ads.write import update_sitelink as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, - entity_type=entity_type, - entity_id=entity_id, + asset_id=asset_id, + link_text=link_text, + final_url=final_url, + description1=description1, + description2=description2, ) @mcp.tool(annotations=_WRITE) @_safe -def enable_entity( - entity_type: str, - entity_id: str, +def update_callout( + asset_id: str, + callout_text: str, customer_id: str = "", ) -> dict: - """Draft enabling a paused campaign, ad group, ad, or keyword — returns a PREVIEW. - - entity_type: "campaign", "ad_group", "ad", or "keyword" - entity_id format by type: - - campaign: campaign ID (e.g. "12345678") - - ad_group: ad group ID (e.g. "12345678") - - ad: "adGroupId~adId" (e.g. "12345678~987654") - - keyword: "adGroupId~criterionId" (e.g. "12345678~987654") + """Update an existing CalloutAsset's text in place — returns a PREVIEW. - Call confirm_and_apply with the returned plan_id to execute. + callout_text: new callout text (max 25 chars). """ - from adloop.ads.write import enable_entity as _impl + from adloop.ads.write import update_callout as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, - entity_type=entity_type, - entity_id=entity_id, + asset_id=asset_id, + callout_text=callout_text, ) -@mcp.tool(annotations=_DESTRUCTIVE) +@mcp.tool(annotations=_WRITE) @_safe -def remove_entity( - entity_type: str, - entity_id: str, +def add_ad_schedule( + campaign_id: str, + schedule: list[dict], customer_id: str = "", ) -> dict: - """Draft REMOVING an entity — returns a PREVIEW. This is IRREVERSIBLE. + """Draft ad schedule criteria for a campaign — returns a PREVIEW. - entity_type: "campaign", "ad_group", "ad", "keyword", "negative_keyword", - "shared_criterion", "campaign_asset", "asset", or "customer_asset" - entity_id: The resource ID. - For keywords: "adGroupId~criterionId" - For negative_keywords: "campaignId~criterionId" - (use the resource_id field from get_negative_keywords) - For shared_criterion: "sharedSetId~criterionId" - (use the resource_id field from get_negative_keyword_list_keywords) - For campaign_asset: "campaignId~assetId~fieldType" - For asset: simple asset ID - For customer_asset: "assetId~fieldType" + Adds AdScheduleInfo CampaignCriterion records so the campaign only + serves during the specified hours/days. Hours follow the account's + configured time zone. - WARNING: Removed entities cannot be re-enabled. Use pause_entity instead - if you just want to temporarily disable something. + schedule: list of dicts: + - day_of_week: MONDAY..SUNDAY + - start_hour: 0..23 + - end_hour: 0..24 (must be > start) + - start_minute / end_minute: 0, 15, 30, or 45 (default 0) + + Note: this tool is additive. Existing schedule criteria are not + removed; if you need a clean slate, use remove_entity on the existing + schedule criteria first. Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.ads.write import remove_entity as _impl + from adloop.ads.write import add_ad_schedule as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, - entity_type=entity_type, - entity_id=entity_id, + campaign_id=campaign_id, + schedule=schedule, ) @mcp.tool(annotations=_WRITE) @_safe -def draft_sitelinks( +def add_geo_exclusions( campaign_id: str, - sitelinks: list[dict], + geo_target_ids: list[str], customer_id: str = "", ) -> dict: - """Draft sitelink extensions for a campaign — returns a PREVIEW. - - Sitelinks appear as additional links below your ad, increasing click area - and directing users to specific pages. + """Draft negative geo CampaignCriterion records — returns a PREVIEW. - campaign_id: the campaign to attach sitelinks to - sitelinks: list of dicts, each with: - - link_text (str, required, max 25 chars) — the clickable text shown - - final_url (str, required) — destination URL for this sitelink - - description1 (str, optional, max 35 chars) — first description line - - description2 (str, optional, max 35 chars) — second description line + Adds excluded locations so the campaign does not serve to users in + those geos. Use this when you have a broad include list but specific + sub-geos to suppress (e.g. include "California" but exclude "Los + Angeles"). - Google recommends at least 4 sitelinks per campaign. Fewer than 2 may not show. + geo_target_ids: list of geoTargetConstant IDs. Look them up via: + SELECT geo_target_constant.id, geo_target_constant.name + FROM geo_target_constant + WHERE geo_target_constant.country_code = 'US' + AND geo_target_constant.name = 'Los Angeles' Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.ads.write import draft_sitelinks as _impl + from adloop.ads.write import add_geo_exclusions as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, campaign_id=campaign_id, - sitelinks=sitelinks, + geo_target_ids=geo_target_ids, ) diff --git a/tests/test_ads_extensions.py b/tests/test_ads_extensions.py new file mode 100644 index 0000000..8c7eacb --- /dev/null +++ b/tests/test_ads_extensions.py @@ -0,0 +1,2975 @@ +"""Tests for AdLoop write-tool extensions: + +- Customer-level scope for sitelinks/callouts/structured_snippets +- draft_call_asset +- draft_location_asset +- add_ad_schedule (+ ad_schedule integration in draft_campaign / update_campaign) +- add_geo_exclusions (+ geo_exclude_ids integration in draft_campaign / update_campaign) +- _validate_ad_schedule + _normalize_phone_e164 +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from google.ads.googleads.client import GoogleAdsClient + +from adloop.ads.client import GOOGLE_ADS_API_VERSION +from adloop.ads import write +from adloop.config import AdLoopConfig, AdsConfig, SafetyConfig +from adloop.safety import preview as preview_store + + +# --------------------------------------------------------------------------- +# Shared fakes (mirror test_ads_write.py to stay consistent with existing +# patterns) +# --------------------------------------------------------------------------- + + +class _FakeResult: + def __init__(self, resource_name: str = ""): + self.resource_name = resource_name + + +class _FakeMutateOperationResponse: + def __init__(self, response_type: str | None = None, resource_name: str = ""): + self.campaign_budget_result = _FakeResult() + self.campaign_result = _FakeResult() + self.ad_group_result = _FakeResult() + self.campaign_criterion_result = _FakeResult() + self.asset_result = _FakeResult() + self.campaign_asset_result = _FakeResult() + self.customer_asset_result = _FakeResult() + self._response_type = response_type + if response_type: + getattr(self, response_type).resource_name = resource_name + + def WhichOneof(self, _: str) -> str | None: + return self._response_type + + +class _FakePathService: + def __init__(self, prefix: str): + self.prefix = prefix + + def campaign_path(self, customer_id: str, entity_id: str) -> str: + return f"customers/{customer_id}/{self.prefix}/{entity_id}" + + def campaign_budget_path(self, customer_id: str, entity_id: str) -> str: + return f"customers/{customer_id}/{self.prefix}/{entity_id}" + + def ad_group_path(self, customer_id: str, entity_id: str) -> str: + return f"customers/{customer_id}/{self.prefix}/{entity_id}" + + def asset_path(self, customer_id: str, entity_id: str) -> str: + return f"customers/{customer_id}/{self.prefix}/{entity_id}" + + def conversion_action_path(self, customer_id: str, entity_id: str) -> str: + return f"customers/{customer_id}/{self.prefix}/{entity_id}" + + +class _FakeGoogleAdsService(_FakePathService): + def __init__( + self, + responses: list[_FakeMutateOperationResponse] | None = None, + search_rows: list[object] | None = None, + ): + super().__init__("campaigns") + self.operations = None + self._responses = responses or [] + self._search_rows = search_rows or [] + self.search_calls: list[str] = [] + + def mutate(self, customer_id: str, mutate_operations: list[object]) -> object: + self.operations = mutate_operations + return SimpleNamespace(mutate_operation_responses=self._responses) + + def search(self, customer_id: str, query: str) -> list[object]: + self.search_calls.append(query) + return list(self._search_rows) + + +class _FakeCampaignCriterionService(_FakePathService): + def __init__(self, responses: list[_FakeResult] | None = None): + super().__init__("campaignCriteria") + self.operations = None + self._responses = responses or [] + + def mutate_campaign_criteria( + self, customer_id: str, operations: list[object] + ) -> object: + self.operations = operations + return SimpleNamespace(results=self._responses) + + +class _FakeAssetSetService(_FakePathService): + def __init__(self, responses: list[_FakeResult] | None = None): + super().__init__("assetSets") + self.operations = None + self._responses = responses or [] + + def mutate_asset_sets( + self, customer_id: str, operations: list[object] + ) -> object: + self.operations = operations + return SimpleNamespace(results=self._responses) + + +class _FakeCustomerAssetSetService(_FakePathService): + def __init__(self, responses: list[_FakeResult] | None = None): + super().__init__("customerAssetSets") + self.operations = None + self._responses = responses or [] + + def mutate_customer_asset_sets( + self, customer_id: str, operations: list[object] + ) -> object: + self.operations = operations + return SimpleNamespace(results=self._responses) + + +class _FakeCampaignAssetSetService(_FakePathService): + def __init__(self, responses: list[_FakeResult] | None = None): + super().__init__("campaignAssetSets") + self.operations = None + self._responses = responses or [] + + def mutate_campaign_asset_sets( + self, customer_id: str, operations: list[object] + ) -> object: + self.operations = operations + return SimpleNamespace(results=self._responses) + + +class _FakeClient: + def __init__(self, services: dict[str, object]): + self._base = GoogleAdsClient( + credentials=None, + developer_token="test-token", + use_proto_plus=True, + version=GOOGLE_ADS_API_VERSION, + ) + self.enums = self._base.enums + self.get_type = self._base.get_type + self._services = services + + def get_service(self, name: str) -> object: + return self._services[name] + + +@pytest.fixture(autouse=True) +def clear_pending_plans(): + preview_store._pending_plans.clear() + yield + preview_store._pending_plans.clear() + + +@pytest.fixture +def config() -> AdLoopConfig: + return AdLoopConfig( + ads=AdsConfig(customer_id="123-456-7890"), + safety=SafetyConfig(require_dry_run=True), + ) + + +# --------------------------------------------------------------------------- +# _validate_ad_schedule +# --------------------------------------------------------------------------- + + +class TestValidateAdSchedule: + def test_valid_entry_normalizes_day_to_uppercase(self): + validated, errors = write._validate_ad_schedule( + [{"day_of_week": "monday", "start_hour": 7, "end_hour": 18}] + ) + assert errors == [] + assert validated == [{ + "day_of_week": "MONDAY", + "start_hour": 7, + "start_minute": 0, + "end_hour": 18, + "end_minute": 0, + }] + + def test_invalid_day_of_week_raises_error(self): + _, errors = write._validate_ad_schedule( + [{"day_of_week": "TOMORROW", "start_hour": 7, "end_hour": 18}] + ) + assert any("day_of_week" in e for e in errors) + + def test_invalid_minutes_value_rejected(self): + _, errors = write._validate_ad_schedule( + [{ + "day_of_week": "MONDAY", + "start_hour": 7, + "start_minute": 7, + "end_hour": 18, + }] + ) + assert any("start_minute" in e for e in errors) + + def test_end_must_be_after_start(self): + _, errors = write._validate_ad_schedule( + [{"day_of_week": "MONDAY", "start_hour": 18, "end_hour": 7}] + ) + assert any("end" in e and "after" in e for e in errors) + + def test_non_dict_entry_rejected(self): + _, errors = write._validate_ad_schedule(["MONDAY 7-18"]) + assert any("must be a dict" in e for e in errors) + + def test_hour_out_of_range_rejected(self): + _, errors = write._validate_ad_schedule( + [{"day_of_week": "MONDAY", "start_hour": 25, "end_hour": 26}] + ) + assert any("start_hour" in e for e in errors) + assert any("end_hour" in e for e in errors) + + def test_minute_increments_accepted(self): + for minute in (0, 15, 30, 45): + validated, errors = write._validate_ad_schedule( + [{ + "day_of_week": "WEDNESDAY", + "start_hour": 9, + "start_minute": minute, + "end_hour": 17, + "end_minute": minute, + }] + ) + assert errors == [] + assert validated[0]["start_minute"] == minute + assert validated[0]["end_minute"] == minute + + +# --------------------------------------------------------------------------- +# _normalize_phone_e164 +# --------------------------------------------------------------------------- + + +class TestNormalizePhoneE164: + @pytest.mark.parametrize( + "phone,country,expected", + [ + ("(916) 339-3676", "US", "+19163393676"), + ("9163393676", "US", "+19163393676"), + ("19163393676", "US", "+19163393676"), + ("+19163393676", "US", "+19163393676"), + ("020 7946 0958", "GB", "+442079460958"), + ], + ) + def test_normalizes_to_e164(self, phone, country, expected): + normalized, err = write._normalize_phone_e164(phone, country) + assert err is None + assert normalized == expected + + def test_empty_phone_errors(self): + normalized, err = write._normalize_phone_e164("", "US") + assert "empty" in err + assert normalized == "" + + def test_unknown_country_without_plus_prefix_errors(self): + normalized, err = write._normalize_phone_e164("123456789", "ZZ") + assert "country_code" in err + assert normalized == "" + + def test_already_e164_with_unknown_country_passes(self): + normalized, err = write._normalize_phone_e164("+99000111222", "ZZ") + assert err is None + assert normalized == "+99000111222" + + +# --------------------------------------------------------------------------- +# Customer-level scope on sitelinks / callouts / structured_snippets +# --------------------------------------------------------------------------- + + +class TestCustomerScopeAssets: + def test_draft_callouts_without_campaign_id_uses_customer_scope(self, config): + result = write.draft_callouts( + config, + customer_id="1234567890", + callouts=["Free Pickup", "R2 Certified"], + ) + assert "error" not in result + assert result["entity_type"] == "customer_asset" + plan_id = result["plan_id"] + plan = preview_store._pending_plans[plan_id] + assert plan.changes["scope"] == "customer" + assert plan.changes["campaign_id"] == "" + + def test_draft_callouts_with_campaign_id_uses_campaign_scope(self, config): + result = write.draft_callouts( + config, + customer_id="1234567890", + campaign_id="1001", + callouts=["Free Pickup"], + ) + assert result["entity_type"] == "campaign_asset" + plan_id = result["plan_id"] + plan = preview_store._pending_plans[plan_id] + assert plan.changes["scope"] == "campaign" + assert plan.changes["campaign_id"] == "1001" + + def test_draft_structured_snippets_customer_scope(self, config): + result = write.draft_structured_snippets( + config, + customer_id="1234567890", + snippets=[{"header": "Services", "values": ["A", "B", "C"]}], + ) + assert result["entity_type"] == "customer_asset" + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["scope"] == "customer" + + def test_draft_sitelinks_customer_scope(self, config, monkeypatch): + # _validate_urls performs HTTP — stub it out for unit tests + monkeypatch.setattr( + write, + "_validate_urls", + lambda urls, timeout=10: {u: None for u in urls}, + ) + result = write.draft_sitelinks( + config, + customer_id="1234567890", + sitelinks=[ + { + "link_text": "Get a Quote", + "final_url": "https://example.com/quote", + }, + { + "link_text": "Contact Us", + "final_url": "https://example.com/contact", + }, + ], + ) + assert result["entity_type"] == "customer_asset" + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["scope"] == "customer" + + def test_apply_create_callouts_customer_scope_emits_customer_asset_op(self): + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "customer_asset_result", "customers/1/customerAssets/1~CALLOUT" + ), + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + } + ) + + write._apply_create_callouts( + client, + "1", + { + "scope": "customer", + "campaign_id": "", + "callouts": ["Free Shipping"], + }, + ) + + assert len(google_ads.operations) == 2 + # Op 0: asset create + asset_op = google_ads.operations[0].asset_operation.create + assert asset_op.callout_asset.callout_text == "Free Shipping" + # Op 1: customer asset link (NOT campaign asset) + link_op = google_ads.operations[1].customer_asset_operation.create + assert link_op.field_type == client.enums.AssetFieldTypeEnum.CALLOUT + # Must NOT have populated campaign_asset_operation + assert ( + google_ads.operations[1].campaign_asset_operation.create.field_type + == client.enums.AssetFieldTypeEnum.UNSPECIFIED + ) + + def test_apply_create_sitelinks_customer_scope_emits_customer_asset_op(self): + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "customer_asset_result", "customers/1/customerAssets/1~SITELINK" + ), + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + } + ) + + write._apply_create_sitelinks( + client, + "1", + { + "scope": "customer", + "campaign_id": "", + "sitelinks": [ + { + "link_text": "Quote", + "final_url": "https://example.com/quote", + "description1": "Quick quote", + "description2": "Sacramento-based", + } + ], + }, + ) + link_op = google_ads.operations[1].customer_asset_operation.create + assert link_op.field_type == client.enums.AssetFieldTypeEnum.SITELINK + + def test_apply_create_structured_snippets_customer_scope(self): + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "customer_asset_result", + "customers/1/customerAssets/1~STRUCTURED_SNIPPET", + ), + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + } + ) + + write._apply_create_structured_snippets( + client, + "1", + { + "scope": "customer", + "campaign_id": "", + "snippets": [ + {"header": "Services", "values": ["A", "B", "C"]} + ], + }, + ) + link_op = google_ads.operations[1].customer_asset_operation.create + assert link_op.field_type == client.enums.AssetFieldTypeEnum.STRUCTURED_SNIPPET + + def test_apply_assets_rejects_unknown_scope(self): + client = _FakeClient( + { + "GoogleAdsService": _FakeGoogleAdsService(), + "AssetService": _FakePathService("assets"), + } + ) + with pytest.raises(ValueError, match="Unknown asset scope"): + write._apply_assets( + client, + "1", + [{"callout_text": "X"}], + client.enums.AssetFieldTypeEnum.CALLOUT, + lambda asset, p: None, + scope="bogus", + campaign_id="", + ) + + def test_apply_assets_campaign_scope_requires_campaign_id(self): + client = _FakeClient( + { + "GoogleAdsService": _FakeGoogleAdsService(), + "AssetService": _FakePathService("assets"), + } + ) + with pytest.raises(ValueError, match="campaign_id is required"): + write._apply_assets( + client, + "1", + [{"callout_text": "X"}], + client.enums.AssetFieldTypeEnum.CALLOUT, + lambda asset, p: None, + scope="campaign", + campaign_id="", + ) + + +# --------------------------------------------------------------------------- +# draft_call_asset +# --------------------------------------------------------------------------- + + +class TestDraftCallAsset: + def test_requires_phone_number(self, config): + result = write.draft_call_asset(config, customer_id="1234567890") + assert "phone_number is required" in result["error"] + + def test_normalizes_us_phone_and_picks_customer_scope(self, config): + result = write.draft_call_asset( + config, + customer_id="1234567890", + phone_number="(916) 339-3676", + ) + assert "error" not in result + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["phone_number"] == "+19163393676" + assert plan.changes["scope"] == "customer" + assert plan.changes["country_code"] == "US" + assert "warnings" in result + assert any("verification" in w.lower() for w in result["warnings"]) + + def test_campaign_scope_when_campaign_id_provided(self, config): + result = write.draft_call_asset( + config, + customer_id="1234567890", + campaign_id="42", + phone_number="+19163393676", + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["scope"] == "campaign" + assert plan.changes["campaign_id"] == "42" + + def test_invalid_ad_schedule_short_circuits(self, config): + result = write.draft_call_asset( + config, + customer_id="1234567890", + phone_number="+19163393676", + ad_schedule=[{"day_of_week": "BAD", "start_hour": 7, "end_hour": 18}], + ) + assert result["error"] == "Ad schedule validation failed" + + +class TestApplyCreateCallAsset: + def test_customer_scope_creates_customer_asset_link(self): + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "customer_asset_result", "customers/1/customerAssets/1~CALL" + ), + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + "ConversionActionService": _FakePathService("conversionActions"), + } + ) + + write._apply_create_call_asset( + client, + "1", + { + "scope": "customer", + "campaign_id": "", + "phone_number": "+19163393676", + "country_code": "US", + "call_conversion_action_id": "", + "ad_schedule": [ + { + "day_of_week": "MONDAY", + "start_hour": 7, + "start_minute": 0, + "end_hour": 18, + "end_minute": 0, + } + ], + }, + ) + + assert len(google_ads.operations) == 2 + asset_op = google_ads.operations[0].asset_operation.create + assert asset_op.call_asset.country_code == "US" + assert asset_op.call_asset.phone_number == "+19163393676" + # Schedule embedded on call asset + assert len(asset_op.call_asset.ad_schedule_targets) == 1 + sched = asset_op.call_asset.ad_schedule_targets[0] + assert sched.day_of_week == client.enums.DayOfWeekEnum.MONDAY + assert sched.start_hour == 7 + assert sched.end_hour == 18 + # Customer-scope link + link = google_ads.operations[1].customer_asset_operation.create + assert link.field_type == client.enums.AssetFieldTypeEnum.CALL + + def test_campaign_scope_creates_campaign_asset_link(self): + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "campaign_asset_result", "customers/1/campaignAssets/42~CALL" + ), + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + "ConversionActionService": _FakePathService("conversionActions"), + } + ) + + write._apply_create_call_asset( + client, + "1", + { + "scope": "campaign", + "campaign_id": "42", + "phone_number": "+442079460958", + "country_code": "GB", + "call_conversion_action_id": "", + "ad_schedule": [], + }, + ) + link = google_ads.operations[1].campaign_asset_operation.create + assert link.field_type == client.enums.AssetFieldTypeEnum.CALL + assert link.campaign == "customers/1/campaigns/42" + + def test_with_call_conversion_action(self): + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "customer_asset_result", "customers/1/customerAssets/1~CALL" + ), + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + "ConversionActionService": _FakePathService("conversionActions"), + } + ) + + write._apply_create_call_asset( + client, + "1", + { + "scope": "customer", + "campaign_id": "", + "phone_number": "+19163393676", + "country_code": "US", + "call_conversion_action_id": "777", + "ad_schedule": [], + }, + ) + asset_op = google_ads.operations[0].asset_operation.create + assert asset_op.call_asset.call_conversion_action == ( + "customers/1/conversionActions/777" + ) + assert ( + asset_op.call_asset.call_conversion_reporting_state + == client.enums.CallConversionReportingStateEnum.USE_RESOURCE_LEVEL_CALL_CONVERSION_ACTION + ) + + def test_unknown_scope_raises(self): + google_ads = _FakeGoogleAdsService([]) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + "ConversionActionService": _FakePathService("conversionActions"), + } + ) + with pytest.raises(ValueError, match="Unknown scope"): + write._apply_create_call_asset( + client, + "1", + { + "scope": "bogus", + "campaign_id": "", + "phone_number": "+19163393676", + "country_code": "US", + "call_conversion_action_id": "", + "ad_schedule": [], + }, + ) + + +# --------------------------------------------------------------------------- +# draft_location_asset +# --------------------------------------------------------------------------- + + +class TestDraftLocationAsset: + def test_requires_business_profile_account_id(self, config): + result = write.draft_location_asset(config, customer_id="1234567890") + assert "business_profile_account_id is required" in result["error"] + + def test_default_asset_set_name_uses_id(self, config): + result = write.draft_location_asset( + config, + customer_id="1234567890", + business_profile_account_id="987654321", + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["asset_set_name"] == "GBP Locations - 987654321" + assert plan.changes["scope"] == "customer" + assert plan.changes["business_profile_account_id"] == "987654321" + + def test_campaign_scope_when_campaign_id_provided(self, config): + result = write.draft_location_asset( + config, + customer_id="1234567890", + business_profile_account_id="987654321", + campaign_id="42", + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["scope"] == "campaign" + + def test_warnings_mention_gbp_link_requirement(self, config): + result = write.draft_location_asset( + config, + customer_id="1234567890", + business_profile_account_id="987654321", + ) + assert "warnings" in result + assert any("Business Profile" in w for w in result["warnings"]) + + +class TestApplyCreateLocationAsset: + def test_customer_scope_creates_asset_set_and_customer_link(self): + asset_set_service = _FakeAssetSetService( + [_FakeResult("customers/1/assetSets/9001")] + ) + customer_link_service = _FakeCustomerAssetSetService( + [_FakeResult("customers/1/customerAssetSets/9001")] + ) + client = _FakeClient( + { + "GoogleAdsService": _FakeGoogleAdsService(), + "AssetSetService": asset_set_service, + "CustomerAssetSetService": customer_link_service, + } + ) + + result = write._apply_create_location_asset( + client, + "1", + { + "scope": "customer", + "campaign_id": "", + "business_profile_account_id": "987", + "asset_set_name": "GBP Locations - 987", + "label_filters": [], + "listing_id_filters": [], + }, + ) + assert result["asset_set"] == "customers/1/assetSets/9001" + assert result["customer_asset_set"] == "customers/1/customerAssetSets/9001" + + op = asset_set_service.operations[0].create + assert op.name == "GBP Locations - 987" + assert op.type_ == client.enums.AssetSetTypeEnum.LOCATION_SYNC + assert ( + op.location_set.business_profile_location_set.business_account_id + == "987" + ) + + def test_campaign_scope_creates_campaign_asset_set(self): + asset_set_service = _FakeAssetSetService( + [_FakeResult("customers/1/assetSets/9001")] + ) + campaign_link_service = _FakeCampaignAssetSetService( + [_FakeResult("customers/1/campaignAssetSets/42~9001")] + ) + client = _FakeClient( + { + "GoogleAdsService": _FakeGoogleAdsService(), + "AssetSetService": asset_set_service, + "CampaignService": _FakePathService("campaigns"), + "CampaignAssetSetService": campaign_link_service, + } + ) + + result = write._apply_create_location_asset( + client, + "1", + { + "scope": "campaign", + "campaign_id": "42", + "business_profile_account_id": "987", + "asset_set_name": "GBP", + "label_filters": [], + "listing_id_filters": [], + }, + ) + assert ( + result["campaign_asset_set"] == "customers/1/campaignAssetSets/42~9001" + ) + link_op = campaign_link_service.operations[0].create + assert link_op.campaign == "customers/1/campaigns/42" + assert link_op.asset_set == "customers/1/assetSets/9001" + + def test_label_filters_propagate(self): + asset_set_service = _FakeAssetSetService( + [_FakeResult("customers/1/assetSets/9001")] + ) + customer_link_service = _FakeCustomerAssetSetService( + [_FakeResult("customers/1/customerAssetSets/9001")] + ) + client = _FakeClient( + { + "GoogleAdsService": _FakeGoogleAdsService(), + "AssetSetService": asset_set_service, + "CustomerAssetSetService": customer_link_service, + } + ) + + write._apply_create_location_asset( + client, + "1", + { + "scope": "customer", + "campaign_id": "", + "business_profile_account_id": "987", + "asset_set_name": "GBP", + "label_filters": ["Storefront", "Warehouse"], + "listing_id_filters": [], + }, + ) + op = asset_set_service.operations[0].create + assert list(op.location_set.business_profile_location_set.label_filters) == [ + "Storefront", + "Warehouse", + ] + + def test_campaign_scope_requires_campaign_id(self): + asset_set_service = _FakeAssetSetService( + [_FakeResult("customers/1/assetSets/9001")] + ) + client = _FakeClient( + { + "GoogleAdsService": _FakeGoogleAdsService(), + "AssetSetService": asset_set_service, + "CampaignService": _FakePathService("campaigns"), + "CampaignAssetSetService": _FakeCampaignAssetSetService(), + } + ) + with pytest.raises(ValueError, match="campaign_id required"): + write._apply_create_location_asset( + client, + "1", + { + "scope": "campaign", + "campaign_id": "", + "business_profile_account_id": "987", + "asset_set_name": "GBP", + "label_filters": [], + "listing_id_filters": [], + }, + ) + + +# --------------------------------------------------------------------------- +# add_ad_schedule +# --------------------------------------------------------------------------- + + +class TestAddAdSchedule: + def test_requires_campaign_id(self, config): + result = write.add_ad_schedule( + config, + customer_id="1234567890", + schedule=[ + {"day_of_week": "MONDAY", "start_hour": 7, "end_hour": 18} + ], + ) + assert "campaign_id is required" in result["error"] + + def test_requires_at_least_one_entry(self, config): + result = write.add_ad_schedule( + config, + customer_id="1234567890", + campaign_id="42", + schedule=[], + ) + assert "At least one schedule entry" in result["error"] + + def test_invalid_schedule_returns_validation_failure(self, config): + result = write.add_ad_schedule( + config, + customer_id="1234567890", + campaign_id="42", + schedule=[{"day_of_week": "BAD", "start_hour": 7, "end_hour": 18}], + ) + assert result["error"] == "Validation failed" + + def test_valid_schedule_stores_plan(self, config): + result = write.add_ad_schedule( + config, + customer_id="1234567890", + campaign_id="42", + schedule=[ + {"day_of_week": "Monday", "start_hour": 7, "end_hour": 18}, + {"day_of_week": "TUESDAY", "start_hour": 7, "end_hour": 18}, + ], + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.operation == "add_ad_schedule" + assert plan.changes["campaign_id"] == "42" + assert len(plan.changes["schedule"]) == 2 + assert plan.changes["schedule"][0]["day_of_week"] == "MONDAY" + + +class TestApplyAddAdSchedule: + def test_creates_one_criterion_per_day(self): + crit_service = _FakeCampaignCriterionService( + [ + _FakeResult("customers/1/campaignCriteria/42~1001"), + _FakeResult("customers/1/campaignCriteria/42~1002"), + ] + ) + client = _FakeClient( + { + "CampaignService": _FakePathService("campaigns"), + "CampaignCriterionService": crit_service, + } + ) + + result = write._apply_add_ad_schedule( + client, + "1", + { + "campaign_id": "42", + "schedule": [ + { + "day_of_week": "MONDAY", + "start_hour": 7, + "start_minute": 0, + "end_hour": 18, + "end_minute": 30, + }, + { + "day_of_week": "TUESDAY", + "start_hour": 9, + "start_minute": 15, + "end_hour": 17, + "end_minute": 0, + }, + ], + }, + ) + + assert len(crit_service.operations) == 2 + first = crit_service.operations[0].create + assert first.campaign == "customers/1/campaigns/42" + assert first.ad_schedule.day_of_week == client.enums.DayOfWeekEnum.MONDAY + assert first.ad_schedule.start_hour == 7 + assert first.ad_schedule.end_hour == 18 + assert first.ad_schedule.end_minute == client.enums.MinuteOfHourEnum.THIRTY + + second = crit_service.operations[1].create + assert second.ad_schedule.day_of_week == client.enums.DayOfWeekEnum.TUESDAY + assert second.ad_schedule.start_minute == client.enums.MinuteOfHourEnum.FIFTEEN + + assert len(result["campaign_criteria"]) == 2 + + +# --------------------------------------------------------------------------- +# add_geo_exclusions +# --------------------------------------------------------------------------- + + +class TestAddGeoExclusions: + def test_requires_campaign_id(self, config): + result = write.add_geo_exclusions( + config, + customer_id="1234567890", + geo_target_ids=["1014962"], + ) + assert "campaign_id is required" in result["error"] + + def test_requires_at_least_one_geo(self, config): + result = write.add_geo_exclusions( + config, + customer_id="1234567890", + campaign_id="42", + geo_target_ids=[], + ) + assert "At least one geo_target_id" in result["error"] + + def test_strips_blank_entries_and_stores_plan(self, config): + result = write.add_geo_exclusions( + config, + customer_id="1234567890", + campaign_id="42", + geo_target_ids=[" 1014962 ", "", "1013570"], + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.operation == "add_geo_exclusions" + assert plan.changes["geo_target_ids"] == ["1014962", "1013570"] + + +class TestApplyAddGeoExclusions: + def test_creates_negative_location_criteria(self): + crit_service = _FakeCampaignCriterionService( + [ + _FakeResult("customers/1/campaignCriteria/42~1014962"), + _FakeResult("customers/1/campaignCriteria/42~1013570"), + ] + ) + client = _FakeClient( + { + "CampaignService": _FakePathService("campaigns"), + "CampaignCriterionService": crit_service, + } + ) + + write._apply_add_geo_exclusions( + client, + "1", + {"campaign_id": "42", "geo_target_ids": ["1014962", "1013570"]}, + ) + + assert len(crit_service.operations) == 2 + first = crit_service.operations[0].create + assert first.campaign == "customers/1/campaigns/42" + assert first.location.geo_target_constant == "geoTargetConstants/1014962" + assert first.negative is True + + second = crit_service.operations[1].create + assert second.location.geo_target_constant == "geoTargetConstants/1013570" + assert second.negative is True + + +# --------------------------------------------------------------------------- +# draft_campaign integration with new params +# --------------------------------------------------------------------------- + + +class TestDraftCampaignNewParams: + def test_geo_exclude_overlap_returns_error(self, config): + result = write.draft_campaign( + config, + customer_id="1234567890", + campaign_name="X", + daily_budget=10, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=["2840"], + geo_exclude_ids=["2840"], + language_ids=["1000"], + ) + assert result["error"] == "geo_exclude_ids overlap with geo_target_ids" + + def test_invalid_ad_schedule_returns_error(self, config): + result = write.draft_campaign( + config, + customer_id="1234567890", + campaign_name="X", + daily_budget=10, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=["2840"], + language_ids=["1000"], + ad_schedule=[ + {"day_of_week": "BOGUS", "start_hour": 7, "end_hour": 18}, + ], + ) + assert result["error"] == "Ad schedule validation failed" + + def test_valid_new_params_stored_in_plan(self, config): + result = write.draft_campaign( + config, + customer_id="1234567890", + campaign_name="X", + daily_budget=10, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=["2840"], + geo_exclude_ids=["1014962", "1013570"], + language_ids=["1000"], + ad_schedule=[ + {"day_of_week": "monday", "start_hour": 7, "end_hour": 18}, + ], + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["geo_exclude_ids"] == ["1014962", "1013570"] + assert plan.changes["ad_schedule"][0]["day_of_week"] == "MONDAY" + + +class TestApplyCreateCampaignNewParams: + def _build_client(self, num_extra_responses: int): + responses = [ + _FakeMutateOperationResponse( + "campaign_budget_result", "customers/1/campaignBudgets/1" + ), + _FakeMutateOperationResponse( + "campaign_result", "customers/1/campaigns/2" + ), + _FakeMutateOperationResponse( + "ad_group_result", "customers/1/adGroups/3" + ), + ] + [ + _FakeMutateOperationResponse( + "campaign_criterion_result", + f"customers/1/campaignCriteria/2~{i}", + ) + for i in range(num_extra_responses) + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "CampaignService": _FakePathService("campaigns"), + "CampaignBudgetService": _FakePathService("campaignBudgets"), + "AdGroupService": _FakePathService("adGroups"), + } + ) + return client, google_ads + + def test_geo_exclusions_emit_negative_criteria(self): + client, google_ads = self._build_client(num_extra_responses=4) + write._apply_create_campaign( + client, + "1", + { + "campaign_name": "X", + "daily_budget": 10, + "bidding_strategy": "MAXIMIZE_CONVERSIONS", + "channel_type": "SEARCH", + "ad_group_name": "Default", + "geo_target_ids": ["2840"], + "language_ids": ["1000"], + "geo_exclude_ids": ["1014962", "1013570"], + }, + ) + # Operations: budget, campaign, ad_group, geo (1), lang (1), excl (2) = 7 + assert len(google_ads.operations) == 7 + excl_ops = google_ads.operations[5:7] + for op, geo_id in zip(excl_ops, ["1014962", "1013570"]): + crit = op.campaign_criterion_operation.create + assert crit.location.geo_target_constant == f"geoTargetConstants/{geo_id}" + assert crit.negative is True + + def test_ad_schedule_entries_emit_schedule_criteria(self): + client, google_ads = self._build_client(num_extra_responses=4) + write._apply_create_campaign( + client, + "1", + { + "campaign_name": "X", + "daily_budget": 10, + "bidding_strategy": "MAXIMIZE_CONVERSIONS", + "channel_type": "SEARCH", + "ad_group_name": "Default", + "geo_target_ids": ["2840"], + "language_ids": ["1000"], + "ad_schedule": [ + { + "day_of_week": "MONDAY", + "start_hour": 7, + "start_minute": 0, + "end_hour": 18, + "end_minute": 0, + }, + { + "day_of_week": "FRIDAY", + "start_hour": 7, + "start_minute": 0, + "end_hour": 18, + "end_minute": 0, + }, + ], + }, + ) + # budget, campaign, ad_group, geo (1), lang (1), schedule (2) = 7 + assert len(google_ads.operations) == 7 + for op, day in zip( + google_ads.operations[5:7], + [client.enums.DayOfWeekEnum.MONDAY, client.enums.DayOfWeekEnum.FRIDAY], + ): + crit = op.campaign_criterion_operation.create + assert crit.ad_schedule.day_of_week == day + + +# --------------------------------------------------------------------------- +# update_campaign integration with new params +# --------------------------------------------------------------------------- + + +class TestUpdateCampaignNewParams: + def test_geo_exclude_overlap_returns_error(self, config): + result = write.update_campaign( + config, + customer_id="1234567890", + campaign_id="42", + geo_target_ids=["2840"], + geo_exclude_ids=["2840"], + ) + assert result["error"] == "Validation failed" + assert any("overlap" in d for d in result["details"]) + + def test_invalid_ad_schedule_returns_validation_error(self, config): + result = write.update_campaign( + config, + customer_id="1234567890", + campaign_id="42", + ad_schedule=[ + {"day_of_week": "BOGUS", "start_hour": 7, "end_hour": 18} + ], + ) + assert result["error"] == "Validation failed" + + def test_setting_only_geo_exclusions_passes_validation(self, config): + result = write.update_campaign( + config, + customer_id="1234567890", + campaign_id="42", + geo_exclude_ids=["1014962"], + ) + assert "error" not in result + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["geo_exclude_ids"] == ["1014962"] + + def test_setting_only_ad_schedule_passes_validation(self, config): + result = write.update_campaign( + config, + customer_id="1234567890", + campaign_id="42", + ad_schedule=[ + {"day_of_week": "monday", "start_hour": 7, "end_hour": 18}, + ], + ) + assert "error" not in result + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["ad_schedule"][0]["day_of_week"] == "MONDAY" + + def test_empty_list_clears_field(self, config): + result = write.update_campaign( + config, + customer_id="1234567890", + campaign_id="42", + geo_exclude_ids=[], + ) + # An empty list is a valid "clear" instruction — should not error. + assert "error" not in result + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["geo_exclude_ids"] == [] + + +class TestApplyUpdateCampaignNewParams: + def _build_client(self, search_results: list[object] | None = None): + google_ads = _FakeGoogleAdsService( + responses=[ + _FakeMutateOperationResponse( + "campaign_criterion_result", + "customers/1/campaignCriteria/42~rep", + ) + ] + * 8, + search_rows=search_results or [], + ) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "CampaignService": _FakePathService("campaigns"), + } + ) + return client, google_ads + + def test_geo_exclusions_replace_semantics(self): + existing_excl = SimpleNamespace( + campaign_criterion=SimpleNamespace( + resource_name="customers/1/campaignCriteria/42~old" + ) + ) + client, google_ads = self._build_client([existing_excl]) + + write._apply_update_campaign( + client, + "1", + { + "campaign_id": "42", + "geo_exclude_ids": ["1014962", "1013570"], + }, + ) + + # Should have queried for existing negative-location criteria + assert any( + "campaign_criterion.negative = TRUE" in q + for q in google_ads.search_calls + ) + # First op = remove old, then 2 adds + assert google_ads.operations[0].campaign_criterion_operation.remove == ( + "customers/1/campaignCriteria/42~old" + ) + for op, geo_id in zip( + google_ads.operations[1:3], + ["1014962", "1013570"], + ): + crit = op.campaign_criterion_operation.create + assert crit.location.geo_target_constant == ( + f"geoTargetConstants/{geo_id}" + ) + assert crit.negative is True + + def test_ad_schedule_replace_semantics(self): + existing_sched = SimpleNamespace( + campaign_criterion=SimpleNamespace( + resource_name="customers/1/campaignCriteria/42~old" + ) + ) + client, google_ads = self._build_client([existing_sched]) + + write._apply_update_campaign( + client, + "1", + { + "campaign_id": "42", + "ad_schedule": [ + { + "day_of_week": "MONDAY", + "start_hour": 9, + "start_minute": 0, + "end_hour": 17, + "end_minute": 0, + }, + ], + }, + ) + + assert any( + "AD_SCHEDULE" in q for q in google_ads.search_calls + ) + assert google_ads.operations[0].campaign_criterion_operation.remove == ( + "customers/1/campaignCriteria/42~old" + ) + new = google_ads.operations[1].campaign_criterion_operation.create + assert new.ad_schedule.day_of_week == client.enums.DayOfWeekEnum.MONDAY + assert new.ad_schedule.start_hour == 9 + + def test_empty_geo_exclude_ids_only_removes(self): + existing = SimpleNamespace( + campaign_criterion=SimpleNamespace( + resource_name="customers/1/campaignCriteria/42~old" + ) + ) + client, google_ads = self._build_client([existing]) + + write._apply_update_campaign( + client, + "1", + {"campaign_id": "42", "geo_exclude_ids": []}, + ) + # Only the remove op should be present + assert len(google_ads.operations) == 1 + assert google_ads.operations[0].campaign_criterion_operation.remove == ( + "customers/1/campaignCriteria/42~old" + ) + + +# --------------------------------------------------------------------------- +# _populate_ad_schedule_info direct test +# --------------------------------------------------------------------------- + + +class TestPopulateAdScheduleInfo: + def test_populates_all_fields_on_proto(self): + client = _FakeClient( + {"GoogleAdsService": _FakeGoogleAdsService()} + ) + info = client.get_type("AdScheduleInfo") + write._populate_ad_schedule_info( + client, + info, + { + "day_of_week": "FRIDAY", + "start_hour": 9, + "start_minute": 30, + "end_hour": 17, + "end_minute": 45, + }, + ) + assert info.day_of_week == client.enums.DayOfWeekEnum.FRIDAY + assert info.start_hour == 9 + assert info.end_hour == 17 + assert info.start_minute == client.enums.MinuteOfHourEnum.THIRTY + assert info.end_minute == client.enums.MinuteOfHourEnum.FORTY_FIVE + + +# --------------------------------------------------------------------------- +# _execute_plan dispatch coverage for new operations +# --------------------------------------------------------------------------- + + +class TestExecutePlanDispatch: + def test_new_operations_present_in_dispatch(self): + # Static read of source code rather than monkey-patching internals + import inspect + + source = inspect.getsource(write._execute_plan) + for op in ( + "create_call_asset", + "create_location_asset", + "add_ad_schedule", + "add_geo_exclusions", + ): + assert f'"{op}"' in source, f"{op} missing from dispatch" + + +# --------------------------------------------------------------------------- +# Server-tool registration smoke check +# --------------------------------------------------------------------------- + + +class TestServerToolRegistration: + @pytest.fixture + def tools_by_name(self): + import asyncio + + from adloop.server import mcp + + async def _list(): + return await mcp.list_tools() + + tools = asyncio.run(_list()) + return {t.name: t for t in tools} + + def test_new_tools_are_registered(self, tools_by_name): + for name in ( + "draft_call_asset", + "draft_location_asset", + "add_ad_schedule", + "add_geo_exclusions", + ): + assert name in tools_by_name, f"{name} not registered" + + def test_sitelinks_callouts_snippets_make_campaign_id_optional( + self, tools_by_name + ): + for name in ("draft_sitelinks", "draft_callouts", "draft_structured_snippets"): + tool = tools_by_name[name] + required = tool.parameters.get("required", []) + assert "campaign_id" not in required, ( + f"{name} should not require campaign_id" + ) + + def test_draft_campaign_exposes_new_optional_params(self, tools_by_name): + params = tools_by_name["draft_campaign"].parameters["properties"] + assert "geo_exclude_ids" in params + assert "ad_schedule" in params + + def test_update_campaign_exposes_new_optional_params(self, tools_by_name): + params = tools_by_name["update_campaign"].parameters["properties"] + assert "geo_exclude_ids" in params + assert "ad_schedule" in params + + def test_promotion_tools_are_registered(self, tools_by_name): + for name in ("draft_promotion", "update_promotion"): + assert name in tools_by_name, f"{name} not registered" + + def test_link_asset_to_customer_registered(self, tools_by_name): + assert "link_asset_to_customer" in tools_by_name + required = ( + tools_by_name["link_asset_to_customer"].parameters.get("required", []) + ) + assert "links" in required + + def test_draft_promotion_required_params(self, tools_by_name): + required = tools_by_name["draft_promotion"].parameters.get("required", []) + assert "promotion_target" in required + assert "final_url" in required + # money_off / percent_off are mutually exclusive at validation time, + # but neither is required at the schema level (default 0) + assert "money_off" not in required + assert "percent_off" not in required + + def test_update_promotion_requires_asset_id(self, tools_by_name): + required = tools_by_name["update_promotion"].parameters.get("required", []) + assert "asset_id" in required + + +# --------------------------------------------------------------------------- +# _validate_promotion_inputs +# --------------------------------------------------------------------------- + + +class TestValidatePromotionInputs: + def _ok_kwargs(self, **overrides): + defaults = dict( + promotion_target="Window Tint", + final_url="https://example.com/tint", + money_off=100.0, + percent_off=0, + currency_code="USD", + promotion_code="", + orders_over_amount=0, + occasion="", + discount_modifier="", + language_code="en", + start_date="", + end_date="", + redemption_start_date="", + redemption_end_date="", + ad_schedule=None, + ) + defaults.update(overrides) + return defaults + + def _patched(self, monkeypatch): + # Stub URL validation so unit tests don't hit the network + monkeypatch.setattr( + write, + "_validate_urls", + lambda urls, timeout=10: {u: None for u in urls}, + ) + + def test_happy_path_money_off(self, monkeypatch): + self._patched(monkeypatch) + normalized, errors = write._validate_promotion_inputs(**self._ok_kwargs()) + assert errors == [] + assert normalized["money_off"] == 100.0 + assert normalized["percent_off"] == 0.0 + assert normalized["currency_code"] == "USD" + assert normalized["language_code"] == "en" + + def test_happy_path_percent_off(self, monkeypatch): + self._patched(monkeypatch) + normalized, errors = write._validate_promotion_inputs( + **self._ok_kwargs(money_off=0, percent_off=15.0) + ) + assert errors == [] + assert normalized["percent_off"] == 15.0 + assert normalized["money_off"] == 0.0 + + def test_promotion_target_required(self, monkeypatch): + self._patched(monkeypatch) + _, errors = write._validate_promotion_inputs( + **self._ok_kwargs(promotion_target="") + ) + assert any("promotion_target is required" in e for e in errors) + + def test_promotion_target_max_20_chars(self, monkeypatch): + self._patched(monkeypatch) + long_target = "A" * 21 + _, errors = write._validate_promotion_inputs( + **self._ok_kwargs(promotion_target=long_target) + ) + assert any("max 20" in e for e in errors) + + def test_final_url_required(self, monkeypatch): + self._patched(monkeypatch) + _, errors = write._validate_promotion_inputs( + **self._ok_kwargs(final_url="") + ) + assert any("final_url is required" in e for e in errors) + + def test_money_and_percent_both_set_rejected(self, monkeypatch): + self._patched(monkeypatch) + _, errors = write._validate_promotion_inputs( + **self._ok_kwargs(money_off=10, percent_off=5) + ) + assert any("exactly one of money_off or percent_off" in e for e in errors) + + def test_neither_money_nor_percent_rejected(self, monkeypatch): + self._patched(monkeypatch) + _, errors = write._validate_promotion_inputs( + **self._ok_kwargs(money_off=0, percent_off=0) + ) + assert any("One of money_off or percent_off" in e for e in errors) + + def test_percent_off_out_of_range_rejected(self, monkeypatch): + self._patched(monkeypatch) + _, errors = write._validate_promotion_inputs( + **self._ok_kwargs(money_off=0, percent_off=150) + ) + assert any("must be in (0, 100]" in e for e in errors) + + def test_promotion_code_max_15_chars(self, monkeypatch): + self._patched(monkeypatch) + _, errors = write._validate_promotion_inputs( + **self._ok_kwargs(promotion_code="A" * 16) + ) + assert any("max 15" in e for e in errors) + + def test_invalid_occasion_rejected(self, monkeypatch): + self._patched(monkeypatch) + _, errors = write._validate_promotion_inputs( + **self._ok_kwargs(occasion="MARDI_GRAS") + ) + assert any("occasion 'MARDI_GRAS' invalid" in e for e in errors) + + def test_valid_occasion_normalized_uppercase(self, monkeypatch): + self._patched(monkeypatch) + normalized, errors = write._validate_promotion_inputs( + **self._ok_kwargs(occasion="black_friday") + ) + assert errors == [] + assert normalized["occasion"] == "BLACK_FRIDAY" + + def test_invalid_discount_modifier_rejected(self, monkeypatch): + self._patched(monkeypatch) + _, errors = write._validate_promotion_inputs( + **self._ok_kwargs(discount_modifier="MORE_THAN") + ) + assert any("discount_modifier 'MORE_THAN' invalid" in e for e in errors) + + def test_valid_discount_modifier_up_to(self, monkeypatch): + self._patched(monkeypatch) + normalized, errors = write._validate_promotion_inputs( + **self._ok_kwargs(discount_modifier="up_to") + ) + assert errors == [] + assert normalized["discount_modifier"] == "UP_TO" + + def test_bad_date_format_rejected(self, monkeypatch): + self._patched(monkeypatch) + _, errors = write._validate_promotion_inputs( + **self._ok_kwargs(start_date="01/01/2026") + ) + assert any("start_date '01/01/2026' must be YYYY-MM-DD" in e for e in errors) + + def test_iso_date_accepted(self, monkeypatch): + self._patched(monkeypatch) + normalized, errors = write._validate_promotion_inputs( + **self._ok_kwargs(start_date="2026-01-01", end_date="2026-12-31") + ) + assert errors == [] + assert normalized["start_date"] == "2026-01-01" + assert normalized["end_date"] == "2026-12-31" + + def test_unreachable_url_rejected(self, monkeypatch): + # Force URL check to fail + monkeypatch.setattr( + write, + "_validate_urls", + lambda urls, timeout=10: {u: "Connection refused" for u in urls}, + ) + _, errors = write._validate_promotion_inputs(**self._ok_kwargs()) + assert any("not reachable" in e for e in errors) + + def test_promotion_code_and_orders_over_amount_mutually_exclusive(self, monkeypatch): + self._patched(monkeypatch) + _, errors = write._validate_promotion_inputs( + **self._ok_kwargs(promotion_code="SAVE10", orders_over_amount=500) + ) + assert any("mutually exclusive" in e for e in errors) + + def test_orders_over_amount_alone_is_fine(self, monkeypatch): + self._patched(monkeypatch) + normalized, errors = write._validate_promotion_inputs( + **self._ok_kwargs(orders_over_amount=500) + ) + assert errors == [] + assert normalized["orders_over_amount"] == 500.0 + + def test_ad_schedule_validation_propagates(self, monkeypatch): + self._patched(monkeypatch) + _, errors = write._validate_promotion_inputs( + **self._ok_kwargs( + ad_schedule=[{"day_of_week": "MARTES", "start_hour": 8, "end_hour": 17}] + ) + ) + assert any("day_of_week" in e for e in errors) + + +# --------------------------------------------------------------------------- +# draft_promotion +# --------------------------------------------------------------------------- + + +class TestDraftPromotion: + @pytest.fixture(autouse=True) + def _stub_urls(self, monkeypatch): + monkeypatch.setattr( + write, + "_validate_urls", + lambda urls, timeout=10: {u: None for u in urls}, + ) + + def test_customer_scope_when_no_campaign_id(self, config): + result = write.draft_promotion( + config, + customer_id="1234567890", + promotion_target="Window Tint", + final_url="https://example.com/tint", + money_off=100, + ) + assert "error" not in result + assert result["entity_type"] == "customer_asset" + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["scope"] == "customer" + assert plan.changes["campaign_id"] == "" + assert plan.changes["promotion"]["money_off"] == 100.0 + assert plan.changes["promotion"]["promotion_target"] == "Window Tint" + + def test_campaign_scope_when_campaign_id(self, config): + result = write.draft_promotion( + config, + customer_id="1234567890", + campaign_id="42", + promotion_target="Full Front PPF", + final_url="https://example.com/ppf", + money_off=301, + ) + assert result["entity_type"] == "campaign_asset" + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["scope"] == "campaign" + assert plan.changes["campaign_id"] == "42" + + def test_validation_failure_returns_error_dict(self, config): + result = write.draft_promotion( + config, + customer_id="1234567890", + promotion_target="", # required + final_url="https://example.com/tint", + money_off=100, + ) + assert result.get("error") == "Validation failed" + assert any("promotion_target" in d for d in result["details"]) + + def test_percent_off_path(self, config): + result = write.draft_promotion( + config, + customer_id="1234567890", + promotion_target="Spring Sale", + final_url="https://example.com/sale", + percent_off=20, + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["promotion"]["percent_off"] == 20.0 + assert plan.changes["promotion"]["money_off"] == 0.0 + + +# --------------------------------------------------------------------------- +# update_promotion +# --------------------------------------------------------------------------- + + +class TestUpdatePromotion: + @pytest.fixture(autouse=True) + def _stub_urls(self, monkeypatch): + monkeypatch.setattr( + write, + "_validate_urls", + lambda urls, timeout=10: {u: None for u in urls}, + ) + + def test_requires_asset_id(self, config): + result = write.update_promotion( + config, + customer_id="1234567890", + asset_id="", + promotion_target="Window Tint", + final_url="https://example.com/tint", + money_off=100, + ) + assert "asset_id is required" in result["error"] + + def test_emits_swap_plan_with_old_asset_id(self, config): + result = write.update_promotion( + config, + customer_id="1234567890", + campaign_id="42", + asset_id="55555", + promotion_target="Full Front PPF", + final_url="https://example.com/ppf", + money_off=399, + ) + assert "error" not in result + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.operation == "update_promotion" + assert plan.changes["old_asset_id"] == "55555" + assert plan.changes["scope"] == "campaign" + + def test_swap_warning_explains_orphaned_asset(self, config): + result = write.update_promotion( + config, + customer_id="1234567890", + asset_id="55555", + promotion_target="Tint", + final_url="https://example.com/x", + money_off=10, + ) + warnings = result.get("warnings", []) + assert any("orphaned" in w.lower() for w in warnings) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["old_asset_id"] == "55555" + # remove_old_asset is no longer a supported field + assert "remove_old_asset" not in plan.changes + + +# --------------------------------------------------------------------------- +# _apply_create_promotion / _populate_promotion_asset +# --------------------------------------------------------------------------- + + +class TestApplyCreatePromotion: + def test_customer_scope_emits_customer_asset_with_promotion_field_type(self): + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "customer_asset_result", "customers/1/customerAssets/1~PROMOTION" + ), + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + } + ) + + write._apply_create_promotion( + client, + "1", + { + "scope": "customer", + "campaign_id": "", + "promotion": { + "promotion_target": "Tint", + "final_url": "https://example.com/tint", + "money_off": 100.0, + "percent_off": 0.0, + "currency_code": "USD", + "promotion_code": "", + "orders_over_amount": 0, + "occasion": "", + "discount_modifier": "", + "language_code": "en", + "start_date": "", + "end_date": "", + "redemption_start_date": "", + "redemption_end_date": "", + "ad_schedule": [], + }, + }, + ) + + assert len(google_ads.operations) == 2 + asset_op = google_ads.operations[0].asset_operation.create + assert asset_op.promotion_asset.promotion_target == "Tint" + assert asset_op.promotion_asset.money_amount_off.amount_micros == 100_000_000 + assert asset_op.promotion_asset.money_amount_off.currency_code == "USD" + assert "https://example.com/tint" in list(asset_op.final_urls) + + link_op = google_ads.operations[1].customer_asset_operation.create + assert link_op.field_type == client.enums.AssetFieldTypeEnum.PROMOTION + + def test_campaign_scope_emits_campaign_asset(self): + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "campaign_asset_result", "customers/1/campaignAssets/42~PROMOTION" + ), + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + } + ) + + write._apply_create_promotion( + client, + "1", + { + "scope": "campaign", + "campaign_id": "42", + "promotion": { + "promotion_target": "PPF", + "final_url": "https://example.com/ppf", + "money_off": 0, + "percent_off": 25.0, + "currency_code": "USD", + "promotion_code": "BGI25", + "orders_over_amount": 0, + "occasion": "BLACK_FRIDAY", + "discount_modifier": "UP_TO", + "language_code": "en", + "start_date": "2026-11-25", + "end_date": "2026-11-30", + "redemption_start_date": "", + "redemption_end_date": "", + "ad_schedule": [], + }, + }, + ) + + asset_op = google_ads.operations[0].asset_operation.create + # percent_off path — micros encoded + assert asset_op.promotion_asset.percent_off == 25_000_000 + assert asset_op.promotion_asset.promotion_code == "BGI25" + # orders_over_amount and promotion_code are a oneof — only code set here + assert asset_op.promotion_asset.start_date == "2026-11-25" + assert asset_op.promotion_asset.end_date == "2026-11-30" + assert ( + asset_op.promotion_asset.occasion + == client.enums.PromotionExtensionOccasionEnum.BLACK_FRIDAY + ) + assert ( + asset_op.promotion_asset.discount_modifier + == client.enums.PromotionExtensionDiscountModifierEnum.UP_TO + ) + + link_op = google_ads.operations[1].campaign_asset_operation.create + assert link_op.field_type == client.enums.AssetFieldTypeEnum.PROMOTION + + +# --------------------------------------------------------------------------- +# _apply_update_promotion (swap) +# --------------------------------------------------------------------------- + + +class _FakeCampaignAssetService(_FakePathService): + def __init__(self, responses: list[_FakeResult] | None = None): + super().__init__("campaignAssets") + self.operations = None + self._responses = responses or [] + + def mutate_campaign_assets( + self, customer_id: str, operations: list[object] + ) -> object: + self.operations = operations + return SimpleNamespace(results=self._responses) + + +class _FakeCustomerAssetService(_FakePathService): + def __init__(self, responses: list[_FakeResult] | None = None): + super().__init__("customerAssets") + self.operations = None + self._responses = responses or [] + + def mutate_customer_assets( + self, customer_id: str, operations: list[object] + ) -> object: + self.operations = operations + return SimpleNamespace(results=self._responses) + + +class TestApplyUpdatePromotion: + def _promo(self, **overrides): + base = { + "promotion_target": "PPF", + "final_url": "https://example.com/ppf", + "money_off": 200.0, + "percent_off": 0, + "currency_code": "USD", + "promotion_code": "", + "orders_over_amount": 0, + "occasion": "", + "discount_modifier": "", + "language_code": "en", + "start_date": "", + "end_date": "", + "redemption_start_date": "", + "redemption_end_date": "", + "ad_schedule": [], + } + base.update(overrides) + return base + + def test_campaign_swap_creates_new_links_unlinks_old(self): + # Old link found via search + search_row = SimpleNamespace( + campaign_asset=SimpleNamespace( + resource_name="customers/1/campaignAssets/42~99~PROMOTION" + ) + ) + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "campaign_asset_result", "customers/1/campaignAssets/42~999~PROMOTION" + ), + ] + google_ads = _FakeGoogleAdsService(responses, search_rows=[search_row]) + ca_service = _FakeCampaignAssetService([_FakeResult("removed")]) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + "CampaignAssetService": ca_service, + } + ) + + result = write._apply_update_promotion( + client, + "1", + { + "scope": "campaign", + "campaign_id": "42", + "old_asset_id": "99", + "promotion": self._promo(), + }, + ) + + # Step 1+2: create + link in one mutate call + assert len(google_ads.operations) == 2 + # Step 3: unlink old + assert ca_service.operations is not None + assert len(ca_service.operations) == 1 + assert ( + ca_service.operations[0].remove + == "customers/1/campaignAssets/42~99~PROMOTION" + ) + assert result["new_asset"] == "customers/1/assets/-1" + assert result["old_link_removed"] == "customers/1/campaignAssets/42~99~PROMOTION" + + def test_customer_swap_uses_customer_asset_service(self): + search_row = SimpleNamespace( + customer_asset=SimpleNamespace( + resource_name="customers/1/customerAssets/99~PROMOTION" + ) + ) + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "customer_asset_result", "customers/1/customerAssets/-1~PROMOTION" + ), + ] + google_ads = _FakeGoogleAdsService(responses, search_rows=[search_row]) + cust_service = _FakeCustomerAssetService([_FakeResult("removed")]) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + "CustomerAssetService": cust_service, + } + ) + + write._apply_update_promotion( + client, + "1", + { + "scope": "customer", + "campaign_id": "", + "old_asset_id": "99", + "promotion": self._promo(), + }, + ) + + # Customer-level link in step 2 + link_op = google_ads.operations[1].customer_asset_operation.create + assert link_op.field_type == client.enums.AssetFieldTypeEnum.PROMOTION + # Old customer-level link removed + assert cust_service.operations is not None + assert ( + cust_service.operations[0].remove + == "customers/1/customerAssets/99~PROMOTION" + ) + + def test_swap_when_old_link_not_found_skips_unlink(self): + # Empty search rows = no old link found + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "campaign_asset_result", "customers/1/campaignAssets/42~999~PROMOTION" + ), + ] + google_ads = _FakeGoogleAdsService(responses, search_rows=[]) + ca_service = _FakeCampaignAssetService() + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + "CampaignAssetService": ca_service, + } + ) + + result = write._apply_update_promotion( + client, + "1", + { + "scope": "campaign", + "campaign_id": "42", + "old_asset_id": "99", + "promotion": self._promo(), + }, + ) + + # Step 3 is no-op when old link not found + assert ca_service.operations is None + assert result["old_link_removed"] == "" + assert result["new_asset"] == "customers/1/assets/-1" + + def test_unknown_scope_raises(self): + client = _FakeClient( + { + "GoogleAdsService": _FakeGoogleAdsService(), + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + } + ) + with pytest.raises(ValueError, match="Unknown scope"): + write._apply_update_promotion( + client, + "1", + { + "scope": "bogus", + "campaign_id": "", + "old_asset_id": "99", + "promotion": self._promo(), + }, + ) + + +# --------------------------------------------------------------------------- +# Dispatch wiring +# --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# link_asset_to_customer +# --------------------------------------------------------------------------- + + +class TestLinkAssetToCustomer: + def test_validation_rejects_unknown_field_type(self, config): + result = write.link_asset_to_customer( + config, + customer_id="1234567890", + links=[{"asset_id": "12345", "field_type": "NONSENSE"}], + ) + assert result.get("error") == "Validation failed" + assert any("NONSENSE" in d for d in result["details"]) + + def test_validation_rejects_non_numeric_asset_id(self, config): + result = write.link_asset_to_customer( + config, + customer_id="1234567890", + links=[{"asset_id": "abc", "field_type": "BUSINESS_LOGO"}], + ) + assert result.get("error") == "Validation failed" + assert any("must be numeric" in d for d in result["details"]) + + def test_validation_requires_asset_id(self, config): + result = write.link_asset_to_customer( + config, + customer_id="1234567890", + links=[{"asset_id": "", "field_type": "AD_IMAGE"}], + ) + assert result.get("error") == "Validation failed" + assert any("asset_id is required" in d for d in result["details"]) + + def test_validation_requires_field_type(self, config): + result = write.link_asset_to_customer( + config, + customer_id="1234567890", + links=[{"asset_id": "12345", "field_type": ""}], + ) + assert result.get("error") == "Validation failed" + assert any("field_type is required" in d for d in result["details"]) + + def test_empty_links_rejected(self, config): + result = write.link_asset_to_customer( + config, customer_id="1234567890", links=[] + ) + assert "At least one link is required" in result["error"] + + def test_happy_path_emits_customer_asset_plan(self, config): + result = write.link_asset_to_customer( + config, + customer_id="1234567890", + links=[ + {"asset_id": "120726490775", "field_type": "BUSINESS_LOGO"}, + {"asset_id": "200848497279", "field_type": "AD_IMAGE"}, + ], + ) + assert "error" not in result + assert result["entity_type"] == "customer_asset" + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.operation == "link_asset_to_customer" + assert len(plan.changes["links"]) == 2 + assert plan.changes["links"][0]["field_type"] == "BUSINESS_LOGO" + + def test_apply_creates_customer_asset_operations(self): + cust_service = _FakeCustomerAssetService( + [_FakeResult("customers/1/customerAssets/120726490775~BUSINESS_LOGO"), + _FakeResult("customers/1/customerAssets/200848497279~AD_IMAGE")] + ) + client = _FakeClient( + { + "GoogleAdsService": _FakeGoogleAdsService(), + "AssetService": _FakePathService("assets"), + "CustomerAssetService": cust_service, + } + ) + + result = write._apply_link_asset_to_customer( + client, + "1", + { + "links": [ + {"asset_id": "120726490775", "field_type": "BUSINESS_LOGO"}, + {"asset_id": "200848497279", "field_type": "AD_IMAGE"}, + ] + }, + ) + + assert cust_service.operations is not None + assert len(cust_service.operations) == 2 + # First op: BUSINESS_LOGO link + op0 = cust_service.operations[0].create + assert op0.asset == "customers/1/assets/120726490775" + assert op0.field_type == client.enums.AssetFieldTypeEnum.BUSINESS_LOGO + # Second op: AD_IMAGE link + op1 = cust_service.operations[1].create + assert op1.asset == "customers/1/assets/200848497279" + assert op1.field_type == client.enums.AssetFieldTypeEnum.AD_IMAGE + + assert result["linked_count"] == 2 + assert len(result["customer_assets"]) == 2 + + +class TestPromotionDispatchWired: + def test_create_and_update_promotion_in_dispatch(self): + # confirm_and_apply uses an internal dispatch dict — exercise it via + # a dry-run roundtrip on each operation. The handlers themselves + # are tested above; here we verify the names are wired. + from adloop.safety import preview as ps + + # Build a fake plan and put it in the store, then ensure + # _execute_plan finds the correct dispatch entry. We can't easily + # invoke confirm_and_apply (needs a real Ads client), but we can + # introspect the dispatch mapping by reaching into _execute_plan's + # source to confirm the keys exist. + import inspect + + src = inspect.getsource(write._execute_plan) + assert '"create_promotion": _apply_create_promotion' in src + assert '"update_promotion": _apply_update_promotion' in src + + +# --------------------------------------------------------------------------- +# Asset in-place updates: update_call_asset, update_sitelink, update_callout +# --------------------------------------------------------------------------- + + +class TestUpdateCallAsset: + def test_asset_id_required(self, config): + result = write.update_call_asset(config, customer_id="1", asset_id="") + assert "asset_id is required" in result["error"] + + def test_no_fields_to_update_rejected(self, config): + result = write.update_call_asset( + config, customer_id="1", asset_id="357825439813" + ) + assert "No fields to update" in result["error"] + + def test_invalid_reporting_state(self, config): + result = write.update_call_asset( + config, + customer_id="1", + asset_id="357825439813", + call_conversion_reporting_state="WRONG", + ) + assert result["error"] == "Validation failed" + + def test_phone_normalized(self, config): + result = write.update_call_asset( + config, + customer_id="1", + asset_id="357825439813", + phone_number="(916) 460-9257", + country_code="US", + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["phone_number"] == "+19164609257" + assert plan.changes["country_code"] == "US" + + def test_repoint_to_conversion_action(self, config): + result = write.update_call_asset( + config, + customer_id="1", + asset_id="357825439813", + call_conversion_action_id="6797442210", + call_conversion_reporting_state="USE_RESOURCE_LEVEL_CALL_CONVERSION_ACTION", + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["call_conversion_action_id"] == "6797442210" + assert ( + plan.changes["call_conversion_reporting_state"] + == "USE_RESOURCE_LEVEL_CALL_CONVERSION_ACTION" + ) + + +class _FakeAssetService(_FakePathService): + def __init__(self, responses=None): + super().__init__("assets") + self.operations = None + self._responses = responses or [] + + def mutate_assets(self, customer_id: str, operations: list) -> object: + self.operations = operations + return SimpleNamespace(results=self._responses) + + +class _FakeConversionActionService(_FakePathService): + def __init__(self): + super().__init__("conversionActions") + + +class TestApplyUpdateCallAsset: + def test_emits_field_mask_for_specified_fields_only(self): + a_svc = _FakeAssetService( + [_FakeResult("customers/1/assets/357825439813")] + ) + client = _FakeClient( + { + "AssetService": a_svc, + "ConversionActionService": _FakeConversionActionService(), + } + ) + write._apply_update_call_asset( + client, + "1", + { + "asset_id": "357825439813", + "call_conversion_action_id": "6797442210", + "call_conversion_reporting_state": "USE_RESOURCE_LEVEL_CALL_CONVERSION_ACTION", + }, + ) + + op = a_svc.operations[0] + asset = op.update + assert asset.resource_name == "customers/1/assets/357825439813" + assert asset.call_asset.call_conversion_action == "customers/1/conversionActions/6797442210" + assert ( + asset.call_asset.call_conversion_reporting_state + == client.enums.CallConversionReportingStateEnum.USE_RESOURCE_LEVEL_CALL_CONVERSION_ACTION + ) + mask = list(op.update_mask.paths) + assert "call_asset.call_conversion_action" in mask + assert "call_asset.call_conversion_reporting_state" in mask + assert "call_asset.phone_number" not in mask + + +class TestUpdateSitelink: + @pytest.fixture(autouse=True) + def _stub_urls(self, monkeypatch): + monkeypatch.setattr( + write, "_validate_urls", lambda urls, timeout=10: {u: None for u in urls} + ) + + def test_asset_id_required(self, config): + result = write.update_sitelink(config, customer_id="1", asset_id="") + assert "asset_id is required" in result["error"] + + def test_link_text_max_25_chars(self, config): + result = write.update_sitelink( + config, + customer_id="1", + asset_id="357825455476", + link_text="A" * 26, + ) + assert result["error"] == "Validation failed" + assert any("max 25" in d for d in result["details"]) + + def test_description1_max_35(self, config): + result = write.update_sitelink( + config, + customer_id="1", + asset_id="357825455476", + description1="X" * 36, + ) + assert result["error"] == "Validation failed" + assert any("description1" in d for d in result["details"]) + + def test_no_fields_to_update_rejected(self, config): + result = write.update_sitelink( + config, customer_id="1", asset_id="357825455476" + ) + assert "No fields to update" in result["error"] + + def test_partial_update_persists(self, config): + result = write.update_sitelink( + config, + customer_id="1", + asset_id="357825455476", + description1="Premium ceramic from $299", + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["description1"] == "Premium ceramic from $299" + assert "link_text" not in plan.changes + + +class TestApplyUpdateSitelink: + def test_emits_field_mask(self): + a_svc = _FakeAssetService( + [_FakeResult("customers/1/assets/357825455476")] + ) + client = _FakeClient({"AssetService": a_svc}) + write._apply_update_sitelink( + client, + "1", + { + "asset_id": "357825455476", + "link_text": "Auto Window Tint", + "description1": "Premium ceramic from $299", + }, + ) + op = a_svc.operations[0] + asset = op.update + assert asset.sitelink_asset.link_text == "Auto Window Tint" + assert asset.sitelink_asset.description1 == "Premium ceramic from $299" + mask = list(op.update_mask.paths) + assert "sitelink_asset.link_text" in mask + assert "sitelink_asset.description1" in mask + assert "sitelink_asset.description2" not in mask + + +class TestUpdateCallout: + def test_asset_id_required(self, config): + result = write.update_callout( + config, customer_id="1", asset_id="", callout_text="Free Snacks" + ) + assert "asset_id is required" in result["error"] + + def test_callout_text_required(self, config): + result = write.update_callout( + config, customer_id="1", asset_id="123", callout_text=" " + ) + assert "callout_text is required" in result["error"] + + def test_max_25_chars(self, config): + result = write.update_callout( + config, + customer_id="1", + asset_id="123", + callout_text="A" * 26, + ) + assert result["error"] == "Validation failed" + + def test_happy_path(self, config): + result = write.update_callout( + config, + customer_id="1", + asset_id="357825439780", + callout_text="Free Snacks & Lounge", + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["callout_text"] == "Free Snacks & Lounge" + + +class TestApplyUpdateCallout: + def test_emits_minimal_field_mask(self): + a_svc = _FakeAssetService( + [_FakeResult("customers/1/assets/357825439780")] + ) + client = _FakeClient({"AssetService": a_svc}) + write._apply_update_callout( + client, + "1", + {"asset_id": "357825439780", "callout_text": "Free Snacks & Lounge"}, + ) + op = a_svc.operations[0] + asset = op.update + assert asset.callout_asset.callout_text == "Free Snacks & Lounge" + assert list(op.update_mask.paths) == ["callout_asset.callout_text"] + + +# --------------------------------------------------------------------------- +# MCP registration + dispatch wiring (asset updates + conversion actions) +# --------------------------------------------------------------------------- + + +class TestNewToolsRegistered: + @pytest.fixture(scope="class") + def tools_by_name(self): + import asyncio + from adloop.server import mcp + + async def _list(): + return await mcp.list_tools() + + tools = asyncio.run(_list()) + return {t.name: t for t in tools} + + def test_asset_update_and_conversion_tools_registered(self, tools_by_name): + for name in ( + # conversion actions + "draft_create_conversion_action", + "draft_update_conversion_action", + "draft_remove_conversion_action", + # asset updates + "update_call_asset", + "update_sitelink", + "update_callout", + ): + assert name in tools_by_name, f"{name} not registered" + + def test_dispatch_routes_asset_and_conversion_ops(self): + import inspect + + src = inspect.getsource(write._execute_plan) + # asset update ops + assert '"update_call_asset": _apply_update_call_asset' in src + assert '"update_sitelink": _apply_update_sitelink' in src + assert '"update_callout": _apply_update_callout' in src + # conversion-action ops + assert '"create_conversion_action"' in src + assert '"update_conversion_action"' in src + assert '"remove_conversion_action"' in src + + +# --------------------------------------------------------------------------- +# Image asset field-type detection + customer-scope refactor +# --------------------------------------------------------------------------- + + +class TestDetectImageFieldType: + @pytest.mark.parametrize( + "width,height,name,expected", + [ + (1200, 1200, "team-square", "SQUARE_MARKETING_IMAGE"), + (1088, 1088, "team-square", "SQUARE_MARKETING_IMAGE"), + (1024, 1024, "logo-square", "BUSINESS_LOGO"), + (1200, 1200, "company-logo", "BUSINESS_LOGO"), + (1200, 628, "marketing-hero", "MARKETING_IMAGE"), + (1200, 627, "marketing-hero", "MARKETING_IMAGE"), + (1200, 300, "wordmark-logo", "LANDSCAPE_LOGO"), + (480, 600, "vertical-photo", "PORTRAIT_MARKETING_IMAGE"), + (480, 800, "tall-portrait", "TALL_PORTRAIT_MARKETING_IMAGE"), + (1500, 800, "wide-photo", "MARKETING_IMAGE"), + (1300, 1000, "near-square", "MARKETING_IMAGE"), + ], + ) + def test_aspect_ratio_picks_field_type(self, width, height, name, expected): + result = write._detect_image_field_type({ + "width": width, "height": height, "name": name, "path": f"{name}.jpg", + }) + assert result == expected + + def test_explicit_field_type_overrides_detection(self): + result = write._detect_image_field_type({ + "width": 1200, "height": 628, "name": "x", + "field_type": "SQUARE_MARKETING_IMAGE", + }) + assert result == "SQUARE_MARKETING_IMAGE" + + def test_explicit_invalid_field_type_raises(self): + with pytest.raises(ValueError, match="not a supported"): + write._detect_image_field_type({ + "width": 1200, "height": 628, "field_type": "AD_IMAGE", + }) + + def test_zero_dims_returns_marketing_image(self): + assert ( + write._detect_image_field_type({"width": 0, "height": 0}) + == "MARKETING_IMAGE" + ) + + def test_filename_logo_hint_only_applies_to_logo_friendly_ratios(self): + # Wide non-4:1 image with 'logo' in name should NOT become LANDSCAPE_LOGO + result = write._detect_image_field_type({ + "width": 1200, "height": 628, "name": "company-logo-hero", + }) + assert result == "MARKETING_IMAGE" + + +class TestApplyCreateImageAssets: + def _png_path(self, tmp_path): + # Tiny 1x1 PNG so the apply layer can read bytes + import base64 + + p = tmp_path / "tiny.png" + p.write_bytes( + base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2ZfZ0AAAAASUVORK5CYII=" + ) + ) + return str(p) + + def test_campaign_scope_uses_marketing_image_for_landscape(self, tmp_path): + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "campaign_asset_result", + "customers/1/campaignAssets/42~MARKETING_IMAGE", + ), + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + } + ) + + write._apply_create_image_assets( + client, + "1", + { + "scope": "campaign", + "campaign_id": "42", + "images": [ + { + "path": self._png_path(tmp_path), + "name": "marketing-hero", + "mime_type": "image/png", + "width": 1200, + "height": 628, + } + ], + }, + ) + link = google_ads.operations[1].campaign_asset_operation.create + assert link.field_type == client.enums.AssetFieldTypeEnum.MARKETING_IMAGE + assert link.campaign == "customers/1/campaigns/42" + + def test_customer_scope_uses_business_logo_for_logo_named_square(self, tmp_path): + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "customer_asset_result", "customers/1/customerAssets/-1~BUSINESS_LOGO" + ), + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + } + ) + + write._apply_create_image_assets( + client, + "1", + { + "scope": "customer", + "campaign_id": "", + "images": [ + { + "path": self._png_path(tmp_path), + "name": "logo-square", + "mime_type": "image/png", + "width": 1024, + "height": 1024, + } + ], + }, + ) + link = google_ads.operations[1].customer_asset_operation.create + assert link.field_type == client.enums.AssetFieldTypeEnum.BUSINESS_LOGO + # Should not have populated campaign_asset_operation + assert ( + google_ads.operations[1].campaign_asset_operation.create.field_type + == client.enums.AssetFieldTypeEnum.UNSPECIFIED + ) + + def test_explicit_field_type_override(self, tmp_path): + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "customer_asset_result", "customers/1/customerAssets/-1~LOGO" + ), + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + } + ) + + write._apply_create_image_assets( + client, + "1", + { + "scope": "customer", + "campaign_id": "", + "images": [ + { + "path": self._png_path(tmp_path), + "name": "weird-asset", + "mime_type": "image/png", + "width": 1200, + "height": 628, + "field_type": "LOGO", + } + ], + }, + ) + link = google_ads.operations[1].customer_asset_operation.create + assert link.field_type == client.enums.AssetFieldTypeEnum.LOGO + + def test_apply_rejects_unknown_scope(self, tmp_path): + client = _FakeClient( + { + "GoogleAdsService": _FakeGoogleAdsService(), + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + } + ) + with pytest.raises(ValueError, match="Unknown asset scope"): + write._apply_create_image_assets( + client, + "1", + { + "scope": "bogus", + "campaign_id": "", + "images": [ + { + "path": self._png_path(tmp_path), + "name": "x", + "mime_type": "image/png", + "width": 1200, + "height": 628, + } + ], + }, + ) + + def test_apply_campaign_scope_requires_campaign_id(self, tmp_path): + client = _FakeClient( + { + "GoogleAdsService": _FakeGoogleAdsService(), + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + } + ) + with pytest.raises(ValueError, match="campaign_id is required"): + write._apply_create_image_assets( + client, + "1", + { + "scope": "campaign", + "campaign_id": "", + "images": [ + { + "path": self._png_path(tmp_path), + "name": "x", + "mime_type": "image/png", + "width": 1200, + "height": 628, + } + ], + }, + ) + + +class TestDraftImageAssets: + def _png_path(self, tmp_path): + import base64 + + p = tmp_path / "tiny.png" + p.write_bytes( + base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2ZfZ0AAAAASUVORK5CYII=" + ) + ) + return str(p) + + def test_no_campaign_id_uses_customer_scope(self, config, tmp_path): + result = write.draft_image_assets( + config, + customer_id="1234567890", + image_paths=[self._png_path(tmp_path)], + ) + assert "error" not in result + assert result["entity_type"] == "customer_asset" + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["scope"] == "customer" + # Detection ran and stored the resolved field type in the preview + assert plan.changes["images"][0]["resolved_field_type"] in { + "BUSINESS_LOGO", + "SQUARE_MARKETING_IMAGE", + "MARKETING_IMAGE", + "PORTRAIT_MARKETING_IMAGE", + "TALL_PORTRAIT_MARKETING_IMAGE", + "LOGO", + "LANDSCAPE_LOGO", + } + + def test_field_types_length_mismatch(self, config, tmp_path): + result = write.draft_image_assets( + config, + customer_id="1234567890", + image_paths=[self._png_path(tmp_path)], + field_types=["MARKETING_IMAGE", "BUSINESS_LOGO"], + ) + assert result["error"] == "Validation failed" + assert any("field_types has 2" in d for d in result["details"]) + + def test_invalid_override_field_type_rejected(self, config, tmp_path): + result = write.draft_image_assets( + config, + customer_id="1234567890", + image_paths=[self._png_path(tmp_path)], + field_types=["AD_IMAGE"], # AD_IMAGE not allowed for direct linking + ) + assert result["error"] == "Validation failed" + assert any("not a supported" in d for d in result["details"]) + + +# --------------------------------------------------------------------------- +# draft_business_name_asset +# --------------------------------------------------------------------------- + + +class TestDraftBusinessNameAsset: + def test_requires_business_name(self, config): + result = write.draft_business_name_asset(config, customer_id="1") + assert "business_name is required" in result["error"] + + def test_max_25_chars(self, config): + result = write.draft_business_name_asset( + config, + customer_id="1", + business_name="X" * 26, + ) + assert result["error"] == "Validation failed" + assert any("25" in d for d in result["details"]) + + def test_customer_scope_default(self, config): + result = write.draft_business_name_asset( + config, + customer_id="1", + business_name="Modern Waste Solutions", # 22 chars + ) + assert "error" not in result + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["scope"] == "customer" + assert plan.changes["business_name"] == "Modern Waste Solutions" + + def test_campaign_scope_when_campaign_id_provided(self, config): + result = write.draft_business_name_asset( + config, + customer_id="1", + campaign_id="42", + business_name="MWS", + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["scope"] == "campaign" + assert plan.changes["campaign_id"] == "42" + + +class TestApplyCreateBusinessNameAsset: + def test_customer_scope_creates_text_asset_and_customer_link(self): + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "customer_asset_result", "customers/1/customerAssets/-1~BUSINESS_NAME" + ), + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + } + ) + + result = write._apply_create_business_name_asset( + client, + "1", + { + "scope": "customer", + "campaign_id": "", + "business_name": "Modern Waste Solutions", + }, + ) + + # Asset op: TEXT type with the business name + asset_op = google_ads.operations[0].asset_operation.create + assert asset_op.type_ == client.enums.AssetTypeEnum.TEXT + assert asset_op.text_asset.text == "Modern Waste Solutions" + # Link op: BUSINESS_NAME at customer scope + link = google_ads.operations[1].customer_asset_operation.create + assert link.field_type == client.enums.AssetFieldTypeEnum.BUSINESS_NAME + assert result["asset"] == "customers/1/assets/-1" + + def test_campaign_scope_creates_campaign_asset_link(self): + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "campaign_asset_result", "customers/1/campaignAssets/42~BUSINESS_NAME" + ), + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + } + ) + + write._apply_create_business_name_asset( + client, + "1", + { + "scope": "campaign", + "campaign_id": "42", + "business_name": "MWS", + }, + ) + link = google_ads.operations[1].campaign_asset_operation.create + assert link.field_type == client.enums.AssetFieldTypeEnum.BUSINESS_NAME + assert link.campaign == "customers/1/campaigns/42" + + def test_campaign_scope_requires_campaign_id(self): + client = _FakeClient( + { + "GoogleAdsService": _FakeGoogleAdsService(), + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + } + ) + with pytest.raises(ValueError, match="campaign_id required"): + write._apply_create_business_name_asset( + client, + "1", + { + "scope": "campaign", + "campaign_id": "", + "business_name": "MWS", + }, + ) + + def test_unknown_scope_raises(self): + client = _FakeClient( + { + "GoogleAdsService": _FakeGoogleAdsService(), + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + } + ) + with pytest.raises(ValueError, match="Unknown scope"): + write._apply_create_business_name_asset( + client, + "1", + { + "scope": "bogus", + "campaign_id": "", + "business_name": "MWS", + }, + ) + + +class TestServerToolRegistrationsImageAndBusinessName: + @pytest.fixture + def tools_by_name(self): + import asyncio + + from adloop.server import mcp + + async def _list(): + return await mcp.list_tools() + + tools = asyncio.run(_list()) + return {t.name: t for t in tools} + + def test_draft_image_assets_now_optional_campaign_id(self, tools_by_name): + tool = tools_by_name["draft_image_assets"] + required = tool.parameters.get("required", []) + assert "campaign_id" not in required + assert "image_paths" in required + assert "field_types" in tool.parameters["properties"] + + def test_draft_business_name_asset_registered(self, tools_by_name): + assert "draft_business_name_asset" in tools_by_name + tool = tools_by_name["draft_business_name_asset"] + required = tool.parameters.get("required", []) + assert "business_name" in required + assert "campaign_id" not in required + + def test_dispatch_includes_business_name_op(self): + import inspect + + src = inspect.getsource(write._execute_plan) + assert ( + '"create_business_name_asset": _apply_create_business_name_asset' in src + ) diff --git a/tests/test_ads_write.py b/tests/test_ads_write.py index ce333fe..d4d485b 100644 --- a/tests/test_ads_write.py +++ b/tests/test_ads_write.py @@ -27,6 +27,7 @@ def __init__(self, response_type: str | None = None, resource_name: str = ""): self.campaign_criterion_result = _FakeResult() self.asset_result = _FakeResult() self.campaign_asset_result = _FakeResult() + self.customer_asset_result = _FakeResult() self._response_type = response_type if response_type: getattr(self, response_type).resource_name = resource_name @@ -506,6 +507,7 @@ def test_apply_campaign_asset_variants_create_asset_and_link_operations(tmp_path client, "1234567890", { + "scope": "campaign", "campaign_id": "1001", "images": [ { @@ -523,13 +525,16 @@ def test_apply_campaign_asset_variants_create_asset_and_link_operations(tmp_path assert image_asset.name == "AdLoop image square deadbeefcafe" assert image_asset.type_ == client.enums.AssetTypeEnum.IMAGE assert image_asset.image_asset.mime_type == client.enums.MimeTypeEnum.IMAGE_PNG - assert image_link.field_type == client.enums.AssetFieldTypeEnum.AD_IMAGE + # Field type is now auto-detected from aspect ratio (1:1 → SQUARE_MARKETING_IMAGE). + # AD_IMAGE was rejected by Google's API for direct asset linking. + assert image_link.field_type == client.enums.AssetFieldTypeEnum.SQUARE_MARKETING_IMAGE google_ads_service._responses = responses write._apply_create_image_assets( client, "1234567890", { + "scope": "campaign", "campaign_id": "1001", "images": [ { diff --git a/tests/test_conversion_actions.py b/tests/test_conversion_actions.py new file mode 100644 index 0000000..5941a96 --- /dev/null +++ b/tests/test_conversion_actions.py @@ -0,0 +1,458 @@ +"""Tests for conversion-action write tools (create / update / remove).""" +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from google.ads.googleads.client import GoogleAdsClient +from google.protobuf import field_mask_pb2 + +from adloop.ads import conversion_actions, write +from adloop.ads.client import GOOGLE_ADS_API_VERSION +from adloop.config import AdLoopConfig, AdsConfig, SafetyConfig +from adloop.safety import preview as preview_store + + +# --------------------------------------------------------------------------- +# Fakes +# --------------------------------------------------------------------------- + + +class _FakeResult: + def __init__(self, resource_name: str = ""): + self.resource_name = resource_name + + +class _FakeConversionActionService: + def __init__(self, results: list[_FakeResult] | None = None): + self.operations: list = [] + self.results_to_return = results or [] + + def conversion_action_path(self, customer_id: str, ca_id: str) -> str: + return f"customers/{customer_id}/conversionActions/{ca_id}" + + def mutate_conversion_actions( + self, customer_id: str, operations: list + ) -> object: + self.operations = operations + return SimpleNamespace(results=self.results_to_return) + + +class _FakeClient: + def __init__(self, services: dict[str, object] | None = None): + self._base = GoogleAdsClient( + credentials=None, + developer_token="test-token", + use_proto_plus=True, + version=GOOGLE_ADS_API_VERSION, + ) + self.enums = self._base.enums + self.get_type = self._base.get_type + self._services = services or {} + + def get_service(self, name: str) -> object: + return self._services[name] + + +@pytest.fixture(autouse=True) +def clear_pending_plans(): + preview_store._pending_plans.clear() + yield + preview_store._pending_plans.clear() + + +@pytest.fixture +def config() -> AdLoopConfig: + return AdLoopConfig( + ads=AdsConfig(customer_id="123-456-7890"), + safety=SafetyConfig(require_dry_run=True), + ) + + +# --------------------------------------------------------------------------- +# Validation tests for draft_create_conversion_action +# --------------------------------------------------------------------------- + + +class TestDraftCreateConversionActionValidation: + def _ok_args(self, **overrides): + defaults = dict( + customer_id="1234567890", + name="Calls from Ads", + type_="AD_CALL", + category="PHONE_CALL_LEAD", + default_value=250, + currency_code="USD", + ) + defaults.update(overrides) + return defaults + + def test_happy_path(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args() + ) + assert "error" not in result + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["name"] == "Calls from Ads" + assert plan.changes["type"] == "AD_CALL" + assert plan.changes["default_value"] == 250.0 + assert plan.changes["currency_code"] == "USD" + assert plan.changes["counting_type"] == "ONE_PER_CLICK" + assert plan.changes["primary_for_goal"] is True + + def test_name_required(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args(name="") + ) + assert result["error"] == "Validation failed" + assert any("name is required" in d for d in result["details"]) + + def test_invalid_type(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args(type_="MADE_UP_TYPE") + ) + assert result["error"] == "Validation failed" + assert any("MADE_UP_TYPE" in d for d in result["details"]) + + def test_invalid_category(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args(category="WRONG_CATEGORY") + ) + assert result["error"] == "Validation failed" + assert any("WRONG_CATEGORY" in d for d in result["details"]) + + def test_invalid_counting_type(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args(counting_type="WRONG") + ) + assert result["error"] == "Validation failed" + assert any("counting_type" in d for d in result["details"]) + + def test_negative_default_value_rejected(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args(default_value=-1) + ) + assert result["error"] == "Validation failed" + assert any("default_value" in d for d in result["details"]) + + def test_invalid_currency_length(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args(currency_code="USDX") + ) + assert result["error"] == "Validation failed" + assert any("currency_code" in d for d in result["details"]) + + def test_invalid_click_through_window(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args(click_through_window_days=120) + ) + assert result["error"] == "Validation failed" + assert any("click_through_window_days" in d for d in result["details"]) + + def test_invalid_view_through_window(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args(view_through_window_days=60) + ) + assert result["error"] == "Validation failed" + assert any("view_through_window_days" in d for d in result["details"]) + + def test_invalid_attribution_model(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args(attribution_model="MAGIC") + ) + assert result["error"] == "Validation failed" + assert any("attribution_model" in d for d in result["details"]) + + def test_phone_call_duration_threshold_persisted(self, config): + result = conversion_actions.draft_create_conversion_action( + config, + **self._ok_args( + type_="WEBSITE_CALL", + phone_call_duration_seconds=90, + ), + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["phone_call_duration_seconds"] == 90 + + +# --------------------------------------------------------------------------- +# draft_update_conversion_action +# --------------------------------------------------------------------------- + + +class TestDraftUpdateConversionAction: + def test_id_required(self, config): + result = conversion_actions.draft_update_conversion_action( + config, customer_id="1", conversion_action_id="" + ) + assert "conversion_action_id is required" in result["error"] + + def test_no_fields_to_update_rejected(self, config): + result = conversion_actions.draft_update_conversion_action( + config, + customer_id="1", + conversion_action_id="6797442210", + ) + assert "No fields to update" in result["error"] + + def test_partial_update_only_includes_specified(self, config): + result = conversion_actions.draft_update_conversion_action( + config, + customer_id="1", + conversion_action_id="6797442210", + name="Calls from Ads (>=90s)", + primary_for_goal=False, + default_value=250, + currency_code="USD", + ) + plan = preview_store._pending_plans[result["plan_id"]] + # specified fields present + assert plan.changes["name"] == "Calls from Ads (>=90s)" + assert plan.changes["primary_for_goal"] is False + assert plan.changes["default_value"] == 250.0 + assert plan.changes["currency_code"] == "USD" + # unspecified fields absent + assert "counting_type" not in plan.changes + assert "click_through_window_days" not in plan.changes + + def test_promote_to_primary(self, config): + result = conversion_actions.draft_update_conversion_action( + config, + customer_id="1", + conversion_action_id="6797442210", + primary_for_goal=True, + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["primary_for_goal"] is True + + def test_demote_to_secondary(self, config): + result = conversion_actions.draft_update_conversion_action( + config, + customer_id="1", + conversion_action_id="6797442210", + primary_for_goal=False, + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["primary_for_goal"] is False + + def test_invalid_counting_type_rejected(self, config): + result = conversion_actions.draft_update_conversion_action( + config, + customer_id="1", + conversion_action_id="6797442210", + counting_type="BAD", + ) + assert result["error"] == "Validation failed" + + def test_phone_duration_persisted(self, config): + result = conversion_actions.draft_update_conversion_action( + config, + customer_id="1", + conversion_action_id="6797442210", + phone_call_duration_seconds=90, + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["phone_call_duration_seconds"] == 90 + + +# --------------------------------------------------------------------------- +# draft_remove_conversion_action +# --------------------------------------------------------------------------- + + +class TestDraftRemoveConversionAction: + def test_id_required(self, config): + result = conversion_actions.draft_remove_conversion_action( + config, customer_id="1", conversion_action_id="" + ) + assert "conversion_action_id is required" in result["error"] + + def test_emits_irreversible_warning(self, config): + result = conversion_actions.draft_remove_conversion_action( + config, customer_id="1", conversion_action_id="6797442210" + ) + assert "warnings" in result + assert any("irreversible" in w.lower() for w in result["warnings"]) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.operation == "remove_conversion_action" + assert plan.entity_id == "6797442210" + + +# --------------------------------------------------------------------------- +# Apply handlers — exercised against fake services +# --------------------------------------------------------------------------- + + +class TestApplyCreateConversionAction: + def test_websitecall_with_duration_threshold(self): + ca_svc = _FakeConversionActionService( + [_FakeResult("customers/1/conversionActions/100")] + ) + client = _FakeClient({"ConversionActionService": ca_svc}) + + conversion_actions._apply_create_conversion_action( + client, + "1", + { + "name": "Website Call (GFN >=90s)", + "type": "WEBSITE_CALL", + "category": "PHONE_CALL_LEAD", + "default_value": 250.0, + "currency_code": "USD", + "always_use_default_value": True, + "counting_type": "ONE_PER_CLICK", + "phone_call_duration_seconds": 90, + "primary_for_goal": True, + "include_in_conversions_metric": True, + "click_through_window_days": 30, + "view_through_window_days": 1, + "attribution_model": "GOOGLE_SEARCH_ATTRIBUTION_DATA_DRIVEN", + }, + ) + + assert len(ca_svc.operations) == 1 + ca = ca_svc.operations[0].create + assert ca.name == "Website Call (GFN >=90s)" + assert ca.type_ == client.enums.ConversionActionTypeEnum.WEBSITE_CALL + assert ca.category == client.enums.ConversionActionCategoryEnum.PHONE_CALL_LEAD + assert ca.value_settings.default_value == 250.0 + assert ca.value_settings.default_currency_code == "USD" + assert ca.value_settings.always_use_default_value is True + assert ca.counting_type == client.enums.ConversionActionCountingTypeEnum.ONE_PER_CLICK + assert ca.primary_for_goal is True + assert ca.phone_call_duration_seconds == 90 + assert ca.click_through_lookback_window_days == 30 + assert ca.view_through_lookback_window_days == 1 + + +class TestApplyUpdateConversionAction: + def test_partial_update_fieldmask(self): + ca_svc = _FakeConversionActionService( + [_FakeResult("customers/1/conversionActions/6797442210")] + ) + client = _FakeClient({"ConversionActionService": ca_svc}) + + conversion_actions._apply_update_conversion_action( + client, + "1", + { + "conversion_action_id": "6797442210", + "name": "Calls from Ads (>=90s)", + "default_value": 250.0, + "currency_code": "USD", + "always_use_default_value": True, + "counting_type": "ONE_PER_CLICK", + "primary_for_goal": True, + }, + ) + + op = ca_svc.operations[0] + ca = op.update + assert ca.resource_name == "customers/1/conversionActions/6797442210" + assert ca.name == "Calls from Ads (>=90s)" + assert ca.value_settings.default_value == 250.0 + assert ca.counting_type == client.enums.ConversionActionCountingTypeEnum.ONE_PER_CLICK + assert ca.primary_for_goal is True + # Field mask reflects exactly the keys we set + mask_paths = list(op.update_mask.paths) + assert "name" in mask_paths + assert "value_settings.default_value" in mask_paths + assert "value_settings.default_currency_code" in mask_paths + assert "value_settings.always_use_default_value" in mask_paths + assert "counting_type" in mask_paths + assert "primary_for_goal" in mask_paths + # Fields we didn't pass shouldn't be in the mask + assert "phone_call_duration_seconds" not in mask_paths + + def test_update_only_phone_duration(self): + ca_svc = _FakeConversionActionService( + [_FakeResult("customers/1/conversionActions/6797442210")] + ) + client = _FakeClient({"ConversionActionService": ca_svc}) + + conversion_actions._apply_update_conversion_action( + client, + "1", + { + "conversion_action_id": "6797442210", + "phone_call_duration_seconds": 90, + }, + ) + + op = ca_svc.operations[0] + ca = op.update + assert ca.phone_call_duration_seconds == 90 + mask_paths = list(op.update_mask.paths) + assert mask_paths == ["phone_call_duration_seconds"] + + +class TestApplyRemoveConversionAction: + def test_remove_sets_resource_name(self): + ca_svc = _FakeConversionActionService( + [_FakeResult("customers/1/conversionActions/6797442210")] + ) + client = _FakeClient({"ConversionActionService": ca_svc}) + + conversion_actions._apply_remove_conversion_action( + client, + "1", + {"conversion_action_id": "6797442210"}, + ) + + op = ca_svc.operations[0] + assert op.remove == "customers/1/conversionActions/6797442210" + + +# --------------------------------------------------------------------------- +# MCP tool registration + dispatch wiring +# --------------------------------------------------------------------------- + + +class TestMCPRegistration: + @pytest.fixture(scope="class") + def tools_by_name(self): + import asyncio + from adloop.server import mcp + + async def _list(): + return await mcp.list_tools() + + tools = asyncio.run(_list()) + return {t.name: t for t in tools} + + def test_three_conversion_action_tools_registered(self, tools_by_name): + for name in ( + "draft_create_conversion_action", + "draft_update_conversion_action", + "draft_remove_conversion_action", + ): + assert name in tools_by_name, f"{name} not registered" + + def test_create_required_params(self, tools_by_name): + required = ( + tools_by_name["draft_create_conversion_action"] + .parameters.get("required", []) + ) + assert "name" in required + assert "type_" in required + + def test_update_requires_id(self, tools_by_name): + required = ( + tools_by_name["draft_update_conversion_action"] + .parameters.get("required", []) + ) + assert "conversion_action_id" in required + + def test_remove_requires_id(self, tools_by_name): + required = ( + tools_by_name["draft_remove_conversion_action"] + .parameters.get("required", []) + ) + assert "conversion_action_id" in required + + def test_dispatch_routes(self): + import inspect + src = inspect.getsource(write._execute_plan) + assert '"create_conversion_action": _apply_create_conversion_action_route' in src + assert '"update_conversion_action": _apply_update_conversion_action_route' in src + assert '"remove_conversion_action": _apply_remove_conversion_action_route' in src diff --git a/tests/test_update_rsa.py b/tests/test_update_rsa.py new file mode 100644 index 0000000..ade6b75 --- /dev/null +++ b/tests/test_update_rsa.py @@ -0,0 +1,622 @@ +"""Tests for ``update_responsive_search_ad`` — both the draft (preview) layer +and the ``_apply_update_rsa`` mutation layer. + +The Google Ads client is faked: we never hit the network, but we do verify +that the AdOperation we build carries the correct resource_name, the correct +update_mask paths, and the right field values. URL reachability is also +faked so the tests don't depend on the public internet. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from google.ads.googleads.client import GoogleAdsClient + +from adloop.ads import write +from adloop.ads.client import GOOGLE_ADS_API_VERSION +from adloop.config import AdLoopConfig, AdsConfig, SafetyConfig +from adloop.safety import preview as preview_store + + +# --------------------------------------------------------------------------- +# Fake Google Ads client + AdService +# --------------------------------------------------------------------------- + + +class _FakeAdService: + """Captures the operations passed to ``mutate_ads`` for assertion. + + Returns a fake response shaped like the real one so callers that read + ``response.results[0].resource_name`` continue to work. + """ + + def __init__(self) -> None: + self.captured_operations: list[object] | None = None + self.captured_customer_id: str | None = None + + def ad_path(self, customer_id: str, ad_id: str) -> str: + return f"customers/{customer_id}/ads/{ad_id}" + + def mutate_ads( + self, + customer_id: str, + operations: list[object], + ) -> object: + self.captured_operations = operations + self.captured_customer_id = customer_id + first_op = operations[0] + return SimpleNamespace( + results=[SimpleNamespace(resource_name=first_op.update.resource_name)] + ) + + +class _FakeClient: + """Shim around the real client to swap in our fake AdService. + + Reuses the real client's ``enums`` and ``get_type`` for proto wiring; + only ``get_service`` is intercepted. + """ + + def __init__(self, ad_service: _FakeAdService): + self._base = GoogleAdsClient( + credentials=None, + developer_token="test-token", + use_proto_plus=True, + version=GOOGLE_ADS_API_VERSION, + ) + self.enums = self._base.enums + self.get_type = self._base.get_type + self._services = {"AdService": ad_service} + + def get_service(self, name: str) -> object: + return self._services[name] + + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def clear_pending_plans(): + preview_store._pending_plans.clear() + yield + preview_store._pending_plans.clear() + + +@pytest.fixture(autouse=True) +def stub_url_validation(monkeypatch): + """Default: every URL passes. Tests can override to inject failures.""" + monkeypatch.setattr( + write, + "_validate_urls", + lambda urls, timeout=10: {u: None for u in urls}, + ) + + +@pytest.fixture +def config(tmp_path) -> AdLoopConfig: + return AdLoopConfig( + ads=AdsConfig(customer_id="123-456-7890"), + safety=SafetyConfig( + require_dry_run=False, + log_file=str(tmp_path / "audit.log"), + ), + ) + + +@pytest.fixture +def dry_run_config(tmp_path) -> AdLoopConfig: + return AdLoopConfig( + ads=AdsConfig(customer_id="123-456-7890"), + safety=SafetyConfig( + require_dry_run=True, + log_file=str(tmp_path / "audit.log"), + ), + ) + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + + +class TestValidation: + def test_rejects_missing_ad_id(self, config): + result = write.update_responsive_search_ad( + config, customer_id="1234567890", final_url="https://example.com" + ) + assert result["error"] == "Validation failed" + assert any("ad_id is required" in d for d in result["details"]) + + def test_rejects_non_numeric_ad_id(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="abc123", + path1="Pricing", + ) + assert result["error"] == "Validation failed" + assert any("numeric" in d for d in result["details"]) + + def test_rejects_when_no_change_provided(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + ) + assert result["error"] == "Validation failed" + assert any("No changes specified" in d for d in result["details"]) + + def test_rejects_path1_too_long(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1="this-is-way-too-long-for-a-path", + ) + assert result["error"] == "Validation failed" + assert any("path1 must be 15 chars" in d for d in result["details"]) + + def test_rejects_path2_too_long(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path2="X" * 16, + ) + assert result["error"] == "Validation failed" + assert any("path2 must be 15 chars" in d for d in result["details"]) + + def test_accepts_path_at_max_length_15(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1="X" * 15, + ) + assert result.get("error") is None + assert result["operation"] == "update_responsive_search_ad" + + def test_rejects_unreachable_url(self, config, monkeypatch): + monkeypatch.setattr( + write, + "_validate_urls", + lambda urls, timeout=10: {u: "HTTP 404" for u in urls}, + ) + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + final_url="https://example.com/missing", + ) + assert result["error"] == "URL validation failed" + assert any("not reachable" in d for d in result["details"]) + + def test_blocked_operation_rejected_before_validation( + self, config, monkeypatch + ): + config.safety.blocked_operations = ["update_responsive_search_ad"] + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1="OK", + ) + assert "blocked" in result["error"] + + def test_url_validation_skipped_when_only_paths_changed( + self, config, monkeypatch + ): + called = {"count": 0} + + def spy_validate(urls, timeout=10): + called["count"] += 1 + return {u: None for u in urls} + + monkeypatch.setattr(write, "_validate_urls", spy_validate) + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1="Pricing", + ) + assert result.get("error") is None + # No URL was supplied — we shouldn't have hit the validator. + assert called["count"] == 0 + + def test_multiple_validation_errors_returned_together(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="abc", # non-numeric + path1="X" * 16, # too long + ) + assert result["error"] == "Validation failed" + assert len(result["details"]) >= 2 + + +# --------------------------------------------------------------------------- +# Plan construction +# --------------------------------------------------------------------------- + + +class TestPlanConstruction: + def test_plan_metadata(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1="Sale", + ) + assert result["operation"] == "update_responsive_search_ad" + assert result["entity_type"] == "ad" + assert result["entity_id"] == "999" + assert result["customer_id"] == "1234567890" + assert result["status"] == "PENDING_CONFIRMATION" + assert "plan_id" in result + + def test_plan_stored_for_retrieval(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1="Sale", + ) + plan = preview_store.get_plan(result["plan_id"]) + assert plan is not None + assert plan.operation == "update_responsive_search_ad" + + def test_url_only_change_does_not_include_paths(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + final_url="https://example.com/x", + ) + changes = result["changes"] + assert changes["final_url"] == "https://example.com/x" + assert "path1" not in changes + assert "path2" not in changes + + def test_path1_only_change_does_not_include_url_or_path2(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1="Pricing", + ) + changes = result["changes"] + assert changes["path1"] == "Pricing" + assert "final_url" not in changes + assert "path2" not in changes + + def test_path2_only_change_does_not_include_url_or_path1(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path2="Sacramento", + ) + changes = result["changes"] + assert changes["path2"] == "Sacramento" + assert "final_url" not in changes + assert "path1" not in changes + + def test_all_three_fields_set(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + final_url="https://example.com/x", + path1="A", + path2="B", + ) + changes = result["changes"] + assert changes["final_url"] == "https://example.com/x" + assert changes["path1"] == "A" + assert changes["path2"] == "B" + + def test_clear_path1_writes_empty_string_into_changes(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + clear_path1=True, + ) + changes = result["changes"] + # ``"path1" in changes`` is critical — apply uses presence to decide + # whether to mutate. Empty string is the *value*, not "no change". + assert "path1" in changes + assert changes["path1"] == "" + assert "path2" not in changes + + def test_clear_path2_writes_empty_string_into_changes(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + clear_path2=True, + ) + changes = result["changes"] + assert "path2" in changes + assert changes["path2"] == "" + assert "path1" not in changes + + def test_clear_path1_overrides_path1_argument(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1="Pricing", + clear_path1=True, + ) + # When clear_path1 is True we ignore the path1 string and clear it. + assert result["changes"]["path1"] == "" + + def test_paths_are_stripped_of_whitespace(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1=" Sale ", + path2=" \t Pricing\n", + ) + assert result["changes"]["path1"] == "Sale" + assert result["changes"]["path2"] == "Pricing" + + def test_ad_id_coerced_to_string(self, config): + # FastMCP types ad_id as str, but defensive coercion is cheap. + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1="X", + ) + assert isinstance(result["changes"]["ad_id"], str) + assert result["changes"]["ad_id"] == "999" + + +# --------------------------------------------------------------------------- +# Apply / mutate +# --------------------------------------------------------------------------- + + +class TestApply: + def test_calls_mutate_ads_with_correct_resource_name(self, config): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + + write._apply_update_rsa( + client, + "1234567890", + {"ad_id": "999", "final_url": "https://example.com"}, + ) + + op = ad_service.captured_operations[0] + assert op.update.resource_name == "customers/1234567890/ads/999" + + def test_url_only_field_mask_has_only_final_urls(self, config): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + + write._apply_update_rsa( + client, + "1234567890", + {"ad_id": "999", "final_url": "https://example.com"}, + ) + + op = ad_service.captured_operations[0] + assert list(op.update_mask.paths) == ["final_urls"] + assert list(op.update.final_urls) == ["https://example.com"] + + def test_path1_only_field_mask(self, config): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + + write._apply_update_rsa( + client, + "1234567890", + {"ad_id": "999", "path1": "Sale"}, + ) + + op = ad_service.captured_operations[0] + assert list(op.update_mask.paths) == ["responsive_search_ad.path1"] + assert op.update.responsive_search_ad.path1 == "Sale" + + def test_path2_only_field_mask(self, config): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + + write._apply_update_rsa( + client, + "1234567890", + {"ad_id": "999", "path2": "Sacramento"}, + ) + + op = ad_service.captured_operations[0] + assert list(op.update_mask.paths) == ["responsive_search_ad.path2"] + assert op.update.responsive_search_ad.path2 == "Sacramento" + + def test_all_three_fields_in_mask(self, config): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + + write._apply_update_rsa( + client, + "1234567890", + { + "ad_id": "999", + "final_url": "https://example.com", + "path1": "Sale", + "path2": "NE", + }, + ) + + op = ad_service.captured_operations[0] + assert set(op.update_mask.paths) == { + "final_urls", + "responsive_search_ad.path1", + "responsive_search_ad.path2", + } + + def test_clear_path_writes_empty_string_to_proto(self, config): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + + write._apply_update_rsa( + client, + "1234567890", + {"ad_id": "999", "path1": ""}, # the "clear" semantic + ) + + op = ad_service.captured_operations[0] + assert "responsive_search_ad.path1" in list(op.update_mask.paths) + assert op.update.responsive_search_ad.path1 == "" + + def test_returns_resource_name(self, config): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + + result = write._apply_update_rsa( + client, + "1234567890", + {"ad_id": "999", "path1": "X"}, + ) + + assert result == {"resource_name": "customers/1234567890/ads/999"} + + def test_passes_customer_id_to_mutate_call(self, config): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + + write._apply_update_rsa( + client, + "1234567890", + {"ad_id": "999", "path1": "X"}, + ) + + assert ad_service.captured_customer_id == "1234567890" + + +# --------------------------------------------------------------------------- +# Confirm-and-apply integration +# --------------------------------------------------------------------------- + + +class TestConfirmAndApplyIntegration: + def test_dry_run_returns_dry_run_success(self, config): + draft = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1="Pricing", + ) + result = write.confirm_and_apply( + config, plan_id=draft["plan_id"], dry_run=True + ) + assert result["status"] == "DRY_RUN_SUCCESS" + assert result["operation"] == "update_responsive_search_ad" + + def test_require_dry_run_overrides_dry_run_false(self, dry_run_config): + draft = write.update_responsive_search_ad( + dry_run_config, + customer_id="1234567890", + ad_id="999", + path1="Pricing", + ) + result = write.confirm_and_apply( + dry_run_config, plan_id=draft["plan_id"], dry_run=False + ) + assert result["status"] == "DRY_RUN_SUCCESS" + assert result.get("dry_run_forced_by") == "config.safety.require_dry_run" + + def test_unknown_plan_id_returns_error(self, config): + result = write.confirm_and_apply( + config, plan_id="does-not-exist", dry_run=True + ) + assert "error" in result + + def test_apply_routes_to_update_rsa(self, config, monkeypatch): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + + monkeypatch.setattr( + "adloop.ads.client.get_ads_client", lambda _cfg: client + ) + + draft = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + final_url="https://example.com", + path1="Sale", + ) + result = write.confirm_and_apply( + config, plan_id=draft["plan_id"], dry_run=False + ) + + assert result["status"] == "APPLIED" + assert result["operation"] == "update_responsive_search_ad" + assert ad_service.captured_operations is not None + assert len(ad_service.captured_operations) == 1 + + def test_apply_writes_audit_log(self, config, monkeypatch, tmp_path): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + monkeypatch.setattr( + "adloop.ads.client.get_ads_client", lambda _cfg: client + ) + + draft = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1="Sale", + ) + write.confirm_and_apply( + config, plan_id=draft["plan_id"], dry_run=False + ) + + log_path = config.safety.log_file + from pathlib import Path + contents = Path(log_path).read_text() + assert "update_responsive_search_ad" in contents + assert "success" in contents + + def test_apply_writes_dry_run_audit_log(self, config): + draft = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1="Sale", + ) + write.confirm_and_apply( + config, plan_id=draft["plan_id"], dry_run=True + ) + + from pathlib import Path + contents = Path(config.safety.log_file).read_text() + assert "dry_run_success" in contents + assert '"dry_run": true' in contents + + def test_plan_removed_after_successful_apply(self, config, monkeypatch): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + monkeypatch.setattr( + "adloop.ads.client.get_ads_client", lambda _cfg: client + ) + + draft = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1="Sale", + ) + plan_id = draft["plan_id"] + assert preview_store.get_plan(plan_id) is not None + + write.confirm_and_apply(config, plan_id=plan_id, dry_run=False) + assert preview_store.get_plan(plan_id) is None