From 307eb7741df7ef745acd739552944594221d8892 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 28 Jun 2026 11:19:30 -0500 Subject: [PATCH] feat: add TikTok video upload and photo carousel support (#164) - Video publishing via PULL_FROM_URL with async status polling - Photo carousel up to 35 images via content/init endpoint - Configurable posting mode: DIRECT_POST or MEDIA_UPLOAD - Audit warning language string for unverified app limitations - Updated getSupportedMediaTypes() to include carousel Authored-by: Moko Consulting --- CHANGELOG.md | 4 + .../en-GB/plg_mokosuitecross_tiktok.ini | 3 +- .../src/Extension/TiktokService.php | 151 +++++++++++++++--- 3 files changed, 133 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88e64ec9..e087a877 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ - **Facebook Stories**: Publish image and video Stories via photo_stories/video_stories endpoints (#162) - **Facebook scheduled posts**: Schedule feed posts with scheduled_publish_time parameter (#162) - **Facebook draft posts**: Save feed posts as unpublished drafts (#162) +- **TikTok video upload**: PULL_FROM_URL video publishing via video/init endpoint with status polling (#164) +- **TikTok photo carousel**: Up to 35 image carousel posts via content/init endpoint (#164) +- **TikTok posting mode**: Configurable DIRECT_POST or MEDIA_UPLOAD (sends to TikTok inbox for in-app editing) (#164) +- **TikTok audit warning**: Language string explaining that unverified apps can only create private posts (#164) ### Fixed - Webservices plugin Joomla 6 compatibility — `onBeforeApiRoute` receives `BeforeApiRouteEvent` object, extract router via `$event->getRouter()` diff --git a/source/packages/plg_mokosuitecross_tiktok/language/en-GB/plg_mokosuitecross_tiktok.ini b/source/packages/plg_mokosuitecross_tiktok/language/en-GB/plg_mokosuitecross_tiktok.ini index 168c4a3c..39af88ba 100644 --- a/source/packages/plg_mokosuitecross_tiktok/language/en-GB/plg_mokosuitecross_tiktok.ini +++ b/source/packages/plg_mokosuitecross_tiktok/language/en-GB/plg_mokosuitecross_tiktok.ini @@ -1,2 +1,3 @@ PLG_MOKOSUITECROSS_TIKTOK="MokoSuiteCross - TikTok" -PLG_MOKOSUITECROSS_TIKTOK_DESCRIPTION="Cross-post Joomla articles to TikTok." +PLG_MOKOSUITECROSS_TIKTOK_DESCRIPTION="Cross-post Joomla articles to TikTok via Content Posting API. Supports video uploads (PULL_FROM_URL) and photo carousels (up to 35 images)." +PLG_MOKOSUITECROSS_TIKTOK_AUDIT_WARNING="Unverified TikTok developer apps can only create private posts. To publish publicly, your app must pass TikTok's Content Posting API audit. Visit the TikTok Developer Portal to submit your app for review." diff --git a/source/packages/plg_mokosuitecross_tiktok/src/Extension/TiktokService.php b/source/packages/plg_mokosuitecross_tiktok/src/Extension/TiktokService.php index 561509ee..20ffbf57 100644 --- a/source/packages/plg_mokosuitecross_tiktok/src/Extension/TiktokService.php +++ b/source/packages/plg_mokosuitecross_tiktok/src/Extension/TiktokService.php @@ -17,13 +17,13 @@ use Joomla\CMS\Plugin\CMSPlugin; use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface; use Joomla\Event\SubscriberInterface; -/** - * TikTok service plugin for MokoSuiteCross. - * - * API: https://open.tiktokapis.com/v2/post/publish/content/init/ - */ class TiktokService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface { + private const API_BASE = 'https://open.tiktokapis.com/v2/post/publish/'; + private const MAX_PHOTO_IMAGES = 35; + private const MAX_POLL_ATTEMPTS = 10; + private const POLL_INTERVAL_SECONDS = 3; + public static function getSubscribedEvents(): array { return ['onMokoSuiteCrossGetServices' => 'onMokoSuiteCrossGetServices']; @@ -47,28 +47,129 @@ class TiktokService extends CMSPlugin implements SubscriberInterface, MokoSuiteC return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing access token']]; } - if (empty($media[0])) { + if (empty($media)) { return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'TikTok requires a video or image']]; } - $postData = json_encode([ + $postingMode = $params['posting_mode'] ?? 'DIRECT_POST'; + $privacyLevel = $params['privacy_level'] ?? 'SELF_ONLY'; + $caption = mb_substr($message, 0, 2200); + $title = mb_substr(strip_tags($message), 0, 150); + + if ($this->isVideoUrl($media[0])) { + return $this->publishVideo($token, $title, $caption, $media[0], $postingMode, $privacyLevel); + } + + return $this->publishPhotos($token, $title, $caption, $media, $postingMode, $privacyLevel); + } + + private function publishVideo(string $token, string $title, string $caption, string $videoUrl, string $postingMode, string $privacyLevel): array + { + $payload = [ 'post_info' => [ - 'title' => mb_substr(strip_tags($message), 0, 150), - 'description' => mb_substr($message, 0, 2200), - 'privacy_level' => 'SELF_ONLY', + 'title' => $title, + 'description' => $caption, + 'privacy_level' => $privacyLevel, 'disable_comment' => false, ], 'source_info' => [ - 'source' => 'PULL_FROM_URL', - 'video_url' => $media[0], + 'source' => 'PULL_FROM_URL', + 'video_url' => $videoUrl, ], - ]); + 'post_mode' => $postingMode, + ]; - $ch = curl_init(); + $result = $this->apiPost(self::API_BASE . 'video/init/', $token, $payload); + + if (!$result['success']) { + return ['success' => false, 'platform_post_id' => '', 'response' => $result['data']]; + } + + $publishId = $result['data']['data']['publish_id'] ?? ''; + + if (empty($publishId)) { + return ['success' => true, 'platform_post_id' => '', 'response' => $result['data']]; + } + + return $this->pollPublishStatus($token, $publishId, $result['data']); + } + + private function publishPhotos(string $token, string $title, string $caption, array $media, string $postingMode, string $privacyLevel): array + { + $images = \array_slice($media, 0, self::MAX_PHOTO_IMAGES); + + $photoImages = []; + foreach ($images as $url) { + $photoImages[] = ['image_url' => $url]; + } + + $payload = [ + 'post_info' => [ + 'title' => $title, + 'description' => $caption, + 'privacy_level' => $privacyLevel, + 'disable_comment' => false, + ], + 'source_info' => [ + 'source' => 'PULL_FROM_URL', + 'photo_images' => $photoImages, + ], + 'post_mode' => $postingMode, + 'media_type' => 'PHOTO', + ]; + + $result = $this->apiPost(self::API_BASE . 'content/init/', $token, $payload); + + if (!$result['success']) { + return ['success' => false, 'platform_post_id' => '', 'response' => $result['data']]; + } + + $publishId = $result['data']['data']['publish_id'] ?? ''; + + if (empty($publishId)) { + return ['success' => true, 'platform_post_id' => '', 'response' => $result['data']]; + } + + return $this->pollPublishStatus($token, $publishId, $result['data']); + } + + private function pollPublishStatus(string $token, string $publishId, array $initResponse): array + { + for ($i = 0; $i < self::MAX_POLL_ATTEMPTS; $i++) { + sleep(self::POLL_INTERVAL_SECONDS); + + $statusResult = $this->apiPost(self::API_BASE . 'status/fetch/', $token, [ + 'publish_id' => $publishId, + ]); + + if (!$statusResult['success']) { + continue; + } + + $status = $statusResult['data']['data']['status'] ?? ''; + + if ($status === 'PUBLISH_COMPLETE') { + $postId = $statusResult['data']['data']['publicaly_available_post_id'] + ?? $statusResult['data']['data']['post_id'] + ?? $publishId; + return ['success' => true, 'platform_post_id' => (string) $postId, 'response' => $statusResult['data']]; + } + + if ($status === 'FAILED') { + $failReason = $statusResult['data']['data']['fail_reason'] ?? 'Unknown error'; + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => $failReason, 'data' => $statusResult['data']]]; + } + } + + return ['success' => true, 'platform_post_id' => $publishId, 'response' => array_merge($initResponse, ['note' => 'Publish initiated but status polling timed out'])]; + } + + private function apiPost(string $url, string $token, array $payload): array + { + $ch = curl_init($url); curl_setopt_array($ch, [ - CURLOPT_URL => 'https://open.tiktokapis.com/v2/post/publish/content/init/', CURLOPT_POST => true, - CURLOPT_POSTFIELDS => $postData, + CURLOPT_POSTFIELDS => json_encode($payload), CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: application/json'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, @@ -77,24 +178,26 @@ class TiktokService extends CMSPlugin implements SubscriberInterface, MokoSuiteC $response = curl_exec($ch); if ($response === false) { - $curlError = curl_error($ch); - curl_close($ch); - - return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Connection error: ' . $curlError]]; - + return ['success' => false, 'data' => ['error' => 'Connection error: ' . $curlError]]; } + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $data = json_decode($response, true) ?: []; if ($httpCode >= 200 && $httpCode < 300) { - return ['success' => true, 'platform_post_id' => $data['id'] ?? $data['uri'] ?? '', 'response' => $data]; + return ['success' => true, 'data' => $data]; } - return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + return ['success' => false, 'data' => $data]; + } + + private function isVideoUrl(string $url): bool + { + return (bool) preg_match('/\.(mp4|mov|avi|wmv|webm|mkv)(\?|$)/i', $url); } public function validateCredentials(array $credentials): array @@ -129,6 +232,6 @@ class TiktokService extends CMSPlugin implements SubscriberInterface, MokoSuiteC public function getSupportedMediaTypes(): array { - return ['image', 'video']; + return ['image', 'video', 'carousel']; } }