From 0428904ae89b62cf7becfcb715921b378df41bfe Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 27 Jun 2026 20:47:29 -0500 Subject: [PATCH] feat: add Facebook Reels, Stories, and scheduled post support (#162) Authored-by: Moko Consulting --- CHANGELOG.md | 4 + .../src/Extension/FacebookService.php | 94 ++++++++++++++----- 2 files changed, 77 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4bf1b8d..ad2cc23e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ - **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 +- **Facebook Reels**: Publish video Reels via Graph API video_reels endpoint (#162) +- **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) ### Fixed - Webservices plugin Joomla 6 compatibility — `onBeforeApiRoute` receives `BeforeApiRouteEvent` object, extract router via `$event->getRouter()` diff --git a/source/packages/plg_mokosuitecross_facebook/src/Extension/FacebookService.php b/source/packages/plg_mokosuitecross_facebook/src/Extension/FacebookService.php index 7812f160..cdc1901e 100644 --- a/source/packages/plg_mokosuitecross_facebook/src/Extension/FacebookService.php +++ b/source/packages/plg_mokosuitecross_facebook/src/Extension/FacebookService.php @@ -18,22 +18,10 @@ use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossDeleteIn use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface; use Joomla\Event\SubscriberInterface; -/** - * Facebook/Meta service plugin for MokoSuiteCross. - * - * Supports two modes: - * 1. Default MokoSuite App — pre-configured app credentials (hidden from admin UI) - * 2. Custom App — user provides their own Facebook App ID and Page Access Token - * - * Credentials format: - * { - * "mode": "default" | "custom", - * "page_access_token": "...", // Only for custom mode - * "page_id": "..." // Required — Facebook Page ID - * } - */ class FacebookService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface, MokoSuiteCrossDeleteInterface { + private const VIDEO_EXTENSIONS = ['mp4', 'mov', 'avi', 'wmv', 'webm']; + public static function getSubscribedEvents(): array { return [ @@ -65,18 +53,84 @@ class FacebookService extends CMSPlugin implements SubscriberInterface, MokoSuit return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing token or page_id']]; } + $contentType = $params['content_type'] ?? 'feed'; + + return match ($contentType) { + 'reel' => $this->publishReel($message, $media, $token, $pageId, $params), + 'story' => $this->publishStory($media, $token, $pageId), + default => $this->publishFeed($message, $token, $pageId, $params), + }; + } + + private function publishFeed(string $message, string $token, string $pageId, array $params): array + { $apiUrl = 'https://graph.facebook.com/v19.0/' . $pageId . '/feed'; $postData = [ 'message' => $message, ]; - // Attach link if provided in params if (!empty($params['link'])) { $postData['link'] = $params['link']; } - $ch = curl_init($apiUrl); + if (!empty($params['scheduled_at'])) { + $timestamp = is_numeric($params['scheduled_at']) + ? (int) $params['scheduled_at'] + : strtotime($params['scheduled_at']); + + if ($timestamp !== false && $timestamp > 0) { + $postData['scheduled_publish_time'] = $timestamp; + $postData['published'] = 'false'; + } + } elseif (!empty($params['draft'])) { + $postData['published'] = 'false'; + } + + return $this->apiPost($apiUrl, $postData, $token); + } + + private function publishReel(string $message, array $media, string $token, string $pageId, array $params): array + { + if (empty($media[0])) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Reel requires a video URL']]; + } + + $apiUrl = 'https://graph.facebook.com/v19.0/' . $pageId . '/video_reels'; + + $postData = [ + 'upload_phase' => 'finish', + 'video_url' => $media[0], + 'description' => $message, + ]; + + return $this->apiPost($apiUrl, $postData, $token); + } + + private function publishStory(array $media, string $token, string $pageId): array + { + if (empty($media[0])) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Story requires a media URL']]; + } + + $mediaUrl = $media[0]; + $extension = strtolower(pathinfo(parse_url($mediaUrl, PHP_URL_PATH) ?: $mediaUrl, PATHINFO_EXTENSION)); + $isVideo = in_array($extension, self::VIDEO_EXTENSIONS, true); + + if ($isVideo) { + $apiUrl = 'https://graph.facebook.com/v19.0/' . $pageId . '/video_stories'; + $postData = ['video_url' => $mediaUrl]; + } else { + $apiUrl = 'https://graph.facebook.com/v19.0/' . $pageId . '/photo_stories'; + $postData = ['photo_url' => $mediaUrl]; + } + + return $this->apiPost($apiUrl, $postData, $token); + } + + private function apiPost(string $url, array $postData, string $token): array + { + $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query($postData), @@ -88,20 +142,18 @@ class FacebookService extends CMSPlugin implements SubscriberInterface, MokoSuit $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) ?: []; - if ($httpCode === 200 && !empty($data['id'])) { + if ($httpCode >= 200 && $httpCode < 300 && !empty($data['id'])) { return ['success' => true, 'platform_post_id' => $data['id'], 'response' => $data]; } @@ -143,7 +195,7 @@ class FacebookService extends CMSPlugin implements SubscriberInterface, MokoSuit public function getMaxLength(): int { - return 0; // No practical limit + return 0; } public function supportsMedia(): bool