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); + } }