From 1a3b20a75c97eefce8c7093328da0591f8e14cb5 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 20 Sep 2026 20:22:36 +0200 Subject: [PATCH] feat: wrap the per-network account extras Pinterest boards, YouTube playlists and captions, Bluesky post languages, TikTok creator info plus music, place and video lookup, Instagram audio, publishing limit and stories, and LinkedIn mention lookup, as models plus methods on client.accounts. All under the accounts scope. --- src/fopost/__init__.py | 28 ++++ src/fopost/models.py | 147 +++++++++++++++++++++ src/fopost/resources/accounts.py | 219 +++++++++++++++++++++++++++++++ tests/test_accounts.py | 131 ++++++++++++++++++ 4 files changed, 525 insertions(+) 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