From ddc867ad0611341749bb3c61ef4e50b97a3c69fb Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 27 Jun 2026 20:25:26 -0500 Subject: [PATCH] feat: add Instagram carousel, Reels, and Stories support (#151) Authored-by: Moko Consulting --- CHANGELOG.md | 4 + .../src/Extension/InstagramService.php | 176 ++++++++++++++---- 2 files changed, 142 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 069ab423..65a4b16a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] ### Added +- **Instagram carousel**: Multi-image/video posts via Meta carousel container flow (up to 10 items) +- **Instagram Reels**: Short-form video publishing via REELS media type +- **Instagram Stories**: Image and video story publishing via STORIES media type +- **Instagram alt text**: Alt text support for image containers - **Nostr plugin**: Full NIP-01 WebSocket relay publishing with BIP-340 Schnorr signatures (pure PHP, requires ext-gmp) - **Nostr**: Publishes kind-1 text note events to multiple relays with automatic failover - **Nostr**: Raw WebSocket client using stream_socket_client (no external dependencies) diff --git a/source/packages/plg_mokosuitecross_instagram/src/Extension/InstagramService.php b/source/packages/plg_mokosuitecross_instagram/src/Extension/InstagramService.php index 693d5da5..317535eb 100644 --- a/source/packages/plg_mokosuitecross_instagram/src/Extension/InstagramService.php +++ b/source/packages/plg_mokosuitecross_instagram/src/Extension/InstagramService.php @@ -20,7 +20,7 @@ use Joomla\Event\SubscriberInterface; /** * Instagram service plugin for MokoSuiteCross. * - * Uses the Meta Content Publishing API — a 2-step flow: + * Uses the Meta Content Publishing API -- a 2-step flow: * 1. Create a media container via POST /{ig_user_id}/media * 2. Publish the container via POST /{ig_user_id}/media_publish */ @@ -50,24 +50,128 @@ class InstagramService extends CMSPlugin implements SubscriberInterface, MokoSui return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing access token or Instagram account ID.']]; } - // Step 1: Create media container - $containerUrl = 'https://graph.facebook.com/v19.0/' . urlencode($accountId) . '/media'; - $containerData = [ - 'caption' => mb_substr($message, 0, 2200), - 'access_token' => $token, - ]; - - // Attach image if provided - if (!empty($media[0])) { - $containerData['image_url'] = $media[0]; - } else { + if (empty($media)) { return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Instagram requires at least one image or video.']]; } - $ch = curl_init($containerUrl); + $caption = mb_substr($message, 0, 2200); + $mediaType = $params['media_type'] ?? ''; + $altText = $params['alt_text'] ?? ''; + + if ($mediaType === 'reels') { + return $this->publishReels($accountId, $token, $caption, $media[0]); + } + + if ($mediaType === 'stories') { + return $this->publishStories($accountId, $token, $media[0]); + } + + if (\count($media) > 1) { + return $this->publishCarousel($accountId, $token, $caption, $media, $altText); + } + + $fields = [ + 'caption' => $caption, + 'image_url' => $media[0], + ]; + + if ($altText !== '') { + $fields['alt_text'] = $altText; + } + + $container = $this->createContainer($accountId, $token, $fields); + + if (!$container['success']) { + return ['success' => false, 'platform_post_id' => '', 'response' => $container['data'] ?? ['error' => $container['error']]]; + } + + return $this->publishContainer($accountId, $token, $container['id']); + } + + private function publishCarousel(string $accountId, string $token, string $caption, array $media, string $altText): array + { + $media = \array_slice($media, 0, 10); + $childIds = []; + + foreach ($media as $url) { + $fields = ['is_carousel_item' => 'true']; + + if ($this->isVideoUrl($url)) { + $fields['video_url'] = $url; + } else { + $fields['image_url'] = $url; + + if ($altText !== '') { + $fields['alt_text'] = $altText; + } + } + + $child = $this->createContainer($accountId, $token, $fields); + + if (!$child['success']) { + return ['success' => false, 'platform_post_id' => '', 'response' => $child['data'] ?? ['error' => $child['error']]]; + } + + $childIds[] = $child['id']; + } + + $carousel = $this->createContainer($accountId, $token, [ + 'media_type' => 'CAROUSEL', + 'caption' => $caption, + 'children' => implode(',', $childIds), + ]); + + if (!$carousel['success']) { + return ['success' => false, 'platform_post_id' => '', 'response' => $carousel['data'] ?? ['error' => $carousel['error']]]; + } + + return $this->publishContainer($accountId, $token, $carousel['id']); + } + + private function publishReels(string $accountId, string $token, string $caption, string $videoUrl): array + { + $container = $this->createContainer($accountId, $token, [ + 'media_type' => 'REELS', + 'video_url' => $videoUrl, + 'caption' => $caption, + ]); + + if (!$container['success']) { + return ['success' => false, 'platform_post_id' => '', 'response' => $container['data'] ?? ['error' => $container['error']]]; + } + + return $this->publishContainer($accountId, $token, $container['id']); + } + + private function publishStories(string $accountId, string $token, string $mediaUrl): array + { + $fields = ['media_type' => 'STORIES']; + + if ($this->isVideoUrl($mediaUrl)) { + $fields['video_url'] = $mediaUrl; + } else { + $fields['image_url'] = $mediaUrl; + } + + $container = $this->createContainer($accountId, $token, $fields); + + if (!$container['success']) { + return ['success' => false, 'platform_post_id' => '', 'response' => $container['data'] ?? ['error' => $container['error']]]; + } + + return $this->publishContainer($accountId, $token, $container['id']); + } + + private function createContainer(string $accountId, string $token, array $fields): array + { + $url = 'https://graph.facebook.com/v19.0/' . urlencode($accountId) . '/media'; + + $fields['access_token'] = $token; + + $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, - CURLOPT_POSTFIELDS => http_build_query($containerData), + CURLOPT_POSTFIELDS => http_build_query($fields), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, ]); @@ -75,36 +179,34 @@ class InstagramService extends CMSPlugin implements SubscriberInterface, MokoSui $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, '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]; + return ['success' => false, 'error' => $data['error']['message'] ?? 'Container creation failed', 'data' => $data]; } - $containerId = $data['id']; + return ['success' => true, 'id' => $data['id'], 'data' => $data]; + } - // Step 2: Publish the container - $publishUrl = 'https://graph.facebook.com/v19.0/' . urlencode($accountId) . '/media_publish'; - $publishData = [ - 'creation_id' => $containerId, - 'access_token' => $token, - ]; + private function publishContainer(string $accountId, string $token, string $containerId): array + { + $url = 'https://graph.facebook.com/v19.0/' . urlencode($accountId) . '/media_publish'; - $ch = curl_init($publishUrl); + $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, - CURLOPT_POSTFIELDS => http_build_query($publishData), + CURLOPT_POSTFIELDS => http_build_query([ + 'creation_id' => $containerId, + 'access_token' => $token, + ]), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, ]); @@ -112,14 +214,11 @@ class InstagramService extends CMSPlugin implements SubscriberInterface, MokoSui $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); @@ -132,6 +231,11 @@ class InstagramService extends CMSPlugin implements SubscriberInterface, MokoSui return ['success' => false, 'platform_post_id' => '', 'response' => $data]; } + private function isVideoUrl(string $url): bool + { + return (bool) preg_match('/\.(mp4|mov|avi|wmv|webm)(\?|$)/i', $url); + } + public function validateCredentials(array $credentials): array { $token = $this->resolveCredential($credentials, 'access_token'); @@ -150,13 +254,9 @@ class InstagramService extends CMSPlugin implements SubscriberInterface, MokoSui $response = curl_exec($ch); if ($response === false) { - $curlError = curl_error($ch); - curl_close($ch); - return ['valid' => false, 'message' => 'Connection error: ' . $curlError, 'account_name' => '']; - } curl_close($ch); @@ -183,6 +283,6 @@ class InstagramService extends CMSPlugin implements SubscriberInterface, MokoSui public function getSupportedMediaTypes(): array { - return ['image', 'video']; + return ['image', 'video', 'carousel', 'reels', 'stories']; } }