Merge pull request 'feat: TikTok video upload and photo carousel (#164)' (#196) from feature/164-tiktok-enhancements into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 11s

This commit was merged in pull request #196.
This commit is contained in:
2026-06-28 16:25:09 +00:00
3 changed files with 133 additions and 25 deletions
+4
View File
@@ -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()`
@@ -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."
@@ -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'];
}
}