diff --git a/src/fopost/__init__.py b/src/fopost/__init__.py index bce8f9f..54e9b70 100644 --- a/src/fopost/__init__.py +++ b/src/fopost/__init__.py @@ -53,6 +53,7 @@ Audience, AudienceFilter, AudiencesResult, + BlueskyLanguages, BoostablePost, Broadcast, BroadcastCounts, @@ -108,6 +109,10 @@ InboxThread, InsightsMetrics, InsightsRow, + InstagramAudio, + InstagramPublishingLimit, + InstagramStory, + InstagramStoryInsights, KnowledgeMatch, KnowledgeSource, KnowledgeSyncResult, @@ -120,6 +125,7 @@ LeadPageSubscription, LeadsFeedPage, LeadsPage, + LinkedInMention, MediaItem, MetaGreeting, MetaGreetingText, @@ -131,6 +137,7 @@ NetworkAd, Page, PageMeta, + PinterestBoard, Platform, Post, PostAccount, @@ -152,6 +159,10 @@ TelegramBotCommands, TelegramConnectCode, TelegramConnectStatus, + TikTokCreatorInfo, + TikTokMusic, + TikTokPlace, + TikTokVideoSource, ValidateLengthCheck, ValidateLengthResult, ValidateMediaResult, @@ -159,6 +170,9 @@ ValidatePostResult, WebhookSubscription, Workspace, + YouTubeCaptionTrack, + YouTubePlaylist, + YouTubeTranscript, ) try: @@ -255,6 +269,20 @@ "DiscordMessageRef", "DiscordRole", "DiscordScheduledEvent", + "PinterestBoard", + "YouTubePlaylist", + "YouTubeCaptionTrack", + "YouTubeTranscript", + "BlueskyLanguages", + "TikTokCreatorInfo", + "TikTokMusic", + "TikTokPlace", + "TikTokVideoSource", + "InstagramAudio", + "InstagramPublishingLimit", + "InstagramStory", + "InstagramStoryInsights", + "LinkedInMention", "SlackChannel", "SlackIdentity", "InboxHandover", diff --git a/src/fopost/models.py b/src/fopost/models.py index 45e44a4..14f36fa 100644 --- a/src/fopost/models.py +++ b/src/fopost/models.py @@ -440,6 +440,153 @@ class InboxHandover(FopostModel): control: str +# ─── Per-network extras ────────────────────────────────────────── + + +class PinterestBoard(FopostModel): + """``id`` travels as the ``board_id`` platform setting to pin to this board.""" + + id: str + name: str + privacy: str | None = None + description: str | None = None + image: str | None = None + + +class YouTubePlaylist(FopostModel): + """``is_default`` marks the playlist a new video joins when the post picks none.""" + + id: str + title: str + description: str | None = None + privacy: str | None = None + item_count: int | None = None + thumbnail_url: str | None = None + is_default: bool = False + + +class YouTubeCaptionTrack(FopostModel): + id: str + language: str + name: str = "" + track_kind: str | None = None + is_draft: bool = False + is_auto_synced: bool = False + last_updated: str | None = None + + +class YouTubeTranscript(FopostModel): + """``transcript`` is the track as SRT.""" + + caption_id: str + transcript: str + + +class BlueskyLanguages(FopostModel): + """Up to three BCP-47 tags.""" + + languages: list[str] = [] + + +class TikTokCreatorInfo(FopostModel): + """The switches TikTok enforces at publish time, set on the account itself.""" + + username: str | None = None + nickname: str | None = None + avatar_url: str | None = None + privacy_level_options: list[str] = [] + comment_disabled: bool = False + duet_disabled: bool = False + stitch_disabled: bool = False + max_video_post_duration_sec: int | None = None + + +class TikTokMusic(FopostModel): + """``id`` travels as the ``music_id`` platform setting on a post.""" + + id: str + title: str = "" + author: str | None = None + duration_sec: int | None = None + cover_url: str | None = None + preview_url: str | None = None + + +class TikTokPlace(FopostModel): + """``id`` travels as the ``location_id`` platform setting on a post.""" + + id: str + name: str = "" + address: str | None = None + city: str | None = None + country: str | None = None + + +class TikTokVideoSource(FopostModel): + """One of the account's own videos, resolved from a share link.""" + + video_id: str + title: str | None = None + description: str | None = None + duration_sec: int | None = None + cover_image_url: str | None = None + share_url: str | None = None + embed_link: str | None = None + #: What a repurpose run reads. TikTok serves no raw media file. + download_url: str | None = None + + +class InstagramAudio(FopostModel): + """``id`` travels as the ``audio_id`` platform setting on a Reel.""" + + id: str + title: str | None = None + artist: str | None = None + duration_ms: int | None = None + audio_type: str | None = None + cover_artwork_url: str | None = None + preview_url: str | None = None + username: str | None = None + is_ads_eligible: bool | None = None + + +class InstagramPublishingLimit(FopostModel): + quota_usage: int = 0 + quota_total: int | None = None + quota_duration_sec: int | None = None + remaining: int | None = None + + +class InstagramStory(FopostModel): + """``insights`` is present only when asked for.""" + + id: str + media_type: str | None = None + media_product_type: str | None = None + permalink: str | None = None + media_url: str | None = None + thumbnail_url: str | None = None + caption: str | None = None + timestamp: str | None = None + insights: dict[str, int] | None = None + + +class InstagramStoryInsights(FopostModel): + story_id: str + insights: dict[str, int] = {} + + +class LinkedInMention(FopostModel): + """``annotation`` is what a post's text carries for LinkedIn to render a link.""" + + urn: str + name: str + vanity_name: str | None = None + logo_url: str | None = None + type: str = "" + annotation: str + + class SlackChannel(FopostModel): """``is_current`` marks the channel this account posts to.""" diff --git a/src/fopost/resources/accounts.py b/src/fopost/resources/accounts.py index 2eeab5c..9f5ca18 100644 --- a/src/fopost/resources/accounts.py +++ b/src/fopost/resources/accounts.py @@ -10,6 +10,7 @@ from ..models import ( AccountMove, AccountRename, + BlueskyLanguages, DiscordChannel, DiscordIdentity, DiscordMember, @@ -17,12 +18,18 @@ DiscordMessageRef, DiscordRole, DiscordScheduledEvent, + InstagramAudio, + InstagramPublishingLimit, + InstagramStory, + InstagramStoryInsights, + LinkedInMention, MetaGreeting, MetaGreetingText, MetaIceBreaker, MetaIceBreakers, MetaPersistentMenu, MetaPersistentMenuEntry, + PinterestBoard, SlackChannel, SlackIdentity, SlackMember, @@ -31,7 +38,14 @@ TelegramBotCommands, TelegramConnectCode, TelegramConnectStatus, + TikTokCreatorInfo, + TikTokMusic, + TikTokPlace, + TikTokVideoSource, WebhookSubscription, + YouTubeCaptionTrack, + YouTubePlaylist, + YouTubeTranscript, ) from ._base import UNSET, Resource, drop_unset, parse_list @@ -345,6 +359,105 @@ def send_discord_dm(self, account_id: str, member_id: str, content: str) -> Disc ) ) + # ─── Per-network extras ────────────────────────────────────── + + def list_pinterest_boards(self, account_id: str) -> builtins.list[PinterestBoard]: + """Boards this Pinterest connection can pin to.""" + return parse_list( + PinterestBoard, unwrap(self._http.get(f"/accounts/{account_id}/pinterest/boards")) + ) + + def create_pinterest_board( + self, + account_id: str, + *, + name: str, + description: str | None = None, + privacy: str | None = None, + ) -> PinterestBoard: + """``privacy`` is ``PUBLIC``, ``PROTECTED`` or ``SECRET``.""" + body = drop_unset( + { + "name": name, + "description": description if description is not None else UNSET, + "privacy": privacy if privacy is not None else UNSET, + } + ) + return PinterestBoard.model_validate( + unwrap(self._http.post(f"/accounts/{account_id}/pinterest/boards", body)) + ) + + def list_youtube_playlists(self, account_id: str) -> builtins.list[YouTubePlaylist]: + """The channel's own playlists, with the stored default marked.""" + return parse_list( + YouTubePlaylist, unwrap(self._http.get(f"/accounts/{account_id}/youtube/playlists")) + ) + + def create_youtube_playlist( + self, + account_id: str, + *, + title: str, + description: str | None = None, + privacy: str | None = None, + ) -> YouTubePlaylist: + body = drop_unset( + { + "title": title, + "description": description if description is not None else UNSET, + "privacy": privacy if privacy is not None else UNSET, + } + ) + return YouTubePlaylist.model_validate( + unwrap(self._http.post(f"/accounts/{account_id}/youtube/playlists", body)) + ) + + def set_default_youtube_playlist(self, account_id: str, playlist_id: str | None) -> str | None: + """The playlist a new video joins when the post picks none; ``None`` clears it.""" + data = unwrap( + self._http.put( + f"/accounts/{account_id}/youtube/playlists/default", + {"playlist_id": playlist_id}, + ) + ) + stored = data.get("playlist_id") + return stored if isinstance(stored, str) else None + + def list_youtube_captions( + self, account_id: str, video_id: str + ) -> builtins.list[YouTubeCaptionTrack]: + return parse_list( + YouTubeCaptionTrack, + unwrap(self._http.get(f"/accounts/{account_id}/youtube/videos/{video_id}/captions")), + ) + + def upload_youtube_captions( + self, + account_id: str, + video_id: str, + *, + language: str, + body: str, + name: str | None = None, + is_draft: bool | None = None, + ) -> YouTubeCaptionTrack: + """``body`` is the subtitle file; YouTube reads SRT and WebVTT and sniffs which.""" + payload = drop_unset( + { + "language": language, + "body": body, + "name": name if name is not None else UNSET, + "is_draft": is_draft if is_draft is not None else UNSET, + } + ) + return YouTubeCaptionTrack.model_validate( + unwrap( + self._http.post( + f"/accounts/{account_id}/youtube/videos/{video_id}/captions", payload + ) + ) + ) + def list_discord_events(self, account_id: str) -> builtins.list[DiscordScheduledEvent]: return parse_list( DiscordScheduledEvent, @@ -410,6 +523,27 @@ def update_discord_event( ) ) + def read_youtube_transcript(self, account_id: str, caption_id: str) -> YouTubeTranscript: + return YouTubeTranscript.model_validate( + unwrap(self._http.get(f"/accounts/{account_id}/youtube/captions/{caption_id}")) + ) + + def get_bluesky_languages(self, account_id: str) -> BlueskyLanguages: + """What a post from this connection is written in when it does not say.""" + return BlueskyLanguages.model_validate( + unwrap(self._http.get(f"/accounts/{account_id}/bluesky/languages")) + ) + + def set_bluesky_languages(self, account_id: str, languages: Sequence[str]) -> BlueskyLanguages: + """Up to three BCP-47 tags; an empty list clears the default.""" + return BlueskyLanguages.model_validate( + unwrap( + self._http.put( + f"/accounts/{account_id}/bluesky/languages", {"languages": list(languages)} + ) + ) + ) + def delete_discord_event(self, account_id: str, event_id: str) -> bool: data = unwrap(self._http.delete(f"/accounts/{account_id}/discord/events/{event_id}")) return bool(data.get("deleted")) if isinstance(data, Mapping) else False @@ -503,6 +637,91 @@ def remove_discord_member_role(self, account_id: str, role_id: str, member_id: s ) return bool(data.get("assigned")) if isinstance(data, Mapping) else False + def get_tiktok_creator_info(self, account_id: str) -> TikTokCreatorInfo: + """The switches TikTok enforces at publish time, changed in the TikTok app.""" + return TikTokCreatorInfo.model_validate( + unwrap(self._http.get(f"/accounts/{account_id}/tiktok/creator-info")) + ) + + def search_tiktok_music( + self, account_id: str, *, q: str, limit: int | None = None + ) -> builtins.list[TikTokMusic]: + """TikTok's Commercial Music Library. + + Needs the Marketing API product on the TikTok app; without it the call + raises rather than answering an empty list. + """ + return parse_list( + TikTokMusic, + unwrap( + self._http.get( + f"/accounts/{account_id}/tiktok/music", params={"q": q, "limit": limit} + ) + ), + ) + + def search_tiktok_locations( + self, account_id: str, *, q: str, limit: int | None = None + ) -> builtins.list[TikTokPlace]: + """Places a post can be tagged with. Same TikTok product as the music library.""" + return parse_list( + TikTokPlace, + unwrap( + self._http.get( + f"/accounts/{account_id}/tiktok/locations", params={"q": q, "limit": limit} + ) + ), + ) + + def lookup_tiktok_video(self, account_id: str, url: str) -> TikTokVideoSource: + """Resolve a share link to one of this account's own videos, for repurposing.""" + return TikTokVideoSource.model_validate( + unwrap(self._http.post(f"/accounts/{account_id}/tiktok/video-download", {"url": url})) + ) + + def search_instagram_audio( + self, account_id: str, *, q: str | None = None, audio_type: str | None = None + ) -> builtins.list[InstagramAudio]: + """Tracks a Reel can carry; with no query Instagram answers with what is trending.""" + params = {"q": q, "audio_type": audio_type} + return parse_list( + InstagramAudio, + unwrap(self._http.get(f"/accounts/{account_id}/instagram/audio", params=params)), + ) + + def get_instagram_publishing_limit(self, account_id: str) -> InstagramPublishingLimit: + """How many posts are left before Instagram refuses the next one.""" + return InstagramPublishingLimit.model_validate( + unwrap(self._http.get(f"/accounts/{account_id}/instagram/publishing-limit")) + ) + + def list_instagram_stories( + self, account_id: str, *, insights: bool | None = None + ) -> builtins.list[InstagramStory]: + """Stories still inside their 24 hours, posted through FoPost or not.""" + return parse_list( + InstagramStory, + unwrap( + self._http.get( + f"/accounts/{account_id}/instagram/stories", params={"insights": insights} + ) + ), + ) + + def get_instagram_story_insights( + self, account_id: str, story_id: str + ) -> InstagramStoryInsights: + return InstagramStoryInsights.model_validate( + unwrap(self._http.get(f"/accounts/{account_id}/instagram/stories/{story_id}/insights")) + ) + + def search_linkedin_mentions(self, account_id: str, q: str) -> builtins.list[LinkedInMention]: + """Organizations a post can mention. People are not searchable on LinkedIn.""" + return parse_list( + LinkedInMention, + unwrap(self._http.get(f"/accounts/{account_id}/linkedin/mentions", params={"q": q})), + ) + def _discord_event_body(**fields: Any) -> dict[str, Any]: """Only the fields the caller named; Discord keeps the rest as they are.""" diff --git a/tests/test_accounts.py b/tests/test_accounts.py index 83ec2bd..ab7cb29 100644 --- a/tests/test_accounts.py +++ b/tests/test_accounts.py @@ -400,3 +400,134 @@ def test_discord_webhook_connection_raises(client: Fopost) -> None: client.accounts.list_discord_channels("acc_1") assert excinfo.value.status == 409 assert excinfo.value.code == "webhook_connection" + + +@respx.mock +def test_pinterest_board_create_sends_only_what_was_given(client: Fopost) -> None: + board = {"id": "b1", "name": "Recipes", "privacy": "PUBLIC", "description": None} + route = respx.post(f"{BASE_URL}/accounts/acc_1/pinterest/boards").mock( + return_value=httpx.Response(201, json={"data": board}) + ) + + created = client.accounts.create_pinterest_board("acc_1", name="Recipes") + assert created.id == "b1" + assert json.loads(route.calls.last.request.content) == {"name": "Recipes"} + + +@respx.mock +def test_youtube_playlists_and_transcript(client: Fopost) -> None: + respx.get(f"{BASE_URL}/accounts/acc_1/youtube/playlists").mock( + return_value=httpx.Response( + 200, json={"data": [{"id": "PL1", "title": "Tutorials", "is_default": True}]} + ) + ) + respx.get(f"{BASE_URL}/accounts/acc_1/youtube/captions/cap1").mock( + return_value=httpx.Response( + 200, json={"data": {"caption_id": "cap1", "transcript": "1\nHello\n"}} + ) + ) + + playlists = client.accounts.list_youtube_playlists("acc_1") + assert playlists[0].is_default is True + assert client.accounts.read_youtube_transcript("acc_1", "cap1").transcript.endswith("\n") + + +@respx.mock +def test_bluesky_languages_round_trip(client: Fopost) -> None: + path = f"{BASE_URL}/accounts/acc_1/bluesky/languages" + route = respx.put(path).mock( + return_value=httpx.Response(200, json={"data": {"languages": ["en", "pt-BR"]}}) + ) + + result = client.accounts.set_bluesky_languages("acc_1", ["en", "pt-BR"]) + assert result.languages == ["en", "pt-BR"] + assert json.loads(route.calls.last.request.content) == {"languages": ["en", "pt-BR"]} + + +@respx.mock +def test_instagram_and_linkedin_reads(client: Fopost) -> None: + respx.get(f"{BASE_URL}/accounts/acc_1/instagram/publishing-limit").mock( + return_value=httpx.Response( + 200, json={"data": {"quota_usage": 12, "quota_total": 50, "remaining": 38}} + ) + ) + respx.get(f"{BASE_URL}/accounts/acc_1/linkedin/mentions").mock( + return_value=httpx.Response( + 200, + json={ + "data": [ + { + "urn": "urn:li:organization:2414183", + "name": "Devtestco", + "annotation": "@[Devtestco](urn:li:organization:2414183)", + } + ] + }, + ) + ) + + assert client.accounts.get_instagram_publishing_limit("acc_1").remaining == 38 + mentions = client.accounts.search_linkedin_mentions("acc_1", "devtestco") + assert mentions[0].annotation.endswith("(urn:li:organization:2414183)") + + +@respx.mock +def test_tiktok_creator_info_reports_the_accounts_own_switches(client: Fopost) -> None: + respx.get(f"{BASE_URL}/accounts/acc_1/tiktok/creator-info").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "privacy_level_options": ["PUBLIC_TO_EVERYONE"], + "duet_disabled": True, + "max_video_post_duration_sec": 600, + } + }, + ) + ) + + info = client.accounts.get_tiktok_creator_info("acc_1") + assert info.duet_disabled is True + assert info.stitch_disabled is False + assert info.max_video_post_duration_sec == 600 + + +@respx.mock +def test_tiktok_music_and_place_search_pass_the_query_through(client: Fopost) -> None: + music = respx.get(f"{BASE_URL}/accounts/acc_1/tiktok/music").mock( + return_value=httpx.Response( + 200, json={"data": [{"id": "m1", "title": "Sunrise", "author": "Kite"}]} + ) + ) + respx.get(f"{BASE_URL}/accounts/acc_1/tiktok/locations").mock( + return_value=httpx.Response(200, json={"data": [{"id": "p1", "name": "Blue Bottle"}]}) + ) + + tracks = client.accounts.search_tiktok_music("acc_1", q="sunrise", limit=5) + assert tracks[0].id == "m1" + assert music.calls.last.request.url.params["q"] == "sunrise" + assert music.calls.last.request.url.params["limit"] == "5" + + places = client.accounts.search_tiktok_locations("acc_1", q="cafe") + assert places[0].name == "Blue Bottle" + + +@respx.mock +def test_tiktok_video_lookup_returns_the_address_a_repurpose_run_reads(client: Fopost) -> None: + respx.post(f"{BASE_URL}/accounts/acc_1/tiktok/video-download").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "video_id": "7300000000000000000", + "download_url": "https://www.tiktok.com/@a/video/7300000000000000000", + } + }, + ) + ) + + video = client.accounts.lookup_tiktok_video( + "acc_1", "https://www.tiktok.com/@a/video/7300000000000000000" + ) + assert video.video_id == "7300000000000000000" + assert video.download_url is not None