From 7bd151ad622f4e8ee75c54fc293c4d8d68ffdfc6 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 27 Jun 2026 20:25:33 -0500 Subject: [PATCH] feat: add Threads carousel, polls, and spoiler support (#153) Authored-by: Moko Consulting --- CHANGELOG.md | 4 + .../src/Extension/ThreadsService.php | 176 +++++++++++++----- 2 files changed, 130 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 069ab423..895a6c41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - **Nostr**: Publishes kind-1 text note events to multiple relays with automatic failover - **Nostr**: Raw WebSocket client using stream_socket_client (no external dependencies) - **Nostr**: Public key derivation and event signing via secp256k1 elliptic curve math +- **Threads carousel**: Support up to 20-item carousel posts via Threads API multi-container flow +- **Threads polls**: Poll creation support via poll_options parameter (2-4 options) +- **Threads spoiler tags**: Content warning / spoiler flag support for Threads posts +- **Threads text-only optimization**: Simplified single-step flow for text-only posts without media ### Fixed - Webservices plugin Joomla 6 compatibility — `onBeforeApiRoute` receives `BeforeApiRouteEvent` object, extract router via `$event->getRouter()` diff --git a/source/packages/plg_mokosuitecross_threads/src/Extension/ThreadsService.php b/source/packages/plg_mokosuitecross_threads/src/Extension/ThreadsService.php index 04b77d5a..bc4f1a81 100644 --- a/source/packages/plg_mokosuitecross_threads/src/Extension/ThreadsService.php +++ b/source/packages/plg_mokosuitecross_threads/src/Extension/ThreadsService.php @@ -17,15 +17,13 @@ use Joomla\CMS\Plugin\CMSPlugin; use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface; use Joomla\Event\SubscriberInterface; -/** - * Threads (Meta) service plugin for MokoSuiteCross. - * - * Uses the Threads Publishing API — a 2-step flow: - * 1. Create a media container via POST /{user_id}/threads - * 2. Publish the container via POST /{user_id}/threads_publish - */ class ThreadsService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface { + private const API_BASE = 'https://graph.threads.net/v1.0/'; + private const MAX_CAROUSEL_ITEMS = 20; + private const MAX_POLL_OPTIONS = 4; + private const MIN_POLL_OPTIONS = 2; + public static function getSubscribedEvents(): array { return ['onMokoSuiteCrossGetServices' => 'onMokoSuiteCrossGetServices']; @@ -50,62 +48,104 @@ class ThreadsService extends CMSPlugin implements SubscriberInterface, MokoSuite return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing access token or user ID.']]; } - // Step 1: Create media container - $containerUrl = 'https://graph.threads.net/v1.0/' . urlencode($userId) . '/threads'; + $text = mb_substr($message, 0, 500); + $media = array_filter($media); + + if (\count($media) > 1) { + return $this->publishCarousel($text, $media, $userId, $token, $params); + } + + return $this->publishSinglePost($text, $media, $userId, $token, $params); + } + + private function publishSinglePost(string $text, array $media, string $userId, string $token, array $params): array + { + $containerUrl = self::API_BASE . urlencode($userId) . '/threads'; $containerData = [ - 'text' => mb_substr($message, 0, 500), + 'text' => $text, 'access_token' => $token, ]; - // Attach image if provided if (!empty($media[0])) { - $containerData['media_type'] = 'IMAGE'; - $containerData['image_url'] = $media[0]; + $mediaType = $this->detectMediaType($media[0]); + $containerData['media_type'] = $mediaType; + $containerData[$mediaType === 'VIDEO' ? 'video_url' : 'image_url'] = $media[0]; } else { $containerData['media_type'] = 'TEXT'; } - $ch = curl_init($containerUrl); - curl_setopt_array($ch, [ - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => http_build_query($containerData), - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 30, - ]); + $this->applyPollOptions($containerData, $params); + $this->applySpoilerFlag($containerData, $params); - $response = curl_exec($ch); + $result = $this->apiPost($containerUrl, $containerData); - if ($response === false) { - - $curlError = curl_error($ch); - - curl_close($ch); - - return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Connection error: ' . $curlError]]; - - } - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - $data = json_decode($response, true) ?: []; - - if ($httpCode < 200 || $httpCode >= 300 || empty($data['id'])) { - return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + if (!$result['success']) { + return $result; } - $containerId = $data['id']; + return $this->publishContainer($userId, $token, $result['response']['id']); + } - // Step 2: Publish the container - $publishUrl = 'https://graph.threads.net/v1.0/' . urlencode($userId) . '/threads_publish'; + private function publishCarousel(string $text, array $media, string $userId, string $token, array $params): array + { + $items = \array_slice($media, 0, self::MAX_CAROUSEL_ITEMS); + $childIds = []; + + foreach ($items as $url) { + $mediaType = $this->detectMediaType($url); + $childData = [ + 'is_carousel_item' => 'true', + 'media_type' => $mediaType, + 'access_token' => $token, + ]; + $childData[$mediaType === 'VIDEO' ? 'video_url' : 'image_url'] = $url; + + $childUrl = self::API_BASE . urlencode($userId) . '/threads'; + $result = $this->apiPost($childUrl, $childData); + + if (!$result['success']) { + return $result; + } + + $childIds[] = $result['response']['id']; + } + + $carouselData = [ + 'media_type' => 'CAROUSEL', + 'children' => implode(',', $childIds), + 'text' => $text, + 'access_token' => $token, + ]; + + $this->applySpoilerFlag($carouselData, $params); + + $carouselUrl = self::API_BASE . urlencode($userId) . '/threads'; + $result = $this->apiPost($carouselUrl, $carouselData); + + if (!$result['success']) { + return $result; + } + + return $this->publishContainer($userId, $token, $result['response']['id']); + } + + private function publishContainer(string $userId, string $token, string $containerId): array + { + $publishUrl = self::API_BASE . urlencode($userId) . '/threads_publish'; $publishData = [ 'creation_id' => $containerId, 'access_token' => $token, ]; - $ch = curl_init($publishUrl); + return $this->apiPost($publishUrl, $publishData); + } + + private function apiPost(string $url, array $data): array + { + $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, - CURLOPT_POSTFIELDS => http_build_query($publishData), + CURLOPT_POSTFIELDS => http_build_query($data), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, ]); @@ -113,24 +153,60 @@ class ThreadsService extends CMSPlugin implements SubscriberInterface, MokoSuite $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]]; - } + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); - $data = json_decode($response, true) ?: []; + $responseData = json_decode($response, true) ?: []; - if ($httpCode >= 200 && $httpCode < 300 && !empty($data['id'])) { - return ['success' => true, 'platform_post_id' => (string) $data['id'], 'response' => $data]; + if ($httpCode >= 200 && $httpCode < 300 && !empty($responseData['id'])) { + return ['success' => true, 'platform_post_id' => (string) $responseData['id'], 'response' => $responseData]; } - return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + return ['success' => false, 'platform_post_id' => '', 'response' => $responseData]; + } + + private function applyPollOptions(array &$data, array $params): void + { + $options = $params['poll_options'] ?? []; + + if (empty($options) || !\is_array($options)) { + return; + } + + $options = \array_slice($options, 0, self::MAX_POLL_OPTIONS); + + if (\count($options) < self::MIN_POLL_OPTIONS) { + return; + } + + $data['poll'] = json_encode(['options' => array_values($options)]); + } + + private function applySpoilerFlag(array &$data, array $params): void + { + if (!empty($params['spoiler'])) { + $data['spoiler'] = 'true'; + } + } + + private function detectMediaType(string $url): string + { + $path = strtolower(parse_url($url, PHP_URL_PATH) ?? ''); + $videoExtensions = ['.mp4', '.mov', '.avi', '.wmv', '.webm']; + + foreach ($videoExtensions as $ext) { + if (str_ends_with($path, $ext)) { + return 'VIDEO'; + } + } + + return 'IMAGE'; } public function validateCredentials(array $credentials): array @@ -142,7 +218,7 @@ class ThreadsService extends CMSPlugin implements SubscriberInterface, MokoSuite return ['valid' => false, 'message' => 'Access token and user ID are required.', 'account_name' => '']; } - $ch = curl_init('https://graph.threads.net/v1.0/' . urlencode($userId) . '?fields=username&access_token=' . urlencode($token)); + $ch = curl_init(self::API_BASE . urlencode($userId) . '?fields=username&access_token=' . urlencode($token)); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10,