diff --git a/src/fopost/__init__.py b/src/fopost/__init__.py index 4cde9c1..30362aa 100644 --- a/src/fopost/__init__.py +++ b/src/fopost/__init__.py @@ -40,10 +40,14 @@ ActivityPage, Ad, AdAccountTree, + AdBusinessCenter, AdCampaign, AdCampaignNode, + AdComment, + AdCommentsPage, AdConnection, AdCreative, + AdIdentity, AdInsights, AdInsightsReport, AdSet, @@ -157,6 +161,7 @@ SlackIdentity, SlackMember, SocialAccount, + SparkPost, TargetingOption, TelegramBotCommand, TelegramBotCommands, @@ -320,6 +325,11 @@ "WebhookSubscription", "SlackMember", "SocialAccount", + "AdBusinessCenter", + "AdComment", + "AdCommentsPage", + "AdIdentity", + "SparkPost", "TargetingOption", "TelegramBotCommand", "TelegramBotCommands", diff --git a/src/fopost/models.py b/src/fopost/models.py index f88399b..dab2af9 100644 --- a/src/fopost/models.py +++ b/src/fopost/models.py @@ -1135,6 +1135,56 @@ class TargetingOption(FopostModel): detail: str | None = None +class AdBusinessCenter(FopostModel): + """A Business Center, or the network's equivalent grouping of ad accounts.""" + + id: str + name: str + role: str | None = None + + +class AdIdentity(FopostModel): + """The account an ad runs as. Meta calls it a Page, TikTok an identity.""" + + id: str + type: str + name: str + avatar_url: str | None = None + + +class SparkPost(FopostModel): + """A post already live on the network, offered as the source of a Spark ad.""" + + id: str + identity_id: str + caption: str | None = None + thumbnail_url: str | None = None + created_at: str | None = None + views: int | None = None + + +class AdComment(FopostModel): + """A comment on an ad, read live from the network and never stored.""" + + id: str + ad_id: str | None = None + text: str = "" + author_name: str | None = None + author_avatar_url: str | None = None + created_at: str | None = None + likes: int = 0 + reply_count: int = 0 + hidden: bool = False + parent_id: str | None = None + + +class AdCommentsPage(FopostModel): + """One page of an ad's comments; pass ``next_cursor`` back as ``after``.""" + + comments: list[AdComment] = [] + next_cursor: str | None = None + + class LeadForm(FopostModel): id: str name: str diff --git a/src/fopost/resources/ads.py b/src/fopost/resources/ads.py index b924614..881691f 100644 --- a/src/fopost/resources/ads.py +++ b/src/fopost/resources/ads.py @@ -1,6 +1,7 @@ -"""``client.ads`` — ads, catalogs, audiences, the ad archive and lead forms. +"""``client.ads`` — ads, catalogs, audiences, the ad archive, lead forms and ad comments. -Meta is what this module covers; the Google-only surface is ``client.ads.google``. +The connection decides which network a call reaches, so the same methods run +Meta and TikTok; the Google-only surface is ``client.ads.google``. Every method needs the ``ads`` scope; ``boost``, ``create``, ``set_status``, ``delete``, ``bulk_set_status`` and every create, update, delete or duplicate on @@ -18,9 +19,12 @@ Ad, AdAccountTree, AdActivityResult, + AdBusinessCenter, AdCampaign, + AdCommentsPage, AdConnection, AdCreative, + AdIdentity, AdInsightsReport, AdLabel, AdLibraryPage, @@ -52,6 +56,7 @@ ReachEstimate, ReachFrequencyPrediction, ReachFrequencyResult, + SparkPost, TargetingOption, ValueRuleSet, ) @@ -169,6 +174,7 @@ def create( destination_url: str | None = None, media_url: str | None = None, url_tags: str | None = None, + spark_post_id: str | None = None, paused: bool | None = None, ) -> Ad: """Create a standalone ad from a creative. Starts paused unless ``paused=False``. @@ -191,6 +197,7 @@ def create( "destinationUrl": destination_url, "mediaUrl": media_url, "urlTags": url_tags, + "sparkPostId": spark_post_id, "paused": paused, } body.update({k: v for k, v in optional.items() if v is not None}) @@ -368,8 +375,13 @@ def create_campaign( name: str, goal: str, paused: bool | None = None, + smart_plus: bool | None = None, ) -> AdCampaign: - """Starts paused unless ``paused=False``.""" + """Starts paused unless ``paused=False``. + + ``smart_plus`` hands targeting and creative rotation to the network and + needs its ``smartPlus`` capability. + """ body: dict[str, Any] = { "workspaceId": workspace_id, "connectionId": connection_id, @@ -379,6 +391,8 @@ def create_campaign( } if paused is not None: body["paused"] = paused + if smart_plus is not None: + body["smartPlus"] = smart_plus return AdCampaign.model_validate(unwrap(self._http.post("/ads/campaigns", body))) def get_campaign( @@ -1600,6 +1614,154 @@ def _reach_frequency_action( unwrap(self._http.post(f"/ads/reach-frequency/{prediction_id}/{action}", body)) ) + def tiktok_business_centers( + self, *, connection_id: str, workspace_id: str | None = None + ) -> builtins.list[AdBusinessCenter]: + """TikTok's Business Centers, the one network-named read in this resource.""" + return parse_list( + AdBusinessCenter, + unwrap( + self._http.get( + "/ads/tiktok/business-centers", + {"workspace_id": workspace_id, "connection_id": connection_id}, + ) + ), + ) + + def tiktok_identities( + self, *, connection_id: str, ad_account_id: str, workspace_id: str | None = None + ) -> builtins.list[AdIdentity]: + """The accounts an ad can run as; an identity id is a ``page_id``.""" + return parse_list( + AdIdentity, + unwrap( + self._http.get( + "/ads/tiktok/identities", + { + "workspace_id": workspace_id, + "connection_id": connection_id, + "ad_account_id": ad_account_id, + }, + ) + ), + ) + + def spark_posts( + self, + *, + connection_id: str, + ad_account_id: str, + identity_id: str, + workspace_id: str | None = None, + ) -> builtins.list[SparkPost]: + """Posts already live under an identity, each a candidate Spark ad.""" + return parse_list( + SparkPost, + unwrap( + self._http.get( + "/ads/spark-posts", + { + "workspace_id": workspace_id, + "connection_id": connection_id, + "ad_account_id": ad_account_id, + "identity_id": identity_id, + }, + ) + ), + ) + + def upload_conversions( + self, + *, + workspace_id: str, + connection_id: str, + ad_account_id: str, + pixel_id: str, + events: Sequence[Mapping[str, Any]], + ) -> dict[str, Any]: + """Offline conversions. Identifiers are hashed before they leave FoPost.""" + result = unwrap( + self._http.post( + "/ads/conversions", + { + "workspaceId": workspace_id, + "connectionId": connection_id, + "adAccountId": ad_account_id, + "pixelId": pixel_id, + "events": [dict(e) for e in events], + }, + ) + ) + return result if isinstance(result, dict) else {"data": result} + + def comments( + self, + *, + connection_id: str, + ad_id: str, + after: str | None = None, + workspace_id: str | None = None, + ) -> AdCommentsPage: + """One page of an ad's comments; pass ``next_cursor`` back as ``after``.""" + return AdCommentsPage.model_validate( + unwrap( + self._http.get( + "/ads/comments", + { + "workspace_id": workspace_id, + "connection_id": connection_id, + "ad_id": ad_id, + "after": after, + }, + ) + ) + ) + + def reply_to_comment( + self, comment_id: str, *, workspace_id: str, connection_id: str, ad_id: str, text: str + ) -> dict[str, Any]: + """Needs the ``publish`` scope as well as ``ads``.""" + result = unwrap( + self._http.post( + f"/ads/comments/{comment_id}/reply", + { + "workspaceId": workspace_id, + "connectionId": connection_id, + "adId": ad_id, + "text": text, + }, + ) + ) + return result if isinstance(result, dict) else {"data": result} + + def set_comment_hidden( + self, comment_id: str, *, workspace_id: str, connection_id: str, ad_id: str, hidden: bool + ) -> None: + """Needs the ``publish`` scope as well as ``ads``.""" + self._http.post( + f"/ads/comments/{comment_id}/hide", + { + "workspaceId": workspace_id, + "connectionId": connection_id, + "adId": ad_id, + "hidden": hidden, + }, + ) + + def delete_comment( + self, comment_id: str, *, workspace_id: str, connection_id: str, ad_id: str + ) -> None: + """One already gone on the network succeeds. Needs ``publish`` as well as ``ads``.""" + self._http.request( + "DELETE", + f"/ads/comments/{comment_id}", + json={ + "workspaceId": workspace_id, + "connectionId": connection_id, + "adId": ad_id, + }, + ) + def _object( self, method: str, diff --git a/tests/test_ads_tiktok.py b/tests/test_ads_tiktok.py new file mode 100644 index 0000000..b00e6bf --- /dev/null +++ b/tests/test_ads_tiktok.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import json + +import httpx +import respx + +from fopost import Fopost +from tests.conftest import BASE_URL +from tests.test_ads import AD_FIXTURE + +BUDGET = {"minor": 2000, "type": "daily"} +TARGETING = {"countries": ["US"], "ageMin": 18, "ageMax": 44, "gender": "all"} + + +@respx.mock +def test_identities_and_spark_posts_read_the_right_paths(client: Fopost) -> None: + respx.get(f"{BASE_URL}/ads/tiktok/business-centers").mock( + return_value=httpx.Response(200, json={"data": [{"id": "bc1", "name": "Brand HQ"}]}) + ) + respx.get(f"{BASE_URL}/ads/tiktok/identities").mock( + return_value=httpx.Response( + 200, + json={"data": [{"id": "idt_1", "type": "CUSTOMIZED_USER", "name": "Your Brand"}]}, + ) + ) + spark = respx.get(f"{BASE_URL}/ads/spark-posts").mock( + return_value=httpx.Response( + 200, json={"data": [{"id": "item_99", "identityId": "idt_1", "views": 48213}]} + ) + ) + + centers = client.ads.tiktok_business_centers(workspace_id="ws_1", connection_id="conn_1") + assert centers[0].name == "Brand HQ" + + identities = client.ads.tiktok_identities( + workspace_id="ws_1", connection_id="conn_1", ad_account_id="7011" + ) + assert identities[0].type == "CUSTOMIZED_USER" + + posts = client.ads.spark_posts( + workspace_id="ws_1", connection_id="conn_1", ad_account_id="7011", identity_id="idt_1" + ) + assert posts[0].views == 48213 + assert spark.calls.last.request.url.params["identity_id"] == "idt_1" + + +@respx.mock +def test_spark_post_id_and_smart_plus_travel_in_the_body(client: Fopost) -> None: + ad_route = respx.post(f"{BASE_URL}/ads").mock( + return_value=httpx.Response(201, json={"data": AD_FIXTURE}) + ) + campaign_route = respx.post(f"{BASE_URL}/ads/campaigns").mock( + return_value=httpx.Response( + 201, + json={"data": {"id": "c1", "name": "Smart", "status": "PAUSED"}}, + ) + ) + + client.ads.create( + workspace_id="ws_1", + connection_id="conn_1", + ad_account_id="7011", + page_id="idt_1", + name="Spark", + goal="traffic", + budget=BUDGET, + targeting=TARGETING, + text="", + spark_post_id="item_99", + ) + assert json.loads(ad_route.calls.last.request.content)["sparkPostId"] == "item_99" + + client.ads.create_campaign( + workspace_id="ws_1", + connection_id="conn_1", + ad_account_id="7011", + name="Smart", + goal="traffic", + smart_plus=True, + ) + assert json.loads(campaign_route.calls.last.request.content)["smartPlus"] is True + + +@respx.mock +def test_conversions_hash_nothing_locally_and_report_what_was_accepted(client: Fopost) -> None: + route = respx.post(f"{BASE_URL}/ads/conversions").mock( + return_value=httpx.Response(202, json={"data": {"accepted": 1}}) + ) + + result = client.ads.upload_conversions( + workspace_id="ws_1", + connection_id="conn_1", + ad_account_id="7011", + pixel_id="px_1", + events=[{"eventName": "CompletePayment", "occurredAt": "2026-09-18T10:04:00Z"}], + ) + + assert result["accepted"] == 1 + body = json.loads(route.calls.last.request.content) + assert body["pixelId"] == "px_1" + assert body["events"][0]["eventName"] == "CompletePayment" + + +@respx.mock +def test_comments_page_and_the_three_writes(client: Fopost) -> None: + respx.get(f"{BASE_URL}/ads/comments").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "comments": [{"id": "cm_1", "text": "nice", "hidden": True, "likes": 3}], + "nextCursor": "2", + } + }, + ) + ) + reply = respx.post(f"{BASE_URL}/ads/comments/cm_1/reply").mock( + return_value=httpx.Response(201, json={"data": {"replyId": "cm_2"}}) + ) + hide = respx.post(f"{BASE_URL}/ads/comments/cm_1/hide").mock( + return_value=httpx.Response(200, json={"message": "Comment hidden"}) + ) + delete = respx.delete(f"{BASE_URL}/ads/comments/cm_1").mock( + return_value=httpx.Response(200, json={"message": "Comment deleted"}) + ) + + page = client.ads.comments(workspace_id="ws_1", connection_id="conn_1", ad_id="ad_1") + assert page.next_cursor == "2" + assert page.comments[0].hidden is True + + scope = {"workspace_id": "ws_1", "connection_id": "conn_1", "ad_id": "ad_1"} + assert client.ads.reply_to_comment("cm_1", text="Friday!", **scope)["replyId"] == "cm_2" + assert json.loads(reply.calls.last.request.content)["adId"] == "ad_1" + + client.ads.set_comment_hidden("cm_1", hidden=True, **scope) + assert json.loads(hide.calls.last.request.content)["hidden"] is True + + client.ads.delete_comment("cm_1", **scope) + # The ad travels in the body, because the path already carries the comment. + assert json.loads(delete.calls.last.request.content)["adId"] == "ad_1"