From 2db827e3b8530305e22793ba87f43d09c4392e8c Mon Sep 17 00:00:00 2001 From: Owleksiy Date: Fri, 20 Mar 2026 09:59:44 -0700 Subject: [PATCH] Add Search campaign expansion, asset tools, and CPC-cap support - add draft_ad_group and update_ad_group for existing campaigns - extend draft_campaign/update_campaign with Search partners and display expansion settings - support max_cpc for MANUAL_CPC ad-group bids and TARGET_SPEND (Maximize Clicks) CPC caps - add draft_callouts, draft_structured_snippets, and draft_image_assets - create shared campaign-asset mutation helper for campaign-level assets - fix image asset uploads by setting required asset names and preserving compatibility with older pending plans - add write-layer tests covering validation, field masks, campaign settings, asset creation, and image upload regressions --- .claude/rules/adloop.md | 17 +- .cursor/rules/adloop.mdc | 14 +- README.md | 10 +- src/adloop/ads/gaql.py | 10 + src/adloop/ads/write.py | 724 +++++++++++++++++++++++++++++++++++++-- src/adloop/server.py | 218 ++++++++++-- tests/test_ads_write.py | 599 ++++++++++++++++++++++++++++++++ tests/test_server.py | 67 ++++ 8 files changed, 1606 insertions(+), 53 deletions(-) create mode 100644 tests/test_ads_write.py create mode 100644 tests/test_server.py diff --git a/.claude/rules/adloop.md b/.claude/rules/adloop.md index f4becb7..2cdde4e 100644 --- a/.claude/rules/adloop.md +++ b/.claude/rules/adloop.md @@ -1,3 +1,6 @@ +--- +description: AdLoop MCP orchestration — Google Ads + GA4 + codebase intelligence +--- # AdLoop — AI Orchestration Rules @@ -72,9 +75,14 @@ These tools call both APIs internally and return unified results with computed ` | Tool | What It Does | Validation | |------|-------------|------------| -| `draft_campaign` | Create full campaign structure (budget + campaign + ad group + keywords + geo/language targeting) | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), keywords validated | -| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids` | +| `draft_campaign` | Create full campaign structure (budget + campaign + ad group + keywords + geo/language targeting) | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), optional `search_partners_enabled`, `display_network_enabled`, `display_expansion_enabled`, optional `max_cpc` for MANUAL_CPC ad-group bids or TARGET_SPEND CPC caps | +| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, Search partners, display expansion | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `search_partners_enabled`, `display_network_enabled`, TARGET_SPEND `max_cpc` | +| `draft_ad_group` | Create a paused SEARCH_STANDARD ad group in an existing campaign | `campaign_id`, `ad_group_name`, optional MANUAL_CPC `max_cpc` | +| `update_ad_group` | Update ad group name and/or MANUAL_CPC `max_cpc` | `ad_group_id`, optional `ad_group_name`, optional `max_cpc` | | `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each) | +| `draft_callouts` | Create callout assets for a campaign (does NOT publish) | `campaign_id`, `callouts` list with 1-25 chars each | +| `draft_structured_snippets` | Create structured snippet assets for a campaign (does NOT publish) | `campaign_id`, `snippets` list of `{header, values}` with official header values and 3-10 values | +| `draft_image_assets` | Create image assets for a campaign from local files (does NOT publish) | `campaign_id`, `image_paths` list of local PNG/JPEG/GIF files | | `draft_sitelinks` | Create sitelink extensions for a campaign (does NOT publish) | `campaign_id`, `sitelinks` list of {link_text ≤25 chars, final_url, description1 ≤35 chars, description2 ≤35 chars} | | `draft_keywords` | Propose keyword additions (does NOT add) | Each keyword needs `text` and `match_type` (EXACT/PHRASE/BROAD) | | `add_negative_keywords` | Propose negative keywords (does NOT add) | `campaign_id`, keyword list, `match_type` | @@ -92,7 +100,10 @@ These tools call both APIs internally and return unified results with computed ` **Safety behaviors:** - New campaigns and RSAs are created as PAUSED — user must explicitly enable them after review. - `draft_campaign` REQUIRES `geo_target_ids` and `language_ids` — campaigns without targeting waste budget. The tool rejects drafts with missing targeting. -- `draft_campaign` enforces the `max_daily_budget` safety cap, rejects BROAD match + non-Smart Bidding, and warns if budget is below 5x target CPA. +- `draft_campaign` enforces the `max_daily_budget` safety cap, rejects BROAD match + non-Smart Bidding, warns if budget is below 5x target CPA, and interprets `max_cpc` by bidding strategy: MANUAL_CPC seeds the initial ad-group bid, TARGET_SPEND sets the Maximize Clicks CPC ceiling. +- `display_network_enabled` is the canonical Search display-expansion flag. `display_expansion_enabled` is only a compatibility alias and should be normalized away before presenting the plan to the user. +- `update_ad_group` is the right tool for later MANUAL_CPC bid changes. Use `update_campaign` for TARGET_SPEND (Maximize Clicks) `max_cpc` changes. +- Ad-group pause/enable is already handled by `pause_entity` / `enable_entity` with `entity_type="ad_group"`; do not invent a separate pause tool. - `update_campaign` replaces geo/language targets entirely (not append). Pass the full desired list. - `remove_entity` is IRREVERSIBLE — always prefer `pause_entity` unless the user explicitly wants permanent removal. Removal triggers double confirmation in the safety layer. - `remove_entity` supports `entity_type` values: "campaign", "ad_group", "ad", "keyword", "negative_keyword", "campaign_asset". Use "negative_keyword" to remove campaign-level negative keywords. Use "campaign_asset" to remove sitelinks and other asset links from a campaign. diff --git a/.cursor/rules/adloop.mdc b/.cursor/rules/adloop.mdc index 41944f3..4f4d295 100644 --- a/.cursor/rules/adloop.mdc +++ b/.cursor/rules/adloop.mdc @@ -77,9 +77,14 @@ These tools call both APIs internally and return unified results with computed ` | Tool | What It Does | Validation | |------|-------------|------------| -| `draft_campaign` | Create full campaign structure (budget + campaign + ad group + keywords + geo/language targeting) | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), keywords validated | -| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids` | +| `draft_campaign` | Create full campaign structure (budget + campaign + ad group + keywords + geo/language targeting) | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), optional `search_partners_enabled`, `display_network_enabled`, `display_expansion_enabled`, optional `max_cpc` for MANUAL_CPC ad-group bids or TARGET_SPEND CPC caps | +| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, Search partners, display expansion | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `search_partners_enabled`, `display_network_enabled`, TARGET_SPEND `max_cpc` | +| `draft_ad_group` | Create a paused SEARCH_STANDARD ad group in an existing campaign | `campaign_id`, `ad_group_name`, optional MANUAL_CPC `max_cpc` | +| `update_ad_group` | Update ad group name and/or MANUAL_CPC `max_cpc` | `ad_group_id`, optional `ad_group_name`, optional `max_cpc` | | `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each) | +| `draft_callouts` | Create callout assets for a campaign (does NOT publish) | `campaign_id`, `callouts` list with 1-25 chars each | +| `draft_structured_snippets` | Create structured snippet assets for a campaign (does NOT publish) | `campaign_id`, `snippets` list of `{header, values}` with official header values and 3-10 values | +| `draft_image_assets` | Create image assets for a campaign from local files (does NOT publish) | `campaign_id`, `image_paths` list of local PNG/JPEG/GIF files | | `draft_sitelinks` | Create sitelink extensions for a campaign (does NOT publish) | `campaign_id`, `sitelinks` list of {link_text ≤25 chars, final_url, description1 ≤35 chars, description2 ≤35 chars} | | `draft_keywords` | Propose keyword additions (does NOT add) | Each keyword needs `text` and `match_type` (EXACT/PHRASE/BROAD) | | `add_negative_keywords` | Propose negative keywords (does NOT add) | `campaign_id`, keyword list, `match_type` | @@ -97,7 +102,10 @@ These tools call both APIs internally and return unified results with computed ` **Safety behaviors:** - New campaigns and RSAs are created as PAUSED — user must explicitly enable them after review. - `draft_campaign` REQUIRES `geo_target_ids` and `language_ids` — campaigns without targeting waste budget. The tool rejects drafts with missing targeting. -- `draft_campaign` enforces the `max_daily_budget` safety cap, rejects BROAD match + non-Smart Bidding, and warns if budget is below 5x target CPA. +- `draft_campaign` enforces the `max_daily_budget` safety cap, rejects BROAD match + non-Smart Bidding, warns if budget is below 5x target CPA, and interprets `max_cpc` by bidding strategy: MANUAL_CPC seeds the initial ad-group bid, TARGET_SPEND sets the Maximize Clicks CPC ceiling. +- `display_network_enabled` is the canonical Search display-expansion flag. `display_expansion_enabled` is only a compatibility alias and should be normalized away before presenting the plan to the user. +- `update_ad_group` is the right tool for later MANUAL_CPC bid changes. Use `update_campaign` for TARGET_SPEND (Maximize Clicks) `max_cpc` changes. +- Ad-group pause/enable is already handled by `pause_entity` / `enable_entity` with `entity_type="ad_group"`; do not invent a separate pause tool. - `update_campaign` replaces geo/language targets entirely (not append). Pass the full desired list. - `remove_entity` is IRREVERSIBLE — always prefer `pause_entity` unless the user explicitly wants permanent removal. Removal triggers double confirmation in the safety layer. - `remove_entity` supports `entity_type` values: "campaign", "ad_group", "ad", "keyword", "negative_keyword", "campaign_asset". Use "negative_keyword" to remove campaign-level negative keywords. Use "campaign_asset" to remove sitelinks and other asset links from a campaign. diff --git a/README.md b/README.md index 6615cfe..8ea72cb 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Every tool exists because of an actual problem hit while running real Google Ads The best features come from real workflows. If you're using AdLoop and find yourself wishing it could do something it can't, **open an issue describing your situation** — not just "add feature X" but "I was trying to do Y and couldn't because Z." The context matters more than the request. -## All 26 Tools +## All 33 Tools > **Quick start:** `pip install adloop` or `git clone https://github.com/kLOsk/adloop.git && cd adloop && uv sync && uv run adloop init` @@ -102,8 +102,14 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin | Tool | What It Does | |------|-------------| -| `draft_campaign` | Create a full campaign structure — budget + campaign (PAUSED) + ad group + optional keywords. Validates bidding strategy, enforces budget caps, rejects unsafe BROAD match + Manual CPC combinations. | +| `draft_campaign` | Create a full campaign structure — budget + campaign (PAUSED) + ad group + optional keywords. Supports Search partners, display expansion, and `max_cpc` for either MANUAL_CPC initial ad-group bids or TARGET_SPEND (Maximize Clicks) CPC caps. | +| `update_campaign` | Modify existing campaign settings — bidding, budget, geo/language targeting, Search partners, display expansion, and TARGET_SPEND (Maximize Clicks) `max_cpc` caps. | +| `draft_ad_group` | Create a paused SEARCH_STANDARD ad group inside an existing campaign, with optional MANUAL_CPC `max_cpc`. | +| `update_ad_group` | Update an ad group name and/or MANUAL_CPC `max_cpc`. Use `pause_entity` / `enable_entity` for ad-group status changes. | | `draft_responsive_search_ad` | Create RSA preview (3-15 headlines ≤30 chars, 2-4 descriptions ≤90 chars). Warns if headline/description count is below best practice. | +| `draft_callouts` | Create campaign callout assets from 1-25 character text snippets. | +| `draft_structured_snippets` | Create campaign structured snippet assets using official header values and 3-10 snippet values. | +| `draft_image_assets` | Create campaign image assets from local PNG, JPEG, or GIF files. | | `draft_keywords` | Propose keyword additions with match types. Proactively checks bidding strategy — blocks BROAD match on Manual CPC campaigns. | | `add_negative_keywords` | Propose negative keywords to reduce wasted spend | | `pause_entity` | Pause a campaign, ad group, ad, or keyword | diff --git a/src/adloop/ads/gaql.py b/src/adloop/ads/gaql.py index c2ef741..a95174e 100644 --- a/src/adloop/ads/gaql.py +++ b/src/adloop/ads/gaql.py @@ -63,6 +63,16 @@ def run_gaql( # --------------------------------------------------------------------------- _GAQL_ERROR_HINTS = { + "DEVELOPER_TOKEN_NOT_APPROVED": ( + "Your Google Ads developer token is only approved for test accounts. " + "Apply for Basic or Standard access in the Google Ads API Center, " + "or use a test account." + ), + "DEVELOPER_TOKEN_INVALID": ( + "Your Google Ads developer token is invalid. Update " + "`ads.developer_token` in `~/.adloop/config.yaml` using the token " + "from your manager account API Center." + ), "EXPECTED_REFERENCED_FIELD_IN_SELECT_CLAUSE": ( "Fields used in ORDER BY or HAVING must also appear in the SELECT clause. " "Add the missing field to your SELECT." diff --git a/src/adloop/ads/write.py b/src/adloop/ads/write.py index d7646d0..b0d11b5 100644 --- a/src/adloop/ads/write.py +++ b/src/adloop/ads/write.py @@ -6,12 +6,38 @@ from __future__ import annotations +import hashlib +import struct +from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: from adloop.config import AdLoopConfig +_STRUCTURED_SNIPPET_HEADERS = { + "Amenities", + "Brands", + "Courses", + "Degree programs", + "Destinations", + "Featured Hotels", + "Insurance coverage", + "Models", + "Neighborhoods", + "Services", + "Shows", + "Styles", + "Types", +} + +_VALID_IMAGE_MIME_TYPES = { + "image/gif": "IMAGE_GIF", + "image/jpeg": "IMAGE_JPEG", + "image/png": "IMAGE_PNG", +} + + # --------------------------------------------------------------------------- # URL validation — verify URLs exist before creating ads/sitelinks # --------------------------------------------------------------------------- @@ -58,6 +84,104 @@ def _validate_urls(urls: list[str], timeout: int = 10) -> dict[str, str | None]: return results +def _normalize_display_network_setting( + display_network_enabled: bool | None, + display_expansion_enabled: bool | None, +) -> tuple[bool | None, list[str]]: + """Normalize the deprecated alias to one canonical display network flag.""" + errors = [] + if ( + display_network_enabled is not None + and display_expansion_enabled is not None + and display_network_enabled != display_expansion_enabled + ): + errors.append( + "display_network_enabled and display_expansion_enabled must match " + "when both are provided" + ) + if errors: + return None, errors + if display_network_enabled is not None: + return display_network_enabled, [] + return display_expansion_enabled, [] + + +def _parse_image_metadata(path_str: str) -> dict[str, object]: + """Validate a local image file and return metadata used for asset creation.""" + path = Path(path_str).expanduser() + if not path.exists(): + raise ValueError(f"Image file does not exist: {path_str}") + if not path.is_file(): + raise ValueError(f"Image path is not a file: {path_str}") + + data = path.read_bytes() + mime_type, width, height = _detect_image_type_and_size(data) + return { + "path": str(path), + "name": _build_image_asset_name(path, data), + "mime_type": mime_type, + "width": width, + "height": height, + } + + +def _build_image_asset_name(path: Path, data: bytes) -> str: + """Build a deterministic asset name required by Google Ads image assets.""" + digest = hashlib.sha1(data).hexdigest()[:12] + stem = path.stem.strip() or "image" + return f"AdLoop image {stem[:80]} {digest}" + + +def _detect_image_type_and_size(data: bytes) -> tuple[str, int, int]: + """Return MIME type plus width/height for supported local image files.""" + if data.startswith(b"\x89PNG\r\n\x1a\n") and len(data) >= 24: + width, height = struct.unpack(">II", data[16:24]) + return "image/png", width, height + + if data[:6] in (b"GIF87a", b"GIF89a") and len(data) >= 10: + width, height = struct.unpack("= len(data): + break + + marker = data[index] + index += 1 + if marker in {0xD8, 0xD9}: + continue + if index + 1 >= len(data): + break + + segment_length = struct.unpack(">H", data[index:index + 2])[0] + if segment_length < 2 or index + segment_length > len(data): + break + + if marker in { + 0xC0, 0xC1, 0xC2, 0xC3, + 0xC5, 0xC6, 0xC7, + 0xC9, 0xCA, 0xCB, + 0xCD, 0xCE, 0xCF, + }: + if index + 7 > len(data): + break + height, width = struct.unpack(">HH", data[index + 3:index + 7]) + return "image/jpeg", width, height + + index += segment_length + + raise ValueError( + "Unsupported image type. Use a local PNG, JPEG, or GIF file." + ) + + # --------------------------------------------------------------------------- # Draft tools — validate inputs, create a ChangePlan, return preview # --------------------------------------------------------------------------- @@ -217,6 +341,111 @@ def add_negative_keywords( return plan.to_preview() +def draft_ad_group( + config: AdLoopConfig, + *, + customer_id: str = "", + campaign_id: str = "", + ad_group_name: str = "", + max_cpc: float = 0, +) -> dict: + """Draft an ad group creation for an existing campaign.""" + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("create_ad_group", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + errors = [] + if not campaign_id: + errors.append("campaign_id is required") + if not ad_group_name.strip(): + errors.append("ad_group_name is required") + if max_cpc < 0: + errors.append("max_cpc cannot be negative") + if max_cpc: + uses_manual_cpc = _campaign_uses_manual_cpc(config, customer_id, campaign_id) + if uses_manual_cpc is False: + errors.append("max_cpc requires a MANUAL_CPC campaign") + elif uses_manual_cpc is None: + errors.append( + f"Unable to verify bidding strategy for campaign_id '{campaign_id}'" + ) + + if errors: + return {"error": "Validation failed", "details": errors} + + plan = ChangePlan( + operation="create_ad_group", + entity_type="ad_group", + customer_id=customer_id, + changes={ + "campaign_id": campaign_id, + "ad_group_name": ad_group_name.strip(), + "max_cpc": max_cpc if max_cpc else None, + }, + ) + store_plan(plan) + return plan.to_preview() + + +def update_ad_group( + config: AdLoopConfig, + *, + customer_id: str = "", + ad_group_id: str = "", + ad_group_name: str = "", + max_cpc: float = 0, +) -> dict: + """Draft an ad group update for name and manual CPC bid.""" + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("update_ad_group", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + errors = [] + if not ad_group_id: + errors.append("ad_group_id is required") + if max_cpc < 0: + errors.append("max_cpc cannot be negative") + if max_cpc: + uses_manual_cpc = _ad_group_uses_manual_cpc(config, customer_id, ad_group_id) + if uses_manual_cpc is False: + errors.append("max_cpc requires an ad group in a MANUAL_CPC campaign") + elif uses_manual_cpc is None: + errors.append( + f"Unable to verify bidding strategy for ad_group_id '{ad_group_id}'" + ) + + has_any_change = bool(ad_group_name.strip() or max_cpc) + if not has_any_change: + errors.append("No changes specified — provide ad_group_name and/or max_cpc") + + if errors: + return {"error": "Validation failed", "details": errors} + + changes: dict = {"ad_group_id": ad_group_id} + if ad_group_name.strip(): + changes["ad_group_name"] = ad_group_name.strip() + if max_cpc: + changes["max_cpc"] = max_cpc + + plan = ChangePlan( + operation="update_ad_group", + entity_type="ad_group", + entity_id=ad_group_id, + customer_id=customer_id, + changes=changes, + ) + store_plan(plan) + return plan.to_preview() + + def pause_entity( config: AdLoopConfig, *, @@ -304,6 +533,10 @@ def draft_campaign( keywords: list[dict] | None = None, geo_target_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, ) -> dict: """Draft a full campaign structure — returns preview, does NOT execute. @@ -328,6 +561,15 @@ def draft_campaign( except SafetyViolation as e: return {"error": str(e)} + normalized_display_network_enabled, alias_errors = _normalize_display_network_setting( + display_network_enabled, + display_expansion_enabled, + ) + if alias_errors: + return {"error": "Validation failed", "details": alias_errors} + if normalized_display_network_enabled is None: + normalized_display_network_enabled = False + errors, warnings = _validate_campaign( config, campaign_name=campaign_name, @@ -339,6 +581,9 @@ def draft_campaign( keywords=keywords, geo_target_ids=geo_target_ids, language_ids=language_ids, + search_partners_enabled=search_partners_enabled, + display_network_enabled=normalized_display_network_enabled, + max_cpc=max_cpc, ) if errors: return {"error": "Validation failed", "details": errors} @@ -363,6 +608,9 @@ def draft_campaign( "keywords": keywords, "geo_target_ids": geo_target_ids or [], "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, }, ) store_plan(plan) @@ -383,6 +631,10 @@ def update_campaign( daily_budget: float = 0, geo_target_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, ) -> dict: """Draft an update to an existing campaign — returns preview, does NOT execute. @@ -404,6 +656,12 @@ def update_campaign( errors = [] warnings = [] + normalized_display_network_enabled, alias_errors = _normalize_display_network_setting( + display_network_enabled, + display_expansion_enabled, + ) + errors.extend(alias_errors) + if not campaign_id: errors.append("campaign_id is required") @@ -417,6 +675,8 @@ def update_campaign( 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") + if max_cpc < 0: + errors.append("max_cpc cannot be negative") if daily_budget and daily_budget <= 0: errors.append("daily_budget must be greater than 0") @@ -431,9 +691,21 @@ 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") + if max_cpc: + strategy_for_cap = bs or _campaign_bidding_strategy(config, customer_id, campaign_id) + if strategy_for_cap is None: + errors.append("campaign_id was not found") + elif strategy_for_cap != "TARGET_SPEND": + errors.append("max_cpc requires TARGET_SPEND bidding_strategy") has_any_change = any([ - bs, daily_budget, geo_target_ids is not None, language_ids is not None, + bs, + daily_budget, + geo_target_ids is not None, + language_ids is not None, + search_partners_enabled is not None, + normalized_display_network_enabled is not None, + max_cpc, ]) if not has_any_change: errors.append("No changes specified — provide at least one parameter to update") @@ -466,6 +738,12 @@ def update_campaign( changes["geo_target_ids"] = geo_target_ids if language_ids is not None: changes["language_ids"] = language_ids + if search_partners_enabled is not None: + changes["search_partners_enabled"] = search_partners_enabled + if normalized_display_network_enabled is not None: + changes["display_network_enabled"] = normalized_display_network_enabled + if max_cpc: + changes["max_cpc"] = max_cpc plan = ChangePlan( operation="update_campaign", @@ -481,6 +759,110 @@ def update_campaign( return preview +def draft_callouts( + config: AdLoopConfig, + *, + customer_id: str = "", + campaign_id: str = "", + callouts: list[str] | None = None, +) -> dict: + """Draft campaign callout assets.""" + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("create_callouts", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + validated_callouts, errors = _validate_callouts(campaign_id, callouts or []) + if errors: + return {"error": "Validation failed", "details": errors} + + plan = ChangePlan( + operation="create_callouts", + entity_type="campaign_asset", + entity_id=campaign_id, + customer_id=customer_id, + changes={ + "campaign_id": campaign_id, + "callouts": validated_callouts, + }, + ) + store_plan(plan) + return plan.to_preview() + + +def draft_structured_snippets( + config: AdLoopConfig, + *, + customer_id: str = "", + campaign_id: str = "", + snippets: list[dict] | None = None, +) -> dict: + """Draft campaign structured snippet assets.""" + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("create_structured_snippets", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + validated_snippets, errors = _validate_structured_snippets( + campaign_id, snippets or [] + ) + if errors: + return {"error": "Validation failed", "details": errors} + + plan = ChangePlan( + operation="create_structured_snippets", + entity_type="campaign_asset", + entity_id=campaign_id, + customer_id=customer_id, + changes={ + "campaign_id": campaign_id, + "snippets": validated_snippets, + }, + ) + store_plan(plan) + return plan.to_preview() + + +def draft_image_assets( + config: AdLoopConfig, + *, + customer_id: str = "", + campaign_id: str = "", + image_paths: list[str] | None = None, +) -> dict: + """Draft campaign image assets from local files.""" + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("create_image_assets", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + validated_images, errors = _validate_image_assets(campaign_id, image_paths or []) + if errors: + return {"error": "Validation failed", "details": errors} + + plan = ChangePlan( + operation="create_image_assets", + entity_type="campaign_asset", + entity_id=campaign_id, + customer_id=customer_id, + changes={ + "campaign_id": campaign_id, + "images": validated_images, + }, + ) + store_plan(plan) + return plan.to_preview() + + def draft_sitelinks( config: AdLoopConfig, *, @@ -690,6 +1072,137 @@ def confirm_and_apply( } +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" + + +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 + + 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") + + +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" + + +def _validate_callouts( + campaign_id: str, callouts: list[str] +) -> tuple[list[str], list[str]]: + errors = [] + validated = [] + + if not campaign_id: + errors.append("campaign_id is required") + if not callouts: + errors.append("At least one callout is required") + + 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) + + return validated, errors + + +def _validate_structured_snippets( + campaign_id: str, snippets: list[dict] +) -> tuple[list[dict], list[str]]: + errors = [] + validated = [] + + if not campaign_id: + errors.append("campaign_id is required") + 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( + campaign_id: str, image_paths: list[str] +) -> tuple[list[dict[str, object]], list[str]]: + errors = [] + validated = [] + + if not campaign_id: + errors.append("campaign_id is required") + 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, @@ -785,6 +1298,9 @@ def _validate_campaign( keywords: list[dict] | None, geo_target_ids: list[str] | None, language_ids: list[str] | None, + search_partners_enabled: bool, + display_network_enabled: bool, + max_cpc: float, ) -> tuple[list[str], list[str]]: """Validate campaign draft inputs. Returns (errors, warnings).""" errors = [] @@ -822,6 +1338,14 @@ def _validate_campaign( 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( @@ -929,13 +1453,18 @@ def _execute_plan(config: AdLoopConfig, plan: object) -> dict: 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, "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, } @@ -958,6 +1487,46 @@ def _execute_plan(config: AdLoopConfig, plan: object) -> dict: return handler(client, cid, plan.changes) +def _apply_create_ad_group(client: object, cid: str, changes: dict) -> dict: + """Create a paused SEARCH_STANDARD ad group in an existing campaign.""" + service = client.get_service("AdGroupService") + campaign_service = client.get_service("CampaignService") + operation = client.get_type("AdGroupOperation") + ad_group = operation.create + + ad_group.name = changes["ad_group_name"] + ad_group.campaign = campaign_service.campaign_path(cid, changes["campaign_id"]) + ad_group.status = client.enums.AdGroupStatusEnum.PAUSED + ad_group.type_ = client.enums.AdGroupTypeEnum.SEARCH_STANDARD + if changes.get("max_cpc"): + ad_group.cpc_bid_micros = int(changes["max_cpc"] * 1_000_000) + + response = service.mutate_ad_groups(customer_id=cid, operations=[operation]) + return {"resource_name": response.results[0].resource_name} + + +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") @@ -1009,12 +1578,20 @@ def _apply_create_campaign(client: object, cid: str, changes: dict) -> dict: 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 = False - campaign.network_settings.target_content_network = False + 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 @@ -1034,6 +1611,8 @@ def _apply_create_campaign(client: object, cid: str, changes: dict) -> dict: 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) @@ -1107,9 +1686,16 @@ def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict: campaign_id = changes["campaign_id"] resource_name = campaign_service.campaign_path(cid, campaign_id) - # Bid strategy change + # Bid strategy and campaign-level setting changes bs = changes.get("bidding_strategy") - if bs: + 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 @@ -1143,6 +1729,19 @@ def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict: 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) @@ -1478,36 +2077,32 @@ def _apply_status_change( return {"resource_name": response.results[0].resource_name} -def _apply_create_sitelinks(client: object, cid: str, changes: dict) -> dict: - """Create sitelink assets and link them to a campaign.""" +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.""" asset_service = client.get_service("AssetService") - campaign_asset_service = client.get_service("CampaignAssetService") googleads_service = client.get_service("GoogleAdsService") - - campaign_id = changes["campaign_id"] - sitelinks = changes["sitelinks"] operations = [] - # Create Asset resources (one per sitelink) with temp IDs starting at -1 - for i, sl in enumerate(sitelinks): + 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))) - asset.sitelink_asset.link_text = sl["link_text"] - asset.final_urls.append(sl["final_url"]) - if sl.get("description1"): - asset.sitelink_asset.description1 = sl["description1"] - if sl.get("description2"): - asset.sitelink_asset.description2 = sl["description2"] + populate_asset(asset, payload) operations.append(op) - # Link each asset to the campaign - for i in range(len(sitelinks)): + 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 = client.enums.AssetFieldTypeEnum.SITELINK + ca.field_type = field_type operations.append(op) response = googleads_service.mutate( @@ -1515,7 +2110,7 @@ def _apply_create_sitelinks(client: object, cid: str, changes: dict) -> dict: ) results = {"assets": [], "campaign_assets": []} - num_sitelinks = len(sitelinks) + num_assets = len(assets) for i, resp in enumerate(response.mutate_operation_responses): resource = None if resp.asset_result.resource_name: @@ -1524,9 +2119,90 @@ def _apply_create_sitelinks(client: object, cid: str, changes: dict) -> dict: resource = resp.campaign_asset_result.resource_name if resource: - if i < num_sitelinks: + if i < num_assets: results["assets"].append(resource) else: results["campaign_assets"].append(resource) return results + + +def _apply_create_callouts(client: object, cid: str, changes: dict) -> dict: + """Create callout assets and link them to a campaign.""" + + def populate(asset: object, payload: dict) -> None: + asset.callout_asset.callout_text = payload["callout_text"] + + assets = [{"callout_text": text} for text in changes["callouts"]] + return _apply_campaign_assets( + client, + cid, + changes["campaign_id"], + assets, + client.enums.AssetFieldTypeEnum.CALLOUT, + populate, + ) + + +def _apply_create_structured_snippets( + client: object, cid: str, changes: dict +) -> dict: + """Create structured snippet assets and link them to a campaign.""" + + def populate(asset: object, payload: dict) -> None: + asset.structured_snippet_asset.header = payload["header"] + asset.structured_snippet_asset.values.extend(payload["values"]) + + return _apply_campaign_assets( + client, + cid, + changes["campaign_id"], + changes["snippets"], + client.enums.AssetFieldTypeEnum.STRUCTURED_SNIPPET, + populate, + ) + + +def _apply_create_image_assets(client: object, cid: str, changes: dict) -> dict: + """Create image assets from local files and link them to a campaign.""" + + 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 _apply_create_sitelinks(client: object, cid: str, changes: dict) -> dict: + """Create sitelink assets and link them to a campaign.""" + + def populate(asset: object, payload: dict) -> None: + asset.sitelink_asset.link_text = payload["link_text"] + asset.final_urls.append(payload["final_url"]) + if payload.get("description1"): + asset.sitelink_asset.description1 = payload["description1"] + if payload.get("description2"): + asset.sitelink_asset.description2 = payload["description2"] + + return _apply_campaign_assets( + client, + cid, + changes["campaign_id"], + changes["sitelinks"], + client.enums.AssetFieldTypeEnum.SITELINK, + populate, + ) diff --git a/src/adloop/server.py b/src/adloop/server.py index a4e118c..2ecdf19 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -26,6 +26,61 @@ _config = load_config() +def _structured_error(fn_name: str, exc: Exception) -> dict: + """Translate common auth failures into actionable structured errors.""" + err = str(exc) + err_lower = err.lower() + + if "developer_token_not_approved" in err_lower or "only approved for use with test accounts" in err_lower: + return { + "error": ( + "Google Ads authorization failed — developer token is not " + "approved for production accounts." + ), + "hint": ( + "This developer token can only access Google Ads test accounts. " + "Apply for Basic or Standard access in the Google Ads API Center, " + "or switch AdLoop to a test account." + ), + "auth_error": "DEVELOPER_TOKEN_NOT_APPROVED", + } + + if "developer_token_invalid" in err_lower or "developer token is not valid" in err_lower: + return { + "error": "Google Ads authentication failed — developer token is invalid.", + "hint": ( + "Update `ads.developer_token` in `~/.adloop/config.yaml` with " + "the token from your Google Ads manager account API Center. " + "OAuth is working if GA4 tools succeed." + ), + "auth_error": "DEVELOPER_TOKEN_INVALID", + } + + if "invalid_grant" in err_lower or "revoked" in err_lower: + return { + "error": "Authentication failed — OAuth token expired or revoked.", + "hint": ( + "Delete ~/.adloop/token.json and re-run any tool to " + "trigger re-authorization. If this keeps happening, " + "publish the GCP consent screen to 'In production'." + ), + "auth_error": "INVALID_GRANT", + } + + if "statuscode.unauthenticated" in err_lower: + return { + "error": "Authentication failed — Google rejected the request as unauthenticated.", + "hint": ( + "If GA4 tools work but Ads tools fail, check `ads.developer_token`. " + "Otherwise delete ~/.adloop/token.json and re-run any tool to " + "trigger re-authorization." + ), + "details": err, + } + + return {"error": err, "tool": fn_name} + + def _safe(fn: Callable) -> Callable: """Wrap a tool function so exceptions return structured error dicts.""" @@ -36,17 +91,7 @@ def wrapper(*args, **kwargs): except RuntimeError as e: return {"error": str(e)} except Exception as e: - err = str(e).lower() - if "invalid_grant" in err or "revoked" in err: - return { - "error": "Authentication failed — OAuth token expired or revoked.", - "hint": ( - "Delete ~/.adloop/token.json and re-run any tool to " - "trigger re-authorization. If this keeps happening, " - "publish the GCP consent screen to 'In production'." - ), - } - return {"error": str(e), "tool": fn.__name__} + return _structured_error(fn.__name__, e) return wrapper @@ -91,8 +136,15 @@ def health_check() -> dict: status["ga4"] = "ok" status["ga4_properties"] = result.get("total_properties", 0) except Exception as e: + parsed = _structured_error("health_check", e) status["ga4"] = "error" - status["ga4_error"] = str(e) + status["ga4_error"] = parsed["error"] + if "hint" in parsed: + status["ga4_hint"] = parsed["hint"] + if "auth_error" in parsed: + status["ga4_auth_error"] = parsed["auth_error"] + if "details" in parsed: + status["ga4_error_details"] = parsed["details"] try: from adloop.ads.read import list_accounts as _ads_test @@ -101,18 +153,21 @@ def health_check() -> dict: status["ads"] = "ok" status["ads_accounts"] = result.get("total_accounts", 0) except Exception as e: + parsed = _structured_error("health_check", e) status["ads"] = "error" - status["ads_error"] = str(e) + status["ads_error"] = parsed["error"] + if "hint" in parsed: + status["ads_hint"] = parsed["hint"] + if "auth_error" in parsed: + status["ads_auth_error"] = parsed["auth_error"] + if "details" in parsed: + status["ads_error_details"] = parsed["details"] if status["ga4"] == "error" or status["ads"] == "error": - any_error = status.get("ga4_error", "") + status.get("ads_error", "") - if "invalid_grant" in any_error.lower() or "revoked" in any_error.lower(): - status["hint"] = ( - "OAuth token expired or revoked. Delete ~/.adloop/token.json " - "and re-run health_check to trigger re-authorization. " - "To prevent recurring expiry, publish the GCP consent screen " - "from 'Testing' to 'In production'." - ) + if status.get("ads_hint"): + status["hint"] = status["ads_hint"] + elif status.get("ga4_hint"): + status["hint"] = status["ga4_hint"] return status @@ -468,6 +523,10 @@ def draft_campaign( 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, ) -> dict: """Draft a full campaign structure — returns a PREVIEW, does NOT create anything. @@ -480,6 +539,12 @@ def draft_campaign( 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. @@ -504,6 +569,10 @@ def draft_campaign( 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, ) @@ -518,6 +587,10 @@ def update_campaign( daily_budget: float = 0, geo_target_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, ) -> dict: """Draft an update to an existing campaign — returns a PREVIEW, does NOT apply. @@ -533,6 +606,11 @@ def update_campaign( "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 Call confirm_and_apply with the returned plan_id to execute. """ @@ -548,6 +626,10 @@ def update_campaign( 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, ) @@ -628,6 +710,100 @@ def add_negative_keywords( ) +@mcp.tool(annotations=_WRITE) +@_safe +def draft_ad_group( + campaign_id: str, + ad_group_name: str, + customer_id: str = "", + max_cpc: float = 0, +) -> dict: + """Draft a paused SEARCH_STANDARD ad group in an existing campaign.""" + 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, + max_cpc=max_cpc, + ) + + +@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 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( + campaign_id: str, + callouts: list[str], + customer_id: str = "", +) -> dict: + """Draft campaign callout assets — returns a PREVIEW.""" + 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( + campaign_id: str, + snippets: list[dict], + customer_id: str = "", +) -> dict: + """Draft campaign structured snippet assets — returns a PREVIEW.""" + 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( + campaign_id: str, + image_paths: list[str], + 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 + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + campaign_id=campaign_id, + image_paths=image_paths, + ) + + @mcp.tool(annotations=_WRITE) @_safe def pause_entity( diff --git a/tests/test_ads_write.py b/tests/test_ads_write.py new file mode 100644 index 0000000..5cb359d --- /dev/null +++ b/tests/test_ads_write.py @@ -0,0 +1,599 @@ +"""Tests for Google Ads write planning and mutate helpers.""" + +from __future__ import annotations + +import base64 +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 + + +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._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}" + + +class _FakeAdGroupService(_FakePathService): + def __init__(self): + super().__init__("adGroups") + self.operations = None + + def mutate_ad_groups(self, customer_id: str, operations: list[object]) -> object: + self.operations = operations + return SimpleNamespace( + results=[SimpleNamespace(resource_name=f"customers/{customer_id}/adGroups/1")] + ) + + +class _FakeGoogleAdsService(_FakePathService): + def __init__(self, responses: list[_FakeMutateOperationResponse] | None = None): + super().__init__("campaigns") + self.operations = None + self._responses = responses or [] + + 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]: + raise AssertionError(f"Unexpected search call for customer {customer_id}: {query}") + + +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), + ) + + +def test_draft_ad_group_returns_preview_for_manual_cpc(config, monkeypatch): + monkeypatch.setattr(write, "_campaign_uses_manual_cpc", lambda *_args: True) + + result = write.draft_ad_group( + config, + customer_id="123-456-7890", + campaign_id="1001", + ad_group_name="Brand Terms", + max_cpc=2.5, + ) + + assert result["operation"] == "create_ad_group" + assert result["changes"]["campaign_id"] == "1001" + assert result["changes"]["max_cpc"] == 2.5 + + +def test_draft_ad_group_rejects_max_cpc_without_manual_cpc(config, monkeypatch): + monkeypatch.setattr(write, "_campaign_uses_manual_cpc", lambda *_args: False) + + result = write.draft_ad_group( + config, + customer_id="123-456-7890", + campaign_id="1001", + ad_group_name="Brand Terms", + max_cpc=2.5, + ) + + assert result["error"] == "Validation failed" + assert "MANUAL_CPC" in result["details"][0] + + +def test_update_ad_group_requires_a_change(config): + result = write.update_ad_group( + config, + customer_id="123-456-7890", + ad_group_id="2002", + ) + + assert result["error"] == "Validation failed" + assert "No changes specified" in result["details"][0] + + +def test_draft_campaign_normalizes_display_expansion_alias(config): + result = write.draft_campaign( + config, + customer_id="123-456-7890", + campaign_name="Search Launch", + daily_budget=50, + bidding_strategy="MANUAL_CPC", + geo_target_ids=["2840"], + language_ids=["1000"], + display_expansion_enabled=True, + search_partners_enabled=True, + max_cpc=1.75, + ) + + assert result["changes"]["display_network_enabled"] is True + assert result["changes"]["search_partners_enabled"] is True + assert result["changes"]["max_cpc"] == 1.75 + + +def test_draft_campaign_allows_target_spend_cpc_cap(config): + result = write.draft_campaign( + config, + customer_id="123-456-7890", + campaign_name="Traffic Launch", + daily_budget=50, + bidding_strategy="TARGET_SPEND", + geo_target_ids=["2840"], + language_ids=["1000"], + max_cpc=1.75, + ) + + assert result["operation"] == "create_campaign" + assert result["changes"]["bidding_strategy"] == "TARGET_SPEND" + assert result["changes"]["max_cpc"] == 1.75 + + +def test_draft_campaign_rejects_conflicting_display_flags(config): + result = write.draft_campaign( + config, + customer_id="123-456-7890", + campaign_name="Search Launch", + daily_budget=50, + bidding_strategy="MANUAL_CPC", + geo_target_ids=["2840"], + language_ids=["1000"], + display_network_enabled=False, + display_expansion_enabled=True, + ) + + assert result["error"] == "Validation failed" + assert "must match" in result["details"][0] + + +def test_update_campaign_normalizes_display_alias(config): + result = write.update_campaign( + config, + customer_id="123-456-7890", + campaign_id="1001", + display_expansion_enabled=True, + search_partners_enabled=False, + ) + + assert result["changes"]["display_network_enabled"] is True + assert result["changes"]["search_partners_enabled"] is False + + +def test_update_campaign_allows_target_spend_cpc_cap(config, monkeypatch): + monkeypatch.setattr(write, "_campaign_bidding_strategy", lambda *_args: "TARGET_SPEND") + + result = write.update_campaign( + config, + customer_id="123-456-7890", + campaign_id="1001", + max_cpc=1.25, + ) + + assert result["changes"]["max_cpc"] == 1.25 + + +def test_update_campaign_rejects_max_cpc_for_non_target_spend(config, monkeypatch): + monkeypatch.setattr(write, "_campaign_bidding_strategy", lambda *_args: "MANUAL_CPC") + + result = write.update_campaign( + config, + customer_id="123-456-7890", + campaign_id="1001", + max_cpc=1.25, + ) + + assert result["error"] == "Validation failed" + assert "TARGET_SPEND" in result["details"][0] + + +def test_draft_structured_snippets_rejects_invalid_header(config): + result = write.draft_structured_snippets( + config, + customer_id="123-456-7890", + campaign_id="1001", + snippets=[{"header": "Invalid", "values": ["A", "B", "C"]}], + ) + + assert result["error"] == "Validation failed" + assert "header must be one of" in result["details"][0] + + +def test_draft_callouts_returns_preview(config): + result = write.draft_callouts( + config, + customer_id="123-456-7890", + campaign_id="1001", + callouts=["Free Shipping", "24/7 Support"], + ) + + assert result["operation"] == "create_callouts" + assert result["changes"]["callouts"] == ["Free Shipping", "24/7 Support"] + + +def test_draft_image_assets_validates_local_png(config, tmp_path): + image_path = tmp_path / "square.png" + image_path.write_bytes( + base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2ZfZ0AAAAASUVORK5CYII=" + ) + ) + + result = write.draft_image_assets( + config, + customer_id="123-456-7890", + campaign_id="1001", + image_paths=[str(image_path)], + ) + + assert result["operation"] == "create_image_assets" + assert result["changes"]["images"][0]["name"].startswith("AdLoop image square ") + assert result["changes"]["images"][0]["mime_type"] == "image/png" + assert result["changes"]["images"][0]["width"] == 1 + assert result["changes"]["images"][0]["height"] == 1 + + +def test_draft_image_assets_rejects_missing_file(config): + result = write.draft_image_assets( + config, + customer_id="123-456-7890", + campaign_id="1001", + image_paths=["/tmp/does-not-exist.png"], + ) + + assert result["error"] == "Validation failed" + assert "does not exist" in result["details"][0] + + +def test_pause_and_enable_entity_still_support_ad_groups(config): + pause_result = write.pause_entity( + config, + customer_id="123-456-7890", + entity_type="ad_group", + entity_id="2002", + ) + enable_result = write.enable_entity( + config, + customer_id="123-456-7890", + entity_type="ad_group", + entity_id="2002", + ) + + assert pause_result["changes"]["target_status"] == "PAUSED" + assert enable_result["changes"]["target_status"] == "ENABLED" + + +def test_apply_create_ad_group_sets_manual_cpc_fields(): + ad_group_service = _FakeAdGroupService() + client = _FakeClient( + { + "AdGroupService": ad_group_service, + "CampaignService": _FakePathService("campaigns"), + } + ) + + result = write._apply_create_ad_group( + client, + "1234567890", + {"campaign_id": "1001", "ad_group_name": "Brand Terms", "max_cpc": 2.5}, + ) + + operation = ad_group_service.operations[0] + ad_group = operation.create + assert ad_group.status == client.enums.AdGroupStatusEnum.PAUSED + assert ad_group.type_ == client.enums.AdGroupTypeEnum.SEARCH_STANDARD + assert ad_group.cpc_bid_micros == 2_500_000 + assert result["resource_name"].endswith("/adGroups/1") + + +def test_apply_update_ad_group_sets_field_mask(): + ad_group_service = _FakeAdGroupService() + client = _FakeClient({"AdGroupService": ad_group_service}) + + write._apply_update_ad_group( + client, + "1234567890", + {"ad_group_id": "2002", "ad_group_name": "Updated Name", "max_cpc": 1.1}, + ) + + operation = ad_group_service.operations[0] + assert set(operation.update_mask.paths) == {"name", "cpc_bid_micros"} + assert operation.update.name == "Updated Name" + assert operation.update.cpc_bid_micros == 1_100_000 + + +def test_apply_create_campaign_sets_network_flags_and_initial_cpc(): + google_ads_service = _FakeGoogleAdsService( + [ + _FakeMutateOperationResponse( + "campaign_budget_result", + "customers/1234567890/campaignBudgets/1", + ), + _FakeMutateOperationResponse( + "campaign_result", + "customers/1234567890/campaigns/2", + ), + _FakeMutateOperationResponse( + "ad_group_result", + "customers/1234567890/adGroups/3", + ), + ] + ) + client = _FakeClient( + { + "GoogleAdsService": google_ads_service, + "CampaignService": _FakePathService("campaigns"), + "CampaignBudgetService": _FakePathService("campaignBudgets"), + "AdGroupService": _FakePathService("adGroups"), + } + ) + + write._apply_create_campaign( + client, + "1234567890", + { + "campaign_name": "Search Launch", + "daily_budget": 50, + "bidding_strategy": "MANUAL_CPC", + "channel_type": "SEARCH", + "ad_group_name": "Brand Terms", + "geo_target_ids": [], + "language_ids": [], + "search_partners_enabled": True, + "display_network_enabled": True, + "max_cpc": 1.75, + }, + ) + + campaign = google_ads_service.operations[1].campaign_operation.create + ad_group = google_ads_service.operations[2].ad_group_operation.create + assert campaign.network_settings.target_search_network is True + assert campaign.network_settings.target_content_network is True + assert ad_group.cpc_bid_micros == 1_750_000 + + +def test_apply_create_campaign_sets_target_spend_cpc_cap(): + google_ads_service = _FakeGoogleAdsService( + [ + _FakeMutateOperationResponse( + "campaign_budget_result", + "customers/1234567890/campaignBudgets/1", + ), + _FakeMutateOperationResponse( + "campaign_result", + "customers/1234567890/campaigns/2", + ), + _FakeMutateOperationResponse( + "ad_group_result", + "customers/1234567890/adGroups/3", + ), + ] + ) + client = _FakeClient( + { + "GoogleAdsService": google_ads_service, + "CampaignService": _FakePathService("campaigns"), + "CampaignBudgetService": _FakePathService("campaignBudgets"), + "AdGroupService": _FakePathService("adGroups"), + } + ) + + write._apply_create_campaign( + client, + "1234567890", + { + "campaign_name": "Traffic Launch", + "daily_budget": 50, + "bidding_strategy": "TARGET_SPEND", + "channel_type": "SEARCH", + "ad_group_name": "Traffic Terms", + "geo_target_ids": [], + "language_ids": [], + "max_cpc": 1.4, + }, + ) + + campaign = google_ads_service.operations[1].campaign_operation.create + ad_group = google_ads_service.operations[2].ad_group_operation.create + assert campaign.target_spend.cpc_bid_ceiling_micros == 1_400_000 + assert ad_group.cpc_bid_micros == 0 + + +def test_apply_update_campaign_sets_network_field_masks(): + google_ads_service = _FakeGoogleAdsService( + [_FakeMutateOperationResponse("campaign_result", "customers/1234567890/campaigns/1")] + ) + client = _FakeClient( + { + "GoogleAdsService": google_ads_service, + "CampaignService": _FakePathService("campaigns"), + } + ) + + write._apply_update_campaign( + client, + "1234567890", + { + "campaign_id": "1001", + "search_partners_enabled": True, + "display_network_enabled": False, + }, + ) + + operation = google_ads_service.operations[0].campaign_operation + assert set(operation.update_mask.paths) == { + "network_settings.target_content_network", + "network_settings.target_search_network", + } + assert operation.update.network_settings.target_search_network is True + assert operation.update.network_settings.target_content_network is False + + +def test_apply_update_campaign_sets_target_spend_cpc_cap(): + google_ads_service = _FakeGoogleAdsService( + [_FakeMutateOperationResponse("campaign_result", "customers/1234567890/campaigns/1")] + ) + client = _FakeClient( + { + "GoogleAdsService": google_ads_service, + "CampaignService": _FakePathService("campaigns"), + } + ) + + write._apply_update_campaign( + client, + "1234567890", + { + "campaign_id": "1001", + "max_cpc": 1.3, + }, + ) + + operation = google_ads_service.operations[0].campaign_operation + assert set(operation.update_mask.paths) == {"target_spend.cpc_bid_ceiling_micros"} + assert operation.update.target_spend.cpc_bid_ceiling_micros == 1_300_000 + + +def test_apply_campaign_asset_variants_create_asset_and_link_operations(tmp_path): + image_path = tmp_path / "square.png" + image_path.write_bytes( + base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2ZfZ0AAAAASUVORK5CYII=" + ) + ) + + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1234567890/assets/1"), + _FakeMutateOperationResponse( + "campaign_asset_result", + "customers/1234567890/campaignAssets/1001~1~CALLOUT", + ), + ] + + google_ads_service = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads_service, + "AssetService": _FakePathService("assets"), + } + ) + + write._apply_create_callouts( + client, + "1234567890", + {"campaign_id": "1001", "callouts": ["Free Shipping"]}, + ) + callout_link = google_ads_service.operations[1].campaign_asset_operation.create + assert callout_link.field_type == client.enums.AssetFieldTypeEnum.CALLOUT + + google_ads_service._responses = responses + write._apply_create_structured_snippets( + client, + "1234567890", + { + "campaign_id": "1001", + "snippets": [{"header": "Brands", "values": ["A", "B", "C"]}], + }, + ) + snippet_link = google_ads_service.operations[1].campaign_asset_operation.create + assert snippet_link.field_type == client.enums.AssetFieldTypeEnum.STRUCTURED_SNIPPET + + google_ads_service._responses = responses + write._apply_create_image_assets( + client, + "1234567890", + { + "campaign_id": "1001", + "images": [ + { + "path": str(image_path), + "name": "AdLoop image square deadbeefcafe", + "mime_type": "image/png", + "width": 1, + "height": 1, + } + ], + }, + ) + image_asset = google_ads_service.operations[0].asset_operation.create + image_link = google_ads_service.operations[1].campaign_asset_operation.create + 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 + + google_ads_service._responses = responses + write._apply_create_image_assets( + client, + "1234567890", + { + "campaign_id": "1001", + "images": [ + { + "path": str(image_path), + "mime_type": "image/png", + "width": 1, + "height": 1, + } + ], + }, + ) + fallback_image_asset = google_ads_service.operations[0].asset_operation.create + assert fallback_image_asset.name.startswith("AdLoop image square ") diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..c997afa --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,67 @@ +"""Tests for server error formatting.""" + +from adloop.ads.gaql import _parse_gaql_error +from adloop.server import _structured_error + + +def test_structured_error_detects_invalid_developer_token(): + error = Exception( + "errors { error_code { authentication_error: DEVELOPER_TOKEN_INVALID } " + 'message: "The developer token is not valid." }' + ) + + result = _structured_error("list_accounts", error) + + assert result["error"] == "Google Ads authentication failed — developer token is invalid." + assert result["auth_error"] == "DEVELOPER_TOKEN_INVALID" + assert "ads.developer_token" in result["hint"] + + +def test_structured_error_detects_test_only_developer_token(): + error = Exception( + "errors { error_code { authorization_error: DEVELOPER_TOKEN_NOT_APPROVED } " + 'message: "The developer token is only approved for use with test accounts." }' + ) + + result = _structured_error("list_accounts", error) + + assert result["error"] == ( + "Google Ads authorization failed — developer token is not approved " + "for production accounts." + ) + assert result["auth_error"] == "DEVELOPER_TOKEN_NOT_APPROVED" + assert "test accounts" in result["hint"] + + +def test_structured_error_detects_revoked_oauth_token(): + error = Exception("invalid_grant: Token has been expired or revoked.") + + result = _structured_error("health_check", error) + + assert result["error"] == "Authentication failed — OAuth token expired or revoked." + assert result["auth_error"] == "INVALID_GRANT" + assert "~/.adloop/token.json" in result["hint"] + + +def test_parse_gaql_error_detects_invalid_developer_token(): + error = Exception( + "errors { error_code { authentication_error: DEVELOPER_TOKEN_INVALID } " + 'message: "The developer token is not valid." }' + ) + + result = _parse_gaql_error(error) + + assert result.startswith("DEVELOPER_TOKEN_INVALID:") + assert "ads.developer_token" in result + + +def test_parse_gaql_error_detects_test_only_developer_token(): + error = Exception( + "errors { error_code { authorization_error: DEVELOPER_TOKEN_NOT_APPROVED } " + 'message: "The developer token is only approved for use with test accounts." }' + ) + + result = _parse_gaql_error(error) + + assert result.startswith("DEVELOPER_TOKEN_NOT_APPROVED:") + assert "test accounts" in result