diff --git a/scripts/generate_mcp_tools.py b/scripts/generate_mcp_tools.py index 894a755b..9eb5981a 100644 --- a/scripts/generate_mcp_tools.py +++ b/scripts/generate_mcp_tools.py @@ -21,6 +21,7 @@ from typing import Any import yaml +from openapi_body import flatten_request_body_schema # Map OpenAPI tags to SDK resource names TAG_TO_RESOURCE: dict[str, str] = { @@ -361,8 +362,20 @@ def add_param(entry: dict[str, Any]) -> None: content = request_body.get("content", {}) json_content = content.get("application/json", {}) schema = json_content.get("schema", {}) - properties = schema.get("properties", {}) - required_props = schema.get("required", []) + + flattened = flatten_request_body_schema(schema, spec or {}) + if flattened is None: + add_param({ + "name": "body", + "type": "dict[str, Any]", + "required": True, + "default": "", + "description": "Full request body as documented in the API reference.", + "sdk_name": "body", + }) + return params + properties = flattened["properties"] + required_props = flattened["required"] for prop_name, prop_schema in properties.items(): py_name = camel_to_snake(prop_name) diff --git a/scripts/generate_resources.py b/scripts/generate_resources.py index 7b80b9b9..5a8c46e2 100644 --- a/scripts/generate_resources.py +++ b/scripts/generate_resources.py @@ -22,6 +22,7 @@ from typing import Any import yaml +from openapi_body import flatten_request_body_schema # Map OpenAPI tags to resource class names TAG_TO_RESOURCE: dict[str, str] = { @@ -127,7 +128,9 @@ def get_python_type(schema: dict[str, Any], required: bool = True) -> str: return base -def extract_parameters(operation: dict[str, Any]) -> list[dict[str, Any]]: +def extract_parameters( + operation: dict[str, Any], spec: dict[str, Any] +) -> list[dict[str, Any]]: """Extract parameters from an operation.""" params = [] @@ -179,20 +182,31 @@ def extract_parameters(operation: dict[str, Any]) -> list[dict[str, Any]]: json_content = content.get("application/json", {}) schema = json_content.get("schema", {}) - # Handle properties in request body - properties = schema.get("properties", {}) - required_props = schema.get("required", []) - - for prop_name, prop_schema in properties.items(): + flattened = flatten_request_body_schema(schema, spec) + if flattened is None: params.append({ - "name": camel_to_snake(prop_name), - "original_name": prop_name, - "type": get_python_type(prop_schema, prop_name in required_props), - "required": prop_name in required_props, - "description": prop_schema.get("description", ""), - "in": "body", - "default": prop_schema.get("default"), + "name": "body", + "original_name": "body", + "type": "dict[str, Any]", + "required": True, + "description": "Full request body as documented in the API reference.", + "in": "raw_body", + "default": None, }) + else: + properties = flattened["properties"] + required_props = flattened["required"] + + for prop_name, prop_schema in properties.items(): + params.append({ + "name": camel_to_snake(prop_name), + "original_name": prop_name, + "type": get_python_type(prop_schema, prop_name in required_props), + "required": prop_name in required_props, + "description": prop_schema.get("description", ""), + "in": "body", + "default": prop_schema.get("default"), + }) # Path/query params and body props share one kwarg list; two spec names that # snake_case to the same kwarg would emit a "duplicate argument" SyntaxError @@ -278,6 +292,7 @@ def generate_method_body( # Build query params query_params = [p for p in params if p["in"] == "query"] body_params = [p for p in params if p["in"] == "body"] + raw_body_params = [p for p in params if p["in"] == "raw_body"] path_params = [p for p in params if p["in"] == "path"] # Handle path parameters @@ -329,6 +344,8 @@ def generate_method_body( call_args = [path_expr] if body_params: call_args.append("data=payload") + elif raw_body_params: + call_args.append(f"data={raw_body_params[0]['name']}") if query_params: call_args.append("params=params") lines.append( @@ -604,7 +621,7 @@ def main() -> int: "path": path, "summary": operation.get("summary", ""), "description": operation.get("description", ""), - "params": extract_parameters(operation), + "params": extract_parameters(operation, spec), }) # Paths diff --git a/scripts/openapi_body.py b/scripts/openapi_body.py new file mode 100644 index 00000000..4815d589 --- /dev/null +++ b/scripts/openapi_body.py @@ -0,0 +1,49 @@ +"""Shared request-body flattening for the SDK and MCP code generators.""" + +from __future__ import annotations + +from typing import Any + + +def resolve_local_ref(ref: str, spec: dict[str, Any]) -> dict[str, Any]: + node: Any = spec + for part in ref.lstrip("#/").split("/"): + if not isinstance(node, dict): + return {} + node = node.get(part, {}) + return node if isinstance(node, dict) else {} + + +def flatten_request_body_schema( + schema: dict[str, Any], spec: dict[str, Any] +) -> dict[str, Any] | None: + """Flatten a JSON request-body schema into {"properties", "required"}. + + Resolves $ref and merges allOf branches. Returns None for oneOf/anyOf + unions: their variants cannot share one kwargs signature, so callers must + fall back to a raw body parameter instead of dropping the body entirely. + """ + if not isinstance(schema, dict) or not schema: + return {"properties": {}, "required": []} + if "$ref" in schema: + return flatten_request_body_schema( + resolve_local_ref(schema["$ref"], spec), spec + ) + if "oneOf" in schema or "anyOf" in schema: + return None + if "allOf" in schema: + properties: dict[str, Any] = {} + required: list[str] = [] + for branch in schema["allOf"]: + flattened_branch = flatten_request_body_schema(branch, spec) + if flattened_branch is None: + return None + properties.update(flattened_branch["properties"]) + required.extend(flattened_branch["required"]) + properties.update(schema.get("properties", {})) + required.extend(schema.get("required", [])) + return {"properties": properties, "required": required} + return { + "properties": schema.get("properties", {}), + "required": schema.get("required", []), + } diff --git a/src/late/mcp/generated_tools.py b/src/late/mcp/generated_tools.py index b39135a4..3f4db4c3 100644 --- a/src/late/mcp/generated_tools.py +++ b/src/late/mcp/generated_tools.py @@ -1981,11 +1981,14 @@ def ad_audiences_list_ad_audiences( openWorldHint=True, ) ) - def ad_audiences_create_ad_audience() -> str: - """Create custom audience""" + def ad_audiences_create_ad_audience(body: dict[str, Any]) -> str: + """Create custom audience + + Args: + body: Full request body as documented in the API reference. (required)""" client = _get_client() try: - response = client.ad_audiences.create_ad_audience() + response = client.ad_audiences.create_ad_audience(body=body) return _format_response(response) except Exception as e: return f"Error: {e}" @@ -11749,11 +11752,179 @@ def messages_get_message_attachment( openWorldHint=True, ) ) - def messaging_ads_create_messaging_ad() -> str: - """Create click-to-message ad (WhatsApp / Messenger / Instagram Direct)""" + def messaging_ads_create_messaging_ad( + account_id: str, + ad_account_id: str, + name: str, + destination: str, + headline: str | None = None, + body: str | None = None, + image_url: str | None = None, + video: dict[str, Any] | None = None, + creatives: list[dict[str, Any]] | None = None, + ad_set_id: str | None = None, + budget_amount: float | None = None, + budget_type: str | None = None, + currency: str | None = None, + end_date: str | None = None, + countries: list[str] | None = None, + cities: list[dict[str, Any]] | None = None, + regions: list[dict[str, Any]] | None = None, + zips: list[dict[str, Any]] | None = None, + metros: list[dict[str, Any]] | None = None, + custom_locations: list[dict[str, Any]] | None = None, + age_min: int | None = None, + age_max: int | None = None, + interests: list[dict[str, Any]] | None = None, + audience_id: str | None = None, + placements: dict[str, Any] | None = None, + advantage_audience: int | None = None, + objective: str | None = None, + bid_strategy: str | None = None, + bid_amount: float | None = None, + roas_average_floor: float | None = None, + dsa_beneficiary: str | None = None, + dsa_payor: str | None = None, + ) -> str: + """Create click-to-message ad (WhatsApp / Messenger / Instagram Direct) + + Args: + account_id: Facebook or Instagram SocialAccount ID. (required) + ad_account_id: Meta ad account ID, e.g. `act_123456789`. (required) + name: Ad display name. Used to derive campaign / ad set names. + On the multi-creative shape, each ad's Meta name gets a + " #N" suffix (1-indexed) so Ads Manager shows them as a + numbered batch. + (required) + headline: Single-creative shape only. Mutually exclusive with + `creatives[]`. + body: Primary text shown above the image / video. Single-creative + shape only. Mutually exclusive with `creatives[]`. + image_url: Image asset for single-creative shape. Mutually exclusive + with `video` and with `creatives[]`. Required on the + single-creative shape if `video` is not supplied. + video: Video creative for single-creative shape. Mutually + exclusive with `imageUrl` and with `creatives[]`. Required + on the single-creative shape if `imageUrl` is not supplied. + creatives: Multi-creative shape: N CTWA ads under one campaign + one + ad set, sharing budget and targeting. Mutually exclusive + with the top-level single-creative fields (`headline` / + `body` / `imageUrl` / `video`). Each entry must supply its + own headline, body, and exactly one of `imageUrl` / + `video`. + ad_set_id: Attach the creatives to this EXISTING messaging ad set instead of + building a campaign, so the ad set keeps its learning phase. It then + owns budget, targeting and schedule, so `budgetAmount`, `budgetType`, + `endDate`, `objective`, `countries`, `interests` and `audienceId` are + rejected with a 400 alongside it. Its `destination_type` must match + the ad's destination. + budget_amount: Budget amount in the ad account's currency major units + (e.g. dollars for USD, not cents). Must be > 0. + Required unless `adSetId` is set, where the ad set owns it. + budget_type: Required unless `adSetId` is set. + currency: ISO 4217 currency code matching the ad account's currency + (e.g. `USD`). Optional: Zernio resolves it from the ad account + when omitted. The value selects the minor-unit exponent Zernio + converts budget/bid amounts by before calling Meta (most + currencies are cents; zero-decimal currencies like JPY/KRW are + sent as-is). + end_date: ISO 8601 datetime. Required when `budgetType` is `lifetime`. + countries: ISO 3166-1 alpha-2 country codes. Defaults to `["US"]` only + when no other geo (`cities`, `regions`, `zips`, `metros`, + `customLocations`) is supplied. + cities: City-level geo targeting for local CTWA campaigns. Each entry maps to Meta's + TargetingGeoLocationCity. `key` is Meta's city ID. `radius` + and `distance_unit` are coupled: set both or neither. + Meta enforces a minimum city radius (~17 km / 10 mi); + smaller values resolve to a 0-size audience and the ad + fails at launch. For a tighter catchment use customLocations + (lat/lng). + regions: Region / state-level geo targeting. `key` is Meta's region + ID (lookupable via GET /v1/ads/targeting/search?type=region). + zips: ZIP / postal-code geo targeting. `key` is the platform's + postal id resolved via /v1/ads/targeting/search. + metros: DMA / metro-area geo targeting. `key` is Meta's metro id + (e.g. `DMA:807`). + custom_locations: Point-radius geo (Meta `geo_locations.custom_locations`). + Use for targeting a radius around a specific lat/long when + no Meta city/region key fits. `distanceUnit` is required. + age_min + age_max + interests + audience_id: Custom audience ID to target. + placements: Manual ad placements on the shared ad set. Omit + for automatic placements. When set, restricts delivery to the chosen surfaces, + mapped onto the ad set's `targeting.{publisher_platforms, facebook_positions, instagram_positions, + messenger_positions, audience_network_positions, threads_positions, + whatsapp_positions, device_platforms}`. Enum membership is validated here; Meta + additionally enforces co-selection rules and restricts which + placements are eligible for click-to-WhatsApp ads, returning an actionable + error which we surface. + advantage_audience: Meta's Advantage+ audience expansion. `0` (default) keeps + targeting strict; `1` lets Meta expand beyond the supplied + targeting when its delivery system finds better matches. + Always sent on CREATE (Meta requires it). + objective: Defaults to `OUTCOME_ENGAGEMENT`. `OUTCOME_SALES` and `OUTCOME_LEADS` require + additional account configuration (Dataset linked to the WABA + for sales) and may be rejected by Meta if missing. + bid_strategy: Meta bid strategy applied to the shared ad set. Defaults to + `LOWEST_COST_WITHOUT_CAP` (auto-bid) when omitted. + `LOWEST_COST_WITH_BID_CAP` and `COST_CAP` require + `bidAmount`. `LOWEST_COST_WITH_MIN_ROAS` requires + `roasAverageFloor`. CTWA's `optimization_goal` is fixed to + `CONVERSATIONS`, but the bid strategy is independent. + bid_amount: Whole currency units (e.g. `5` = $5.00 on a USD account). + Required when `bidStrategy` is `LOWEST_COST_WITH_BID_CAP` + or `COST_CAP`; rejected otherwise. + roas_average_floor: Decimal ROAS multiplier (e.g. `2.0` = 2.0× ROAS floor). + Required when `bidStrategy` is `LOWEST_COST_WITH_MIN_ROAS`; + rejected otherwise. Meta enforces its own upper bound + server-side. + dsa_beneficiary: Legal entity that benefits from the ad. Required when targeting EU users + (EU DSA, Article 26). Optional if the ad account has a default beneficiary: + set it once via `PATCH /v1/ads/accounts` or in Meta Ads Manager, and Meta + fills it in whenever the field is omitted. + dsa_payor: Legal entity that pays for the ad. Can differ from `dsaBeneficiary` + (for example, an agency paying for a client's ads). Same rules as + `dsaBeneficiary`: required for EU targeting unless the ad account has + a default payor. + destination: Where the conversation opens when the ad is tapped. (required)""" client = _get_client() try: - response = client.messaging_ads.create_messaging_ad() + response = client.messaging_ads.create_messaging_ad( + account_id=account_id, + ad_account_id=ad_account_id, + name=name, + headline=headline, + body=body, + image_url=image_url, + video=video, + creatives=creatives, + ad_set_id=ad_set_id, + budget_amount=budget_amount, + budget_type=budget_type, + currency=currency, + end_date=end_date, + countries=countries, + cities=cities, + regions=regions, + zips=zips, + metros=metros, + custom_locations=custom_locations, + age_min=age_min, + age_max=age_max, + interests=interests, + audience_id=audience_id, + placements=placements, + advantage_audience=advantage_audience, + objective=objective, + bid_strategy=bid_strategy, + bid_amount=bid_amount, + roas_average_floor=roas_average_floor, + dsa_beneficiary=dsa_beneficiary, + dsa_payor=dsa_payor, + destination=destination, + ) return _format_response(response) except Exception as e: return f"Error: {e}" @@ -11766,11 +11937,182 @@ def messaging_ads_create_messaging_ad() -> str: openWorldHint=True, ) ) - def messaging_ads_create_call_ad() -> str: - """Create Click-to-Call ad""" + def messaging_ads_create_call_ad( + account_id: str, + ad_account_id: str, + name: str, + phone_number: str, + link_url: str, + headline: str | None = None, + body: str | None = None, + image_url: str | None = None, + video: dict[str, Any] | None = None, + creatives: list[dict[str, Any]] | None = None, + ad_set_id: str | None = None, + budget_amount: float | None = None, + budget_type: str | None = None, + currency: str | None = None, + end_date: str | None = None, + countries: list[str] | None = None, + cities: list[dict[str, Any]] | None = None, + regions: list[dict[str, Any]] | None = None, + zips: list[dict[str, Any]] | None = None, + metros: list[dict[str, Any]] | None = None, + custom_locations: list[dict[str, Any]] | None = None, + age_min: int | None = None, + age_max: int | None = None, + interests: list[dict[str, Any]] | None = None, + audience_id: str | None = None, + placements: dict[str, Any] | None = None, + advantage_audience: int | None = None, + objective: str | None = None, + bid_strategy: str | None = None, + bid_amount: float | None = None, + roas_average_floor: float | None = None, + dsa_beneficiary: str | None = None, + dsa_payor: str | None = None, + ) -> str: + """Create Click-to-Call ad + + Args: + account_id: Facebook or Instagram SocialAccount ID. (required) + ad_account_id: Meta ad account ID, e.g. `act_123456789`. (required) + name: Ad display name. Used to derive campaign / ad set names. + On the multi-creative shape, each ad's Meta name gets a + " #N" suffix (1-indexed) so Ads Manager shows them as a + numbered batch. + (required) + headline: Single-creative shape only. Mutually exclusive with + `creatives[]`. + body: Primary text shown above the image / video. Single-creative + shape only. Mutually exclusive with `creatives[]`. + image_url: Image asset for single-creative shape. Mutually exclusive + with `video` and with `creatives[]`. Required on the + single-creative shape if `video` is not supplied. + video: Video creative for single-creative shape. Mutually + exclusive with `imageUrl` and with `creatives[]`. Required + on the single-creative shape if `imageUrl` is not supplied. + creatives: Multi-creative shape: N CTWA ads under one campaign + one + ad set, sharing budget and targeting. Mutually exclusive + with the top-level single-creative fields (`headline` / + `body` / `imageUrl` / `video`). Each entry must supply its + own headline, body, and exactly one of `imageUrl` / + `video`. + ad_set_id: Attach the creatives to this EXISTING messaging ad set instead of + building a campaign, so the ad set keeps its learning phase. It then + owns budget, targeting and schedule, so `budgetAmount`, `budgetType`, + `endDate`, `objective`, `countries`, `interests` and `audienceId` are + rejected with a 400 alongside it. Its `destination_type` must match + the ad's destination. + budget_amount: Budget amount in the ad account's currency major units + (e.g. dollars for USD, not cents). Must be > 0. + Required unless `adSetId` is set, where the ad set owns it. + budget_type: Required unless `adSetId` is set. + currency: ISO 4217 currency code matching the ad account's currency + (e.g. `USD`). Optional: Zernio resolves it from the ad account + when omitted. The value selects the minor-unit exponent Zernio + converts budget/bid amounts by before calling Meta (most + currencies are cents; zero-decimal currencies like JPY/KRW are + sent as-is). + end_date: ISO 8601 datetime. Required when `budgetType` is `lifetime`. + countries: ISO 3166-1 alpha-2 country codes. Defaults to `["US"]` only + when no other geo (`cities`, `regions`, `zips`, `metros`, + `customLocations`) is supplied. + cities: City-level geo targeting for local CTWA campaigns. Each entry maps to Meta's + TargetingGeoLocationCity. `key` is Meta's city ID. `radius` + and `distance_unit` are coupled: set both or neither. + Meta enforces a minimum city radius (~17 km / 10 mi); + smaller values resolve to a 0-size audience and the ad + fails at launch. For a tighter catchment use customLocations + (lat/lng). + regions: Region / state-level geo targeting. `key` is Meta's region + ID (lookupable via GET /v1/ads/targeting/search?type=region). + zips: ZIP / postal-code geo targeting. `key` is the platform's + postal id resolved via /v1/ads/targeting/search. + metros: DMA / metro-area geo targeting. `key` is Meta's metro id + (e.g. `DMA:807`). + custom_locations: Point-radius geo (Meta `geo_locations.custom_locations`). + Use for targeting a radius around a specific lat/long when + no Meta city/region key fits. `distanceUnit` is required. + age_min + age_max + interests + audience_id: Custom audience ID to target. + placements: Manual ad placements on the shared ad set. Omit + for automatic placements. When set, restricts delivery to the chosen surfaces, + mapped onto the ad set's `targeting.{publisher_platforms, facebook_positions, instagram_positions, + messenger_positions, audience_network_positions, threads_positions, + whatsapp_positions, device_platforms}`. Enum membership is validated here; Meta + additionally enforces co-selection rules and restricts which + placements are eligible for click-to-WhatsApp ads, returning an actionable + error which we surface. + advantage_audience: Meta's Advantage+ audience expansion. `0` (default) keeps + targeting strict; `1` lets Meta expand beyond the supplied + targeting when its delivery system finds better matches. + Always sent on CREATE (Meta requires it). + objective: Defaults to `OUTCOME_ENGAGEMENT`. `OUTCOME_SALES` and `OUTCOME_LEADS` require + additional account configuration (Dataset linked to the WABA + for sales) and may be rejected by Meta if missing. + bid_strategy: Meta bid strategy applied to the shared ad set. Defaults to + `LOWEST_COST_WITHOUT_CAP` (auto-bid) when omitted. + `LOWEST_COST_WITH_BID_CAP` and `COST_CAP` require + `bidAmount`. `LOWEST_COST_WITH_MIN_ROAS` requires + `roasAverageFloor`. CTWA's `optimization_goal` is fixed to + `CONVERSATIONS`, but the bid strategy is independent. + bid_amount: Whole currency units (e.g. `5` = $5.00 on a USD account). + Required when `bidStrategy` is `LOWEST_COST_WITH_BID_CAP` + or `COST_CAP`; rejected otherwise. + roas_average_floor: Decimal ROAS multiplier (e.g. `2.0` = 2.0× ROAS floor). + Required when `bidStrategy` is `LOWEST_COST_WITH_MIN_ROAS`; + rejected otherwise. Meta enforces its own upper bound + server-side. + dsa_beneficiary: Legal entity that benefits from the ad. Required when targeting EU users + (EU DSA, Article 26). Optional if the ad account has a default beneficiary: + set it once via `PATCH /v1/ads/accounts` or in Meta Ads Manager, and Meta + fills it in whenever the field is omitted. + dsa_payor: Legal entity that pays for the ad. Can differ from `dsaBeneficiary` + (for example, an agency paying for a client's ads). Same rules as + `dsaBeneficiary`: required for EU targeting unless the ad account has + a default payor. + phone_number: E.164 number the CALL_NOW CTA dials (e.g. +34600111222). (required) + link_url: Website shown as the creative's link. Required: Meta rejects tel: as link_data.link; the phone number rides only the CTA. (required)""" client = _get_client() try: - response = client.messaging_ads.create_call_ad() + response = client.messaging_ads.create_call_ad( + account_id=account_id, + ad_account_id=ad_account_id, + name=name, + headline=headline, + body=body, + image_url=image_url, + video=video, + creatives=creatives, + ad_set_id=ad_set_id, + budget_amount=budget_amount, + budget_type=budget_type, + currency=currency, + end_date=end_date, + countries=countries, + cities=cities, + regions=regions, + zips=zips, + metros=metros, + custom_locations=custom_locations, + age_min=age_min, + age_max=age_max, + interests=interests, + audience_id=audience_id, + placements=placements, + advantage_audience=advantage_audience, + objective=objective, + bid_strategy=bid_strategy, + bid_amount=bid_amount, + roas_average_floor=roas_average_floor, + dsa_beneficiary=dsa_beneficiary, + dsa_payor=dsa_payor, + phone_number=phone_number, + link_url=link_url, + ) return _format_response(response) except Exception as e: return f"Error: {e}" @@ -11783,11 +12125,176 @@ def messaging_ads_create_call_ad() -> str: openWorldHint=True, ) ) - def messaging_ads_create_ctwa_ad() -> str: - """Create Click-to-WhatsApp ad (deprecated)""" + def messaging_ads_create_ctwa_ad( + account_id: str, + ad_account_id: str, + name: str, + headline: str | None = None, + body: str | None = None, + image_url: str | None = None, + video: dict[str, Any] | None = None, + creatives: list[dict[str, Any]] | None = None, + ad_set_id: str | None = None, + budget_amount: float | None = None, + budget_type: str | None = None, + currency: str | None = None, + end_date: str | None = None, + countries: list[str] | None = None, + cities: list[dict[str, Any]] | None = None, + regions: list[dict[str, Any]] | None = None, + zips: list[dict[str, Any]] | None = None, + metros: list[dict[str, Any]] | None = None, + custom_locations: list[dict[str, Any]] | None = None, + age_min: int | None = None, + age_max: int | None = None, + interests: list[dict[str, Any]] | None = None, + audience_id: str | None = None, + placements: dict[str, Any] | None = None, + advantage_audience: int | None = None, + objective: str | None = None, + bid_strategy: str | None = None, + bid_amount: float | None = None, + roas_average_floor: float | None = None, + dsa_beneficiary: str | None = None, + dsa_payor: str | None = None, + ) -> str: + """Create Click-to-WhatsApp ad (deprecated) + + Args: + account_id: Facebook or Instagram SocialAccount ID. (required) + ad_account_id: Meta ad account ID, e.g. `act_123456789`. (required) + name: Ad display name. Used to derive campaign / ad set names. + On the multi-creative shape, each ad's Meta name gets a + " #N" suffix (1-indexed) so Ads Manager shows them as a + numbered batch. + (required) + headline: Single-creative shape only. Mutually exclusive with + `creatives[]`. + body: Primary text shown above the image / video. Single-creative + shape only. Mutually exclusive with `creatives[]`. + image_url: Image asset for single-creative shape. Mutually exclusive + with `video` and with `creatives[]`. Required on the + single-creative shape if `video` is not supplied. + video: Video creative for single-creative shape. Mutually + exclusive with `imageUrl` and with `creatives[]`. Required + on the single-creative shape if `imageUrl` is not supplied. + creatives: Multi-creative shape: N CTWA ads under one campaign + one + ad set, sharing budget and targeting. Mutually exclusive + with the top-level single-creative fields (`headline` / + `body` / `imageUrl` / `video`). Each entry must supply its + own headline, body, and exactly one of `imageUrl` / + `video`. + ad_set_id: Attach the creatives to this EXISTING messaging ad set instead of + building a campaign, so the ad set keeps its learning phase. It then + owns budget, targeting and schedule, so `budgetAmount`, `budgetType`, + `endDate`, `objective`, `countries`, `interests` and `audienceId` are + rejected with a 400 alongside it. Its `destination_type` must match + the ad's destination. + budget_amount: Budget amount in the ad account's currency major units + (e.g. dollars for USD, not cents). Must be > 0. + Required unless `adSetId` is set, where the ad set owns it. + budget_type: Required unless `adSetId` is set. + currency: ISO 4217 currency code matching the ad account's currency + (e.g. `USD`). Optional: Zernio resolves it from the ad account + when omitted. The value selects the minor-unit exponent Zernio + converts budget/bid amounts by before calling Meta (most + currencies are cents; zero-decimal currencies like JPY/KRW are + sent as-is). + end_date: ISO 8601 datetime. Required when `budgetType` is `lifetime`. + countries: ISO 3166-1 alpha-2 country codes. Defaults to `["US"]` only + when no other geo (`cities`, `regions`, `zips`, `metros`, + `customLocations`) is supplied. + cities: City-level geo targeting for local CTWA campaigns. Each entry maps to Meta's + TargetingGeoLocationCity. `key` is Meta's city ID. `radius` + and `distance_unit` are coupled: set both or neither. + Meta enforces a minimum city radius (~17 km / 10 mi); + smaller values resolve to a 0-size audience and the ad + fails at launch. For a tighter catchment use customLocations + (lat/lng). + regions: Region / state-level geo targeting. `key` is Meta's region + ID (lookupable via GET /v1/ads/targeting/search?type=region). + zips: ZIP / postal-code geo targeting. `key` is the platform's + postal id resolved via /v1/ads/targeting/search. + metros: DMA / metro-area geo targeting. `key` is Meta's metro id + (e.g. `DMA:807`). + custom_locations: Point-radius geo (Meta `geo_locations.custom_locations`). + Use for targeting a radius around a specific lat/long when + no Meta city/region key fits. `distanceUnit` is required. + age_min + age_max + interests + audience_id: Custom audience ID to target. + placements: Manual ad placements on the shared ad set. Omit + for automatic placements. When set, restricts delivery to the chosen surfaces, + mapped onto the ad set's `targeting.{publisher_platforms, facebook_positions, instagram_positions, + messenger_positions, audience_network_positions, threads_positions, + whatsapp_positions, device_platforms}`. Enum membership is validated here; Meta + additionally enforces co-selection rules and restricts which + placements are eligible for click-to-WhatsApp ads, returning an actionable + error which we surface. + advantage_audience: Meta's Advantage+ audience expansion. `0` (default) keeps + targeting strict; `1` lets Meta expand beyond the supplied + targeting when its delivery system finds better matches. + Always sent on CREATE (Meta requires it). + objective: Defaults to `OUTCOME_ENGAGEMENT`. `OUTCOME_SALES` and `OUTCOME_LEADS` require + additional account configuration (Dataset linked to the WABA + for sales) and may be rejected by Meta if missing. + bid_strategy: Meta bid strategy applied to the shared ad set. Defaults to + `LOWEST_COST_WITHOUT_CAP` (auto-bid) when omitted. + `LOWEST_COST_WITH_BID_CAP` and `COST_CAP` require + `bidAmount`. `LOWEST_COST_WITH_MIN_ROAS` requires + `roasAverageFloor`. CTWA's `optimization_goal` is fixed to + `CONVERSATIONS`, but the bid strategy is independent. + bid_amount: Whole currency units (e.g. `5` = $5.00 on a USD account). + Required when `bidStrategy` is `LOWEST_COST_WITH_BID_CAP` + or `COST_CAP`; rejected otherwise. + roas_average_floor: Decimal ROAS multiplier (e.g. `2.0` = 2.0× ROAS floor). + Required when `bidStrategy` is `LOWEST_COST_WITH_MIN_ROAS`; + rejected otherwise. Meta enforces its own upper bound + server-side. + dsa_beneficiary: Legal entity that benefits from the ad. Required when targeting EU users + (EU DSA, Article 26). Optional if the ad account has a default beneficiary: + set it once via `PATCH /v1/ads/accounts` or in Meta Ads Manager, and Meta + fills it in whenever the field is omitted. + dsa_payor: Legal entity that pays for the ad. Can differ from `dsaBeneficiary` + (for example, an agency paying for a client's ads). Same rules as + `dsaBeneficiary`: required for EU targeting unless the ad account has + a default payor.""" client = _get_client() try: - response = client.messaging_ads.create_ctwa_ad() + response = client.messaging_ads.create_ctwa_ad( + account_id=account_id, + ad_account_id=ad_account_id, + name=name, + headline=headline, + body=body, + image_url=image_url, + video=video, + creatives=creatives, + ad_set_id=ad_set_id, + budget_amount=budget_amount, + budget_type=budget_type, + currency=currency, + end_date=end_date, + countries=countries, + cities=cities, + regions=regions, + zips=zips, + metros=metros, + custom_locations=custom_locations, + age_min=age_min, + age_max=age_max, + interests=interests, + audience_id=audience_id, + placements=placements, + advantage_audience=advantage_audience, + objective=objective, + bid_strategy=bid_strategy, + bid_amount=bid_amount, + roas_average_floor=roas_average_floor, + dsa_beneficiary=dsa_beneficiary, + dsa_payor=dsa_payor, + ) return _format_response(response) except Exception as e: return f"Error: {e}" diff --git a/src/late/resources/_generated/ad_audiences.py b/src/late/resources/_generated/ad_audiences.py index 0dff37ac..674d732c 100644 --- a/src/late/resources/_generated/ad_audiences.py +++ b/src/late/resources/_generated/ad_audiences.py @@ -90,9 +90,9 @@ def list_ad_audiences( ) return self._client._get("/v1/ads/audiences", params=params) - def create_ad_audience(self) -> dict[str, Any]: + def create_ad_audience(self, body: dict[str, Any]) -> dict[str, Any]: """Create custom audience""" - return self._client._post("/v1/ads/audiences") + return self._client._post("/v1/ads/audiences", data=body) def get_ad_audience(self, audience_id: str) -> dict[str, Any]: """Get audience details""" @@ -146,9 +146,9 @@ async def alist_ad_audiences( ) return await self._client._aget("/v1/ads/audiences", params=params) - async def acreate_ad_audience(self) -> dict[str, Any]: + async def acreate_ad_audience(self, body: dict[str, Any]) -> dict[str, Any]: """Create custom audience (async)""" - return await self._client._apost("/v1/ads/audiences") + return await self._client._apost("/v1/ads/audiences", data=body) async def aget_ad_audience(self, audience_id: str) -> dict[str, Any]: """Get audience details (async)""" diff --git a/src/late/resources/_generated/messaging_ads.py b/src/late/resources/_generated/messaging_ads.py index c0d76d4b..8206fd99 100644 --- a/src/late/resources/_generated/messaging_ads.py +++ b/src/late/resources/_generated/messaging_ads.py @@ -10,6 +10,8 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: + from datetime import datetime + from ..client.base import BaseClient @@ -73,26 +75,440 @@ def to_camel(s: str) -> str: result[to_camel(k)] = v return result - def create_messaging_ad(self) -> dict[str, Any]: + def create_messaging_ad( + self, + account_id: str, + ad_account_id: str, + name: str, + destination: str, + *, + headline: str | None = None, + body: str | None = None, + image_url: str | None = None, + video: dict[str, Any] | None = None, + creatives: list[dict[str, Any]] | None = None, + ad_set_id: str | None = None, + budget_amount: float | None = None, + budget_type: str | None = None, + currency: str | None = None, + end_date: datetime | str | None = None, + countries: list[str] | None = None, + cities: list[dict[str, Any]] | None = None, + regions: list[dict[str, Any]] | None = None, + zips: list[dict[str, Any]] | None = None, + metros: list[dict[str, Any]] | None = None, + custom_locations: list[dict[str, Any]] | None = None, + age_min: int | None = None, + age_max: int | None = None, + interests: list[dict[str, Any]] | None = None, + audience_id: str | None = None, + placements: dict[str, Any] | None = None, + advantage_audience: int | None = None, + objective: str | None = None, + bid_strategy: str | None = None, + bid_amount: float | None = None, + roas_average_floor: float | None = None, + dsa_beneficiary: str | None = None, + dsa_payor: str | None = None, + ) -> dict[str, Any]: """Create click-to-message ad (WhatsApp / Messenger / Instagram Direct)""" - return self._client._post("/v1/ads/messaging") + payload = self._build_payload( + account_id=account_id, + ad_account_id=ad_account_id, + name=name, + headline=headline, + body=body, + image_url=image_url, + video=video, + creatives=creatives, + ad_set_id=ad_set_id, + budget_amount=budget_amount, + budget_type=budget_type, + currency=currency, + end_date=end_date, + countries=countries, + cities=cities, + regions=regions, + zips=zips, + metros=metros, + custom_locations=custom_locations, + age_min=age_min, + age_max=age_max, + interests=interests, + audience_id=audience_id, + placements=placements, + advantage_audience=advantage_audience, + objective=objective, + bid_strategy=bid_strategy, + bid_amount=bid_amount, + roas_average_floor=roas_average_floor, + dsa_beneficiary=dsa_beneficiary, + dsa_payor=dsa_payor, + destination=destination, + ) + return self._client._post("/v1/ads/messaging", data=payload) - def create_call_ad(self) -> dict[str, Any]: + def create_call_ad( + self, + account_id: str, + ad_account_id: str, + name: str, + phone_number: str, + link_url: str, + *, + headline: str | None = None, + body: str | None = None, + image_url: str | None = None, + video: dict[str, Any] | None = None, + creatives: list[dict[str, Any]] | None = None, + ad_set_id: str | None = None, + budget_amount: float | None = None, + budget_type: str | None = None, + currency: str | None = None, + end_date: datetime | str | None = None, + countries: list[str] | None = None, + cities: list[dict[str, Any]] | None = None, + regions: list[dict[str, Any]] | None = None, + zips: list[dict[str, Any]] | None = None, + metros: list[dict[str, Any]] | None = None, + custom_locations: list[dict[str, Any]] | None = None, + age_min: int | None = None, + age_max: int | None = None, + interests: list[dict[str, Any]] | None = None, + audience_id: str | None = None, + placements: dict[str, Any] | None = None, + advantage_audience: int | None = None, + objective: str | None = None, + bid_strategy: str | None = None, + bid_amount: float | None = None, + roas_average_floor: float | None = None, + dsa_beneficiary: str | None = None, + dsa_payor: str | None = None, + ) -> dict[str, Any]: """Create Click-to-Call ad""" - return self._client._post("/v1/ads/call") + payload = self._build_payload( + account_id=account_id, + ad_account_id=ad_account_id, + name=name, + headline=headline, + body=body, + image_url=image_url, + video=video, + creatives=creatives, + ad_set_id=ad_set_id, + budget_amount=budget_amount, + budget_type=budget_type, + currency=currency, + end_date=end_date, + countries=countries, + cities=cities, + regions=regions, + zips=zips, + metros=metros, + custom_locations=custom_locations, + age_min=age_min, + age_max=age_max, + interests=interests, + audience_id=audience_id, + placements=placements, + advantage_audience=advantage_audience, + objective=objective, + bid_strategy=bid_strategy, + bid_amount=bid_amount, + roas_average_floor=roas_average_floor, + dsa_beneficiary=dsa_beneficiary, + dsa_payor=dsa_payor, + phone_number=phone_number, + link_url=link_url, + ) + return self._client._post("/v1/ads/call", data=payload) - def create_ctwa_ad(self) -> dict[str, Any]: + def create_ctwa_ad( + self, + account_id: str, + ad_account_id: str, + name: str, + *, + headline: str | None = None, + body: str | None = None, + image_url: str | None = None, + video: dict[str, Any] | None = None, + creatives: list[dict[str, Any]] | None = None, + ad_set_id: str | None = None, + budget_amount: float | None = None, + budget_type: str | None = None, + currency: str | None = None, + end_date: datetime | str | None = None, + countries: list[str] | None = None, + cities: list[dict[str, Any]] | None = None, + regions: list[dict[str, Any]] | None = None, + zips: list[dict[str, Any]] | None = None, + metros: list[dict[str, Any]] | None = None, + custom_locations: list[dict[str, Any]] | None = None, + age_min: int | None = None, + age_max: int | None = None, + interests: list[dict[str, Any]] | None = None, + audience_id: str | None = None, + placements: dict[str, Any] | None = None, + advantage_audience: int | None = None, + objective: str | None = None, + bid_strategy: str | None = None, + bid_amount: float | None = None, + roas_average_floor: float | None = None, + dsa_beneficiary: str | None = None, + dsa_payor: str | None = None, + ) -> dict[str, Any]: """Create Click-to-WhatsApp ad (deprecated)""" - return self._client._post("/v1/ads/ctwa") + payload = self._build_payload( + account_id=account_id, + ad_account_id=ad_account_id, + name=name, + headline=headline, + body=body, + image_url=image_url, + video=video, + creatives=creatives, + ad_set_id=ad_set_id, + budget_amount=budget_amount, + budget_type=budget_type, + currency=currency, + end_date=end_date, + countries=countries, + cities=cities, + regions=regions, + zips=zips, + metros=metros, + custom_locations=custom_locations, + age_min=age_min, + age_max=age_max, + interests=interests, + audience_id=audience_id, + placements=placements, + advantage_audience=advantage_audience, + objective=objective, + bid_strategy=bid_strategy, + bid_amount=bid_amount, + roas_average_floor=roas_average_floor, + dsa_beneficiary=dsa_beneficiary, + dsa_payor=dsa_payor, + ) + return self._client._post("/v1/ads/ctwa", data=payload) - async def acreate_messaging_ad(self) -> dict[str, Any]: + async def acreate_messaging_ad( + self, + account_id: str, + ad_account_id: str, + name: str, + destination: str, + *, + headline: str | None = None, + body: str | None = None, + image_url: str | None = None, + video: dict[str, Any] | None = None, + creatives: list[dict[str, Any]] | None = None, + ad_set_id: str | None = None, + budget_amount: float | None = None, + budget_type: str | None = None, + currency: str | None = None, + end_date: datetime | str | None = None, + countries: list[str] | None = None, + cities: list[dict[str, Any]] | None = None, + regions: list[dict[str, Any]] | None = None, + zips: list[dict[str, Any]] | None = None, + metros: list[dict[str, Any]] | None = None, + custom_locations: list[dict[str, Any]] | None = None, + age_min: int | None = None, + age_max: int | None = None, + interests: list[dict[str, Any]] | None = None, + audience_id: str | None = None, + placements: dict[str, Any] | None = None, + advantage_audience: int | None = None, + objective: str | None = None, + bid_strategy: str | None = None, + bid_amount: float | None = None, + roas_average_floor: float | None = None, + dsa_beneficiary: str | None = None, + dsa_payor: str | None = None, + ) -> dict[str, Any]: """Create click-to-message ad (WhatsApp / Messenger / Instagram Direct) (async)""" - return await self._client._apost("/v1/ads/messaging") + payload = self._build_payload( + account_id=account_id, + ad_account_id=ad_account_id, + name=name, + headline=headline, + body=body, + image_url=image_url, + video=video, + creatives=creatives, + ad_set_id=ad_set_id, + budget_amount=budget_amount, + budget_type=budget_type, + currency=currency, + end_date=end_date, + countries=countries, + cities=cities, + regions=regions, + zips=zips, + metros=metros, + custom_locations=custom_locations, + age_min=age_min, + age_max=age_max, + interests=interests, + audience_id=audience_id, + placements=placements, + advantage_audience=advantage_audience, + objective=objective, + bid_strategy=bid_strategy, + bid_amount=bid_amount, + roas_average_floor=roas_average_floor, + dsa_beneficiary=dsa_beneficiary, + dsa_payor=dsa_payor, + destination=destination, + ) + return await self._client._apost("/v1/ads/messaging", data=payload) - async def acreate_call_ad(self) -> dict[str, Any]: + async def acreate_call_ad( + self, + account_id: str, + ad_account_id: str, + name: str, + phone_number: str, + link_url: str, + *, + headline: str | None = None, + body: str | None = None, + image_url: str | None = None, + video: dict[str, Any] | None = None, + creatives: list[dict[str, Any]] | None = None, + ad_set_id: str | None = None, + budget_amount: float | None = None, + budget_type: str | None = None, + currency: str | None = None, + end_date: datetime | str | None = None, + countries: list[str] | None = None, + cities: list[dict[str, Any]] | None = None, + regions: list[dict[str, Any]] | None = None, + zips: list[dict[str, Any]] | None = None, + metros: list[dict[str, Any]] | None = None, + custom_locations: list[dict[str, Any]] | None = None, + age_min: int | None = None, + age_max: int | None = None, + interests: list[dict[str, Any]] | None = None, + audience_id: str | None = None, + placements: dict[str, Any] | None = None, + advantage_audience: int | None = None, + objective: str | None = None, + bid_strategy: str | None = None, + bid_amount: float | None = None, + roas_average_floor: float | None = None, + dsa_beneficiary: str | None = None, + dsa_payor: str | None = None, + ) -> dict[str, Any]: """Create Click-to-Call ad (async)""" - return await self._client._apost("/v1/ads/call") + payload = self._build_payload( + account_id=account_id, + ad_account_id=ad_account_id, + name=name, + headline=headline, + body=body, + image_url=image_url, + video=video, + creatives=creatives, + ad_set_id=ad_set_id, + budget_amount=budget_amount, + budget_type=budget_type, + currency=currency, + end_date=end_date, + countries=countries, + cities=cities, + regions=regions, + zips=zips, + metros=metros, + custom_locations=custom_locations, + age_min=age_min, + age_max=age_max, + interests=interests, + audience_id=audience_id, + placements=placements, + advantage_audience=advantage_audience, + objective=objective, + bid_strategy=bid_strategy, + bid_amount=bid_amount, + roas_average_floor=roas_average_floor, + dsa_beneficiary=dsa_beneficiary, + dsa_payor=dsa_payor, + phone_number=phone_number, + link_url=link_url, + ) + return await self._client._apost("/v1/ads/call", data=payload) - async def acreate_ctwa_ad(self) -> dict[str, Any]: + async def acreate_ctwa_ad( + self, + account_id: str, + ad_account_id: str, + name: str, + *, + headline: str | None = None, + body: str | None = None, + image_url: str | None = None, + video: dict[str, Any] | None = None, + creatives: list[dict[str, Any]] | None = None, + ad_set_id: str | None = None, + budget_amount: float | None = None, + budget_type: str | None = None, + currency: str | None = None, + end_date: datetime | str | None = None, + countries: list[str] | None = None, + cities: list[dict[str, Any]] | None = None, + regions: list[dict[str, Any]] | None = None, + zips: list[dict[str, Any]] | None = None, + metros: list[dict[str, Any]] | None = None, + custom_locations: list[dict[str, Any]] | None = None, + age_min: int | None = None, + age_max: int | None = None, + interests: list[dict[str, Any]] | None = None, + audience_id: str | None = None, + placements: dict[str, Any] | None = None, + advantage_audience: int | None = None, + objective: str | None = None, + bid_strategy: str | None = None, + bid_amount: float | None = None, + roas_average_floor: float | None = None, + dsa_beneficiary: str | None = None, + dsa_payor: str | None = None, + ) -> dict[str, Any]: """Create Click-to-WhatsApp ad (deprecated) (async)""" - return await self._client._apost("/v1/ads/ctwa") + payload = self._build_payload( + account_id=account_id, + ad_account_id=ad_account_id, + name=name, + headline=headline, + body=body, + image_url=image_url, + video=video, + creatives=creatives, + ad_set_id=ad_set_id, + budget_amount=budget_amount, + budget_type=budget_type, + currency=currency, + end_date=end_date, + countries=countries, + cities=cities, + regions=regions, + zips=zips, + metros=metros, + custom_locations=custom_locations, + age_min=age_min, + age_max=age_max, + interests=interests, + audience_id=audience_id, + placements=placements, + advantage_audience=advantage_audience, + objective=objective, + bid_strategy=bid_strategy, + bid_amount=bid_amount, + roas_average_floor=roas_average_floor, + dsa_beneficiary=dsa_beneficiary, + dsa_payor=dsa_payor, + ) + return await self._client._apost("/v1/ads/ctwa", data=payload) diff --git a/tests/test_generated_request_bodies.py b/tests/test_generated_request_bodies.py new file mode 100644 index 00000000..7121e457 --- /dev/null +++ b/tests/test_generated_request_bodies.py @@ -0,0 +1,65 @@ +import inspect +from typing import Any + +from late.resources._generated.ad_audiences import AdAudiencesResource +from late.resources._generated.messaging_ads import MessagingAdsResource + + +class RecordingClient: + def __init__(self) -> None: + self.calls: list[tuple[str, Any]] = [] + + def _post(self, path: str, data: Any = None) -> dict[str, Any]: + self.calls.append((path, data)) + return {} + + +def test_create_ad_audience_requires_and_sends_raw_body() -> None: + client = RecordingClient() + body = { + "accountId": "acc_1", + "adAccountId": "act_1", + "type": "website", + "name": "Site visitors 30d", + } + + AdAudiencesResource(client).create_ad_audience(body) + + assert client.calls == [("/v1/ads/audiences", body)] + + +def test_create_ad_audience_body_is_mandatory() -> None: + parameters = inspect.signature(AdAudiencesResource.create_ad_audience).parameters + assert parameters["body"].default is inspect.Parameter.empty + + +def test_messaging_ad_creators_expose_flattened_body_fields() -> None: + for method in ( + MessagingAdsResource.create_messaging_ad, + MessagingAdsResource.create_call_ad, + MessagingAdsResource.create_ctwa_ad, + ): + parameters = inspect.signature(method).parameters + for field in ("account_id", "ad_account_id", "name"): + assert field in parameters, f"{method.__name__} lost body field {field}" + + +def test_create_messaging_ad_sends_camel_cased_payload() -> None: + client = RecordingClient() + + MessagingAdsResource(client).create_messaging_ad( + account_id="acc_1", + ad_account_id="act_1", + name="WhatsApp test", + destination="whatsapp", + headline="Talk to us", + budget_amount=20, + ) + + path, payload = client.calls[0] + assert path == "/v1/ads/messaging" + assert payload["accountId"] == "acc_1" + assert payload["adAccountId"] == "act_1" + assert payload["destination"] == "whatsapp" + assert payload["budgetAmount"] == 20 + assert "video" not in payload