From c663b5282836968a5cbffded2ceb5382d06a5442 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 20 Sep 2026 20:24:54 +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. All on accounts, all under the accounts scope. --- src/Model/BlueskyLanguages.php | 29 +++ src/Model/InstagramAudio.php | 42 +++++ src/Model/InstagramPublishingLimit.php | 32 ++++ src/Model/InstagramStory.php | 45 +++++ src/Model/InstagramStoryInsights.php | 31 ++++ src/Model/LinkedInMention.php | 36 ++++ src/Model/PinterestBoard.php | 34 ++++ src/Model/TikTokCreatorInfo.php | 43 +++++ src/Model/TikTokMusic.php | 36 ++++ src/Model/TikTokPlace.php | 34 ++++ src/Model/TikTokVideoSource.php | 41 +++++ src/Model/YouTubeCaptionTrack.php | 38 ++++ src/Model/YouTubePlaylist.php | 38 ++++ src/Model/YouTubeTranscript.php | 28 +++ src/Resource/AccountsResource.php | 240 +++++++++++++++++++++++++ tests/AccountsTest.php | 95 ++++++++++ 16 files changed, 842 insertions(+) create mode 100644 src/Model/BlueskyLanguages.php create mode 100644 src/Model/InstagramAudio.php create mode 100644 src/Model/InstagramPublishingLimit.php create mode 100644 src/Model/InstagramStory.php create mode 100644 src/Model/InstagramStoryInsights.php create mode 100644 src/Model/LinkedInMention.php create mode 100644 src/Model/PinterestBoard.php create mode 100644 src/Model/TikTokCreatorInfo.php create mode 100644 src/Model/TikTokMusic.php create mode 100644 src/Model/TikTokPlace.php create mode 100644 src/Model/TikTokVideoSource.php create mode 100644 src/Model/YouTubeCaptionTrack.php create mode 100644 src/Model/YouTubePlaylist.php create mode 100644 src/Model/YouTubeTranscript.php diff --git a/src/Model/BlueskyLanguages.php b/src/Model/BlueskyLanguages.php new file mode 100644 index 0000000..9bb50ef --- /dev/null +++ b/src/Model/BlueskyLanguages.php @@ -0,0 +1,29 @@ + $languages + */ + private function __construct( + array $raw, + public readonly array $languages, + ) { + parent::__construct($raw); + } + + public static function fromArray(mixed $data): static + { + $data = is_array($data) ? $data : []; + + return new self( + $data, + self::seq($data, 'languages'), + ); + } +} diff --git a/src/Model/InstagramAudio.php b/src/Model/InstagramAudio.php new file mode 100644 index 0000000..2d6f950 --- /dev/null +++ b/src/Model/InstagramAudio.php @@ -0,0 +1,42 @@ +|null $insights + */ + private function __construct( + array $raw, + public readonly string $id, + public readonly ?string $mediaType, + public readonly ?string $mediaProductType, + public readonly ?string $permalink, + public readonly ?string $mediaUrl, + public readonly ?string $thumbnailUrl, + public readonly ?string $caption, + public readonly ?string $timestamp, + public readonly ?array $insights, + ) { + parent::__construct($raw); + } + + public static function fromArray(mixed $data): static + { + $data = is_array($data) ? $data : []; + + return new self( + $data, + self::requiredStr($data, 'id'), + self::str($data, 'media_type'), + self::str($data, 'media_product_type'), + self::str($data, 'permalink'), + self::str($data, 'media_url'), + self::str($data, 'thumbnail_url'), + self::str($data, 'caption'), + self::str($data, 'timestamp'), + self::nested($data, 'insights'), + ); + } +} diff --git a/src/Model/InstagramStoryInsights.php b/src/Model/InstagramStoryInsights.php new file mode 100644 index 0000000..2113dc3 --- /dev/null +++ b/src/Model/InstagramStoryInsights.php @@ -0,0 +1,31 @@ + $insights + */ + private function __construct( + array $raw, + public readonly string $storyId, + public readonly array $insights, + ) { + parent::__construct($raw); + } + + public static function fromArray(mixed $data): static + { + $data = is_array($data) ? $data : []; + + return new self( + $data, + self::requiredStr($data, 'story_id'), + self::map($data, 'insights'), + ); + } +} diff --git a/src/Model/LinkedInMention.php b/src/Model/LinkedInMention.php new file mode 100644 index 0000000..660ab13 --- /dev/null +++ b/src/Model/LinkedInMention.php @@ -0,0 +1,36 @@ + $privacyLevelOptions + */ + private function __construct( + array $raw, + public readonly ?string $username, + public readonly ?string $nickname, + public readonly ?string $avatarUrl, + public readonly array $privacyLevelOptions, + public readonly bool $commentDisabled, + public readonly bool $duetDisabled, + public readonly bool $stitchDisabled, + public readonly ?int $maxVideoPostDurationSec, + ) { + parent::__construct($raw); + } + + public static function fromArray(mixed $data): static + { + $data = is_array($data) ? $data : []; + + return new self( + $data, + self::str($data, 'username'), + self::str($data, 'nickname'), + self::str($data, 'avatar_url'), + self::seq($data, 'privacy_level_options'), + self::bool($data, 'comment_disabled') ?? false, + self::bool($data, 'duet_disabled') ?? false, + self::bool($data, 'stitch_disabled') ?? false, + self::int($data, 'max_video_post_duration_sec'), + ); + } +} diff --git a/src/Model/TikTokMusic.php b/src/Model/TikTokMusic.php new file mode 100644 index 0000000..714933c --- /dev/null +++ b/src/Model/TikTokMusic.php @@ -0,0 +1,36 @@ +accounts(): the social accounts connected to a workspace. */ @@ -589,4 +603,230 @@ private static function discordRoleBody( return array_filter($body, static fn (mixed $value): bool => $value !== null); } + // --- Per-network extras ------------------------------------------- + + /** + * Boards this Pinterest connection can pin to. + * + * @return array + */ + public function listPinterestBoards(string $accountId): array + { + return PinterestBoard::listFrom(self::unwrap($this->http->get("/accounts/{$accountId}/pinterest/boards"))); + } + + /** $privacy is PUBLIC, PROTECTED or SECRET. */ + public function createPinterestBoard( + string $accountId, + string $name, + ?string $description = null, + ?string $privacy = null, + ): PinterestBoard { + $body = ['name' => $name]; + if ($description !== null) { + $body['description'] = $description; + } + if ($privacy !== null) { + $body['privacy'] = $privacy; + } + + return PinterestBoard::fromArray( + self::unwrap($this->http->post("/accounts/{$accountId}/pinterest/boards", $body)), + ); + } + + /** + * The channel's own playlists, with the stored default marked. + * + * @return array + */ + public function listYouTubePlaylists(string $accountId): array + { + return YouTubePlaylist::listFrom(self::unwrap($this->http->get("/accounts/{$accountId}/youtube/playlists"))); + } + + public function createYouTubePlaylist( + string $accountId, + string $title, + ?string $description = null, + ?string $privacy = null, + ): YouTubePlaylist { + $body = ['title' => $title]; + if ($description !== null) { + $body['description'] = $description; + } + if ($privacy !== null) { + $body['privacy'] = $privacy; + } + + return YouTubePlaylist::fromArray( + self::unwrap($this->http->post("/accounts/{$accountId}/youtube/playlists", $body)), + ); + } + + /** The playlist a new video joins when the post picks none; null clears it. */ + public function setDefaultYouTubePlaylist(string $accountId, ?string $playlistId): ?string + { + $data = self::unwrap( + $this->http->put("/accounts/{$accountId}/youtube/playlists/default", ['playlist_id' => $playlistId]), + ); + $stored = is_array($data) ? ($data['playlist_id'] ?? null) : null; + + return is_string($stored) ? $stored : null; + } + + /** @return array */ + public function listYouTubeCaptions(string $accountId, string $videoId): array + { + return YouTubeCaptionTrack::listFrom( + self::unwrap($this->http->get("/accounts/{$accountId}/youtube/videos/{$videoId}/captions")), + ); + } + + /** $body is the subtitle file itself; YouTube reads SRT and WebVTT and sniffs which. */ + public function uploadYouTubeCaptions( + string $accountId, + string $videoId, + string $language, + string $body, + ?string $name = null, + ?bool $isDraft = null, + ): YouTubeCaptionTrack { + $payload = ['language' => $language, 'body' => $body]; + if ($name !== null) { + $payload['name'] = $name; + } + if ($isDraft !== null) { + $payload['is_draft'] = $isDraft; + } + + return YouTubeCaptionTrack::fromArray( + self::unwrap($this->http->post("/accounts/{$accountId}/youtube/videos/{$videoId}/captions", $payload)), + ); + } + + public function readYouTubeTranscript(string $accountId, string $captionId): YouTubeTranscript + { + return YouTubeTranscript::fromArray( + self::unwrap($this->http->get("/accounts/{$accountId}/youtube/captions/{$captionId}")), + ); + } + + /** What a post from this connection is written in when it does not say. */ + public function getBlueskyLanguages(string $accountId): BlueskyLanguages + { + return BlueskyLanguages::fromArray( + self::unwrap($this->http->get("/accounts/{$accountId}/bluesky/languages")), + ); + } + + /** + * Up to three BCP-47 tags; an empty list clears the default. + * + * @param array $languages + */ + public function setBlueskyLanguages(string $accountId, array $languages): BlueskyLanguages + { + return BlueskyLanguages::fromArray( + self::unwrap( + $this->http->put("/accounts/{$accountId}/bluesky/languages", ['languages' => array_values($languages)]), + ), + ); + } + + /** The switches TikTok enforces at publish time, changed in the TikTok app. */ + public function getTikTokCreatorInfo(string $accountId): TikTokCreatorInfo + { + return TikTokCreatorInfo::fromArray( + self::unwrap($this->http->get("/accounts/{$accountId}/tiktok/creator-info")), + ); + } + + /** + * TikTok's Commercial Music Library. + * + * Needs the Marketing API product on the TikTok app; without it the call + * fails with 403 rather than answering an empty list. + * + * @return array + */ + public function searchTikTokMusic(string $accountId, string $q, ?int $limit = null): array + { + return TikTokMusic::listFrom( + self::unwrap($this->http->get("/accounts/{$accountId}/tiktok/music", ['q' => $q, 'limit' => $limit])), + ); + } + + /** + * Places a post can be tagged with. Same TikTok product as the music library. + * + * @return array + */ + public function searchTikTokLocations(string $accountId, string $q, ?int $limit = null): array + { + return TikTokPlace::listFrom( + self::unwrap($this->http->get("/accounts/{$accountId}/tiktok/locations", ['q' => $q, 'limit' => $limit])), + ); + } + + /** Resolve a share link to one of this account's own videos, for repurposing. */ + public function lookupTikTokVideo(string $accountId, string $url): TikTokVideoSource + { + return TikTokVideoSource::fromArray( + self::unwrap($this->http->post("/accounts/{$accountId}/tiktok/video-download", ['url' => $url])), + ); + } + + /** + * Tracks a Reel can carry; with no query Instagram answers with what is trending. + * + * @return array + */ + public function searchInstagramAudio(string $accountId, ?string $q = null, ?string $audioType = null): array + { + $params = ['q' => $q, 'audio_type' => $audioType]; + + return InstagramAudio::listFrom( + self::unwrap($this->http->get("/accounts/{$accountId}/instagram/audio", $params)), + ); + } + + /** How many posts are left before Instagram refuses the next one. */ + public function getInstagramPublishingLimit(string $accountId): InstagramPublishingLimit + { + return InstagramPublishingLimit::fromArray( + self::unwrap($this->http->get("/accounts/{$accountId}/instagram/publishing-limit")), + ); + } + + /** + * Stories still inside their 24 hours, posted through FoPost or not. + * + * @return array + */ + public function listInstagramStories(string $accountId, ?bool $insights = null): array + { + return InstagramStory::listFrom( + self::unwrap($this->http->get("/accounts/{$accountId}/instagram/stories", ['insights' => $insights])), + ); + } + + public function getInstagramStoryInsights(string $accountId, string $storyId): InstagramStoryInsights + { + return InstagramStoryInsights::fromArray( + self::unwrap($this->http->get("/accounts/{$accountId}/instagram/stories/{$storyId}/insights")), + ); + } + + /** + * Organizations a LinkedIn post can mention. People are not searchable. + * + * @return array + */ + public function searchLinkedInMentions(string $accountId, string $q): array + { + return LinkedInMention::listFrom( + self::unwrap($this->http->get("/accounts/{$accountId}/linkedin/mentions", ['q' => $q])), + ); + } } diff --git a/tests/AccountsTest.php b/tests/AccountsTest.php index b411f08..0e00d24 100644 --- a/tests/AccountsTest.php +++ b/tests/AccountsTest.php @@ -364,4 +364,99 @@ public function testDiscordWebhookConnectionIsAnApiException(): void $this->assertSame('webhook_connection', $e->errorCode); } } + public function testCreatePinterestBoardSendsOnlyWhatWasGiven(): void + { + $this->transport->push(201, ['data' => ['id' => 'b1', 'name' => 'Recipes', 'privacy' => 'PUBLIC']]); + + $board = $this->client()->accounts()->createPinterestBoard('a_1', 'Recipes'); + + $this->assertSame('POST', $this->transport->last()['method']); + $this->assertSame('https://api.fopost.com/v1/accounts/a_1/pinterest/boards', $this->transport->last()['url']); + $this->assertSame(['name' => 'Recipes'], $this->transport->lastJson()); + $this->assertSame('b1', $board->id); + } + + public function testYouTubePlaylistsAndTranscript(): void + { + $this->transport->push(200, ['data' => [['id' => 'PL1', 'title' => 'Tutorials', 'is_default' => true]]]); + $playlists = $this->client()->accounts()->listYouTubePlaylists('a_1'); + $this->assertTrue($playlists[0]->isDefault); + + $this->transport->push(200, ['data' => ['caption_id' => 'cap1', 'transcript' => "1\nHello\n"]]); + $transcript = $this->client()->accounts()->readYouTubeTranscript('a_1', 'cap1'); + $this->assertStringContainsString('Hello', $transcript->transcript); + } + + public function testBlueskyLanguagesRoundTrip(): void + { + $this->transport->push(200, ['data' => ['languages' => ['en', 'pt-BR']]]); + + $result = $this->client()->accounts()->setBlueskyLanguages('a_1', ['en', 'pt-BR']); + + $this->assertSame('PUT', $this->transport->last()['method']); + $this->assertSame(['languages' => ['en', 'pt-BR']], $this->transport->lastJson()); + $this->assertSame(['en', 'pt-BR'], $result->languages); + } + + public function testTikTokCreatorInfoReportsTheAccountsOwnSwitches(): void + { + $this->transport->push(200, ['data' => [ + 'privacy_level_options' => ['PUBLIC_TO_EVERYONE'], + 'duet_disabled' => true, + 'max_video_post_duration_sec' => 600, + ]]); + + $info = $this->client()->accounts()->getTikTokCreatorInfo('a_1'); + + $this->assertTrue($info->duetDisabled); + $this->assertFalse($info->stitchDisabled); + $this->assertSame(600, $info->maxVideoPostDurationSec); + } + + public function testTikTokMusicAndPlaceSearchPassTheQueryThrough(): void + { + $this->transport->push(200, ['data' => [ + ['id' => 'm1', 'title' => 'Sunrise', 'author' => 'Kite'], + ]]); + + $tracks = $this->client()->accounts()->searchTikTokMusic('a_1', 'sunrise', 5); + + $this->assertSame('m1', $tracks[0]->id); + $this->assertStringContainsString('q=sunrise', $this->transport->last()['url']); + $this->assertStringContainsString('limit=5', $this->transport->last()['url']); + + $this->transport->push(200, ['data' => [['id' => 'p1', 'name' => 'Blue Bottle']]]); + $places = $this->client()->accounts()->searchTikTokLocations('a_1', 'cafe'); + $this->assertSame('Blue Bottle', $places[0]->name); + } + + public function testTikTokVideoLookupReturnsTheAddressARepurposeRunReads(): void + { + $this->transport->push(200, ['data' => [ + 'video_id' => '7300000000000000000', + 'download_url' => 'https://www.tiktok.com/@a/video/7300000000000000000', + ]]); + + $video = $this->client()->accounts()->lookupTikTokVideo( + 'a_1', + 'https://www.tiktok.com/@a/video/7300000000000000000', + ); + + $this->assertSame('7300000000000000000', $video->videoId); + $this->assertNotNull($video->downloadUrl); + } + + public function testInstagramAndLinkedInReads(): void + { + $this->transport->push(200, ['data' => ['quota_usage' => 12, 'quota_total' => 50, 'remaining' => 38]]); + $this->assertSame(38, $this->client()->accounts()->getInstagramPublishingLimit('a_1')->remaining); + + $this->transport->push(200, ['data' => [[ + 'urn' => 'urn:li:organization:2414183', + 'name' => 'Devtestco', + 'annotation' => '@[Devtestco](urn:li:organization:2414183)', + ]]]); + $mentions = $this->client()->accounts()->searchLinkedInMentions('a_1', 'devtestco'); + $this->assertStringEndsWith('(urn:li:organization:2414183)', $mentions[0]->annotation); + } }