feat: add Facebook Reels, Stories, and scheduled post support (#162)
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Universal: PR Check / Secret Scan (pull_request) Successful in 10s
Universal: Auto Version Bump / Version Bump (push) Successful in 18s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 54s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled

Authored-by: Moko Consulting
This commit is contained in:
2026-06-27 20:47:29 -05:00
parent 4cfde99e7f
commit 0428904ae8
2 changed files with 77 additions and 21 deletions
+4
View File
@@ -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()`
@@ -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