diff --git a/CHANGELOG.md b/CHANGELOG.md index 75e62211..e168f594 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,19 @@ # Changelog ## [Unreleased] +### Added +- **Instagram plugin**: Cross-post to Instagram via Meta Content Publishing API (2-step container flow) +- **YouTube plugin**: Cross-post to YouTube via Data API v3 channel bulletins + +### Fixed +- **ConvertKit**: Removed duplicate curl_setopt_array with undefined $token +- **Brevo**: Removed duplicate curl_setopt_array with undefined $token and wrong auth header +- **Constant Contact**: Removed duplicate curl_setopt_array +- **Mailchimp**: Fixed campaign creation checking HTTP 200 instead of 2xx range +- **Medium**: Fixed getUserId() returning array instead of string on error +- **Bluesky**: Replaced md5() with hash('sha256', ...) for cache key +- **ServiceController**: Exception details no longer exposed to client + ## [01.04.01] --- 2026-06-21 diff --git a/source/packages/com_mokosuitecross/src/Controller/ServiceController.php b/source/packages/com_mokosuitecross/src/Controller/ServiceController.php index 882c13a7..ecdd833c 100644 --- a/source/packages/com_mokosuitecross/src/Controller/ServiceController.php +++ b/source/packages/com_mokosuitecross/src/Controller/ServiceController.php @@ -96,7 +96,7 @@ class ServiceController extends FormController $app->mimeType = 'application/json'; $app->setHeader('Content-Type', 'application/json; charset=utf-8'); - echo new JsonResponse($e); + echo new JsonResponse(['error' => $e->getMessage()]); } $app->close(); diff --git a/source/packages/plg_mokosuitecross_bluesky/src/Extension/BlueskyService.php b/source/packages/plg_mokosuitecross_bluesky/src/Extension/BlueskyService.php index 7efb369a..01d1a298 100644 --- a/source/packages/plg_mokosuitecross_bluesky/src/Extension/BlueskyService.php +++ b/source/packages/plg_mokosuitecross_bluesky/src/Extension/BlueskyService.php @@ -127,7 +127,7 @@ class BlueskyService extends CMSPlugin implements SubscriberInterface, MokoSuite private function authenticateWithCache(string $pds, string $handle, string $appPwd): array { - $cacheKey = md5($pds . $handle); + $cacheKey = hash('sha256', $pds . $handle); if (isset(self::$sessionCache[$cacheKey])) { $cached = self::$sessionCache[$cacheKey]; diff --git a/source/packages/plg_mokosuitecross_brevo/src/Extension/BrevoService.php b/source/packages/plg_mokosuitecross_brevo/src/Extension/BrevoService.php index fbf55f30..42beb732 100644 --- a/source/packages/plg_mokosuitecross_brevo/src/Extension/BrevoService.php +++ b/source/packages/plg_mokosuitecross_brevo/src/Extension/BrevoService.php @@ -70,15 +70,6 @@ class BrevoService extends CMSPlugin implements SubscriberInterface, MokoSuiteCr CURLOPT_TIMEOUT => 30, ]); - curl_setopt_array($ch, [ - CURLOPT_URL => 'https://api.brevo.com/v3/emailCampaigns', - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => $postData, - CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: application/json'], - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 30, - ]); - $response = curl_exec($ch); if ($response === false) { diff --git a/source/packages/plg_mokosuitecross_constantcontact/src/Extension/ConstantcontactService.php b/source/packages/plg_mokosuitecross_constantcontact/src/Extension/ConstantcontactService.php index e3ae973b..eb38c64c 100644 --- a/source/packages/plg_mokosuitecross_constantcontact/src/Extension/ConstantcontactService.php +++ b/source/packages/plg_mokosuitecross_constantcontact/src/Extension/ConstantcontactService.php @@ -73,15 +73,6 @@ class ConstantcontactService extends CMSPlugin implements SubscriberInterface, M CURLOPT_TIMEOUT => 30, ]); - curl_setopt_array($ch, [ - CURLOPT_URL => 'https://api.cc.email/v3/emails', - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => $postData, - CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: application/json'], - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 30, - ]); - $response = curl_exec($ch); if ($response === false) { diff --git a/source/packages/plg_mokosuitecross_convertkit/src/Extension/ConvertkitService.php b/source/packages/plg_mokosuitecross_convertkit/src/Extension/ConvertkitService.php index 2a296f21..d2a48cfe 100644 --- a/source/packages/plg_mokosuitecross_convertkit/src/Extension/ConvertkitService.php +++ b/source/packages/plg_mokosuitecross_convertkit/src/Extension/ConvertkitService.php @@ -66,15 +66,6 @@ class ConvertkitService extends CMSPlugin implements SubscriberInterface, MokoSu CURLOPT_TIMEOUT => 30, ]); - curl_setopt_array($ch, [ - CURLOPT_URL => 'https://api.convertkit.com/v3/broadcasts', - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => $postData, - CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: application/json'], - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 30, - ]); - $response = curl_exec($ch); if ($response === false) { diff --git a/source/packages/plg_mokosuitecross_instagram/index.html b/source/packages/plg_mokosuitecross_instagram/index.html new file mode 100644 index 00000000..2efb97f3 --- /dev/null +++ b/source/packages/plg_mokosuitecross_instagram/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/plg_mokosuitecross_instagram/instagram.php b/source/packages/plg_mokosuitecross_instagram/instagram.php new file mode 100644 index 00000000..9b76408c --- /dev/null +++ b/source/packages/plg_mokosuitecross_instagram/instagram.php @@ -0,0 +1,11 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +defined('_JEXEC') or die; diff --git a/source/packages/plg_mokosuitecross_instagram/instagram.xml b/source/packages/plg_mokosuitecross_instagram/instagram.xml new file mode 100644 index 00000000..505c8409 --- /dev/null +++ b/source/packages/plg_mokosuitecross_instagram/instagram.xml @@ -0,0 +1,39 @@ + + + MokoSuiteCross - Instagram + 01.04.02-dev + 2026-06-23 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOSUITECROSS_INSTAGRAM_DESCRIPTION + + Joomla\Plugin\MokoSuiteCross\Instagram + + + instagram.php + src + services + language + + + + language/en-GB/plg_mokosuitecross_instagram.ini + language/en-GB/plg_mokosuitecross_instagram.sys.ini + + + +
+ +
+
+
+
diff --git a/source/packages/plg_mokosuitecross_instagram/language/en-GB/index.html b/source/packages/plg_mokosuitecross_instagram/language/en-GB/index.html new file mode 100644 index 00000000..2efb97f3 --- /dev/null +++ b/source/packages/plg_mokosuitecross_instagram/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/plg_mokosuitecross_instagram/language/en-GB/plg_mokosuitecross_instagram.ini b/source/packages/plg_mokosuitecross_instagram/language/en-GB/plg_mokosuitecross_instagram.ini new file mode 100644 index 00000000..30f71369 --- /dev/null +++ b/source/packages/plg_mokosuitecross_instagram/language/en-GB/plg_mokosuitecross_instagram.ini @@ -0,0 +1,5 @@ +PLG_MOKOSUITECROSS_INSTAGRAM="MokoSuiteCross - Instagram" +PLG_MOKOSUITECROSS_INSTAGRAM_DESCRIPTION="Cross-post Joomla articles to Instagram via Meta Content Publishing API." +PLG_MOKOSUITECROSS_INSTAGRAM_FIELDSET_DEFAULTS="Default Settings" +PLG_MOKOSUITECROSS_INSTAGRAM_DEFAULT_WEBHOOK="Default Webhook URL" +PLG_MOKOSUITECROSS_INSTAGRAM_DEFAULT_WEBHOOK_DESC="Pre-configured MokoSuite webhook URL. Services using default mode will use this URL." diff --git a/source/packages/plg_mokosuitecross_instagram/language/en-GB/plg_mokosuitecross_instagram.sys.ini b/source/packages/plg_mokosuitecross_instagram/language/en-GB/plg_mokosuitecross_instagram.sys.ini new file mode 100644 index 00000000..6f74ebb3 --- /dev/null +++ b/source/packages/plg_mokosuitecross_instagram/language/en-GB/plg_mokosuitecross_instagram.sys.ini @@ -0,0 +1,2 @@ +PLG_MOKOSUITECROSS_INSTAGRAM="MokoSuiteCross - Instagram" +PLG_MOKOSUITECROSS_INSTAGRAM_DESCRIPTION="Cross-post Joomla articles to Instagram via Meta Content Publishing API." diff --git a/source/packages/plg_mokosuitecross_instagram/language/index.html b/source/packages/plg_mokosuitecross_instagram/language/index.html new file mode 100644 index 00000000..2efb97f3 --- /dev/null +++ b/source/packages/plg_mokosuitecross_instagram/language/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/plg_mokosuitecross_instagram/services/index.html b/source/packages/plg_mokosuitecross_instagram/services/index.html new file mode 100644 index 00000000..2efb97f3 --- /dev/null +++ b/source/packages/plg_mokosuitecross_instagram/services/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/plg_mokosuitecross_instagram/services/provider.php b/source/packages/plg_mokosuitecross_instagram/services/provider.php new file mode 100644 index 00000000..985e45df --- /dev/null +++ b/source/packages/plg_mokosuitecross_instagram/services/provider.php @@ -0,0 +1,38 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Extension\PluginInterface; +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\DI\Container; +use Joomla\DI\ServiceProviderInterface; +use Joomla\Event\DispatcherInterface; +use Joomla\Plugin\MokoSuiteCross\Instagram\Extension\InstagramService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new InstagramService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokosuitecross', 'instagram') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/source/packages/plg_mokosuitecross_instagram/src/Extension/InstagramService.php b/source/packages/plg_mokosuitecross_instagram/src/Extension/InstagramService.php new file mode 100644 index 00000000..693d5da5 --- /dev/null +++ b/source/packages/plg_mokosuitecross_instagram/src/Extension/InstagramService.php @@ -0,0 +1,188 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +namespace Joomla\Plugin\MokoSuiteCross\Instagram\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * Instagram service plugin for MokoSuiteCross. + * + * 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 + */ +class InstagramService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoSuiteCrossGetServices' => 'onMokoSuiteCrossGetServices']; + } + + public function onMokoSuiteCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string { return 'instagram'; } + public function getServiceName(): string { return 'Instagram'; } + public function getMaxLength(): int { return 2200; } + public function supportsMedia(): bool { return true; } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + $token = $this->resolveCredential($credentials, 'access_token'); + $accountId = $credentials['instagram_account_id'] ?? ''; + + if (empty($token) || empty($accountId)) { + 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 { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Instagram requires at least one image or video.']]; + } + + $ch = curl_init($containerUrl); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query($containerData), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + + $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 || $httpCode >= 300 || empty($data['id'])) { + return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + } + + $containerId = $data['id']; + + // Step 2: Publish the container + $publishUrl = 'https://graph.facebook.com/v19.0/' . urlencode($accountId) . '/media_publish'; + $publishData = [ + 'creation_id' => $containerId, + 'access_token' => $token, + ]; + + $ch = curl_init($publishUrl); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query($publishData), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + + $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 && $httpCode < 300 && !empty($data['id'])) { + return ['success' => true, 'platform_post_id' => (string) $data['id'], 'response' => $data]; + } + + return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + } + + public function validateCredentials(array $credentials): array + { + $token = $this->resolveCredential($credentials, 'access_token'); + $accountId = $credentials['instagram_account_id'] ?? ''; + + if (empty($token) || empty($accountId)) { + return ['valid' => false, 'message' => 'Access token and Instagram account ID are required.', 'account_name' => '']; + } + + $ch = curl_init('https://graph.facebook.com/v19.0/me?fields=id,username&access_token=' . urlencode($token)); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + ]); + + $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); + + $data = json_decode($response, true) ?: []; + + if (!empty($data['id'])) { + $name = $data['username'] ?? $data['id']; + return ['valid' => true, 'message' => 'Connected', 'account_name' => '@' . $name]; + } + + return ['valid' => false, 'message' => $data['error']['message'] ?? 'Failed to verify credentials.', 'account_name' => '']; + } + + private function resolveCredential(array $credentials, string $key): string + { + $mode = $credentials['mode'] ?? 'default'; + + if ($mode === 'custom') { + return $credentials[$key] ?? ''; + } + + return $this->params->get('default_' . $key, ''); + } + + public function getSupportedMediaTypes(): array + { + return ['image', 'video']; + } +} diff --git a/source/packages/plg_mokosuitecross_instagram/src/Extension/index.html b/source/packages/plg_mokosuitecross_instagram/src/Extension/index.html new file mode 100644 index 00000000..2efb97f3 --- /dev/null +++ b/source/packages/plg_mokosuitecross_instagram/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/plg_mokosuitecross_instagram/src/index.html b/source/packages/plg_mokosuitecross_instagram/src/index.html new file mode 100644 index 00000000..2efb97f3 --- /dev/null +++ b/source/packages/plg_mokosuitecross_instagram/src/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/plg_mokosuitecross_mailchimp/src/Extension/MailchimpService.php b/source/packages/plg_mokosuitecross_mailchimp/src/Extension/MailchimpService.php index d56bffb6..8455740f 100644 --- a/source/packages/plg_mokosuitecross_mailchimp/src/Extension/MailchimpService.php +++ b/source/packages/plg_mokosuitecross_mailchimp/src/Extension/MailchimpService.php @@ -95,7 +95,7 @@ class MailchimpService extends CMSPlugin implements SubscriberInterface, MokoSui $data = json_decode($response, true) ?: []; - if ($httpCode !== 200 || empty($data['id'])) { + if ($httpCode < 200 || $httpCode >= 300 || empty($data['id'])) { return ['success' => false, 'platform_post_id' => '', 'response' => $data]; } diff --git a/source/packages/plg_mokosuitecross_medium/src/Extension/MediumService.php b/source/packages/plg_mokosuitecross_medium/src/Extension/MediumService.php index 37dfb772..44391f53 100644 --- a/source/packages/plg_mokosuitecross_medium/src/Extension/MediumService.php +++ b/source/packages/plg_mokosuitecross_medium/src/Extension/MediumService.php @@ -163,7 +163,7 @@ class MediumService extends CMSPlugin implements SubscriberInterface, MokoSuiteC curl_close($ch); - return ['valid' => false, 'message' => 'Connection error: ' . $curlError, 'account_name' => '']; + return ''; } curl_close($ch); diff --git a/source/packages/plg_mokosuitecross_youtube/index.html b/source/packages/plg_mokosuitecross_youtube/index.html new file mode 100644 index 00000000..2efb97f3 --- /dev/null +++ b/source/packages/plg_mokosuitecross_youtube/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/plg_mokosuitecross_youtube/language/en-GB/index.html b/source/packages/plg_mokosuitecross_youtube/language/en-GB/index.html new file mode 100644 index 00000000..2efb97f3 --- /dev/null +++ b/source/packages/plg_mokosuitecross_youtube/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/plg_mokosuitecross_youtube/language/en-GB/plg_mokosuitecross_youtube.ini b/source/packages/plg_mokosuitecross_youtube/language/en-GB/plg_mokosuitecross_youtube.ini new file mode 100644 index 00000000..339a9cc6 --- /dev/null +++ b/source/packages/plg_mokosuitecross_youtube/language/en-GB/plg_mokosuitecross_youtube.ini @@ -0,0 +1,2 @@ +PLG_MOKOSUITECROSS_YOUTUBE="MokoSuiteCross - YouTube" +PLG_MOKOSUITECROSS_YOUTUBE_DESCRIPTION="Cross-post Joomla articles to YouTube community posts." diff --git a/source/packages/plg_mokosuitecross_youtube/language/en-GB/plg_mokosuitecross_youtube.sys.ini b/source/packages/plg_mokosuitecross_youtube/language/en-GB/plg_mokosuitecross_youtube.sys.ini new file mode 100644 index 00000000..339a9cc6 --- /dev/null +++ b/source/packages/plg_mokosuitecross_youtube/language/en-GB/plg_mokosuitecross_youtube.sys.ini @@ -0,0 +1,2 @@ +PLG_MOKOSUITECROSS_YOUTUBE="MokoSuiteCross - YouTube" +PLG_MOKOSUITECROSS_YOUTUBE_DESCRIPTION="Cross-post Joomla articles to YouTube community posts." diff --git a/source/packages/plg_mokosuitecross_youtube/language/index.html b/source/packages/plg_mokosuitecross_youtube/language/index.html new file mode 100644 index 00000000..2efb97f3 --- /dev/null +++ b/source/packages/plg_mokosuitecross_youtube/language/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/plg_mokosuitecross_youtube/services/index.html b/source/packages/plg_mokosuitecross_youtube/services/index.html new file mode 100644 index 00000000..2efb97f3 --- /dev/null +++ b/source/packages/plg_mokosuitecross_youtube/services/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/plg_mokosuitecross_youtube/services/provider.php b/source/packages/plg_mokosuitecross_youtube/services/provider.php new file mode 100644 index 00000000..755296a5 --- /dev/null +++ b/source/packages/plg_mokosuitecross_youtube/services/provider.php @@ -0,0 +1,38 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Extension\PluginInterface; +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\DI\Container; +use Joomla\DI\ServiceProviderInterface; +use Joomla\Event\DispatcherInterface; +use Joomla\Plugin\MokoSuiteCross\Youtube\Extension\YoutubeService; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new YoutubeService( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('mokosuitecross', 'youtube') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/source/packages/plg_mokosuitecross_youtube/src/Extension/YoutubeService.php b/source/packages/plg_mokosuitecross_youtube/src/Extension/YoutubeService.php new file mode 100644 index 00000000..c06bf815 --- /dev/null +++ b/source/packages/plg_mokosuitecross_youtube/src/Extension/YoutubeService.php @@ -0,0 +1,137 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +namespace Joomla\Plugin\MokoSuiteCross\Youtube\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface; +use Joomla\Event\SubscriberInterface; + +/** + * YouTube service plugin for MokoSuiteCross. + * + * Posts to YouTube via the Data API v3 channel bulletins. + * + * Credentials: + * access_token - OAuth 2.0 token with youtube.force-ssl scope + * channel_id - YouTube channel ID + */ +class YoutubeService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface +{ + public static function getSubscribedEvents(): array + { + return ['onMokoSuiteCrossGetServices' => 'onMokoSuiteCrossGetServices']; + } + + public function onMokoSuiteCrossGetServices(&$services): void + { + $services[] = $this; + } + + public function getServiceType(): string { return 'youtube'; } + public function getServiceName(): string { return 'YouTube'; } + public function getMaxLength(): int { return 5000; } + public function supportsMedia(): bool { return true; } + + public function publish(string $message, array $media, array $credentials, array $params): array + { + $token = $credentials['access_token'] ?? ''; + $channelId = $credentials['channel_id'] ?? ''; + + if (empty($token) || empty($channelId)) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing access token or channel ID']]; + } + + $postData = json_encode([ + 'snippet' => [ + 'channelId' => $channelId, + 'description' => $message, + ], + 'contentDetails' => [ + 'bulletin' => [ + 'resourceId' => [ + 'kind' => 'youtube#channel', + 'channelId' => $channelId, + ], + ], + ], + ]); + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => 'https://www.googleapis.com/youtube/v3/activities?part=snippet,contentDetails', + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $postData, + CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: application/json'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + + $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 && $httpCode < 300) { + return ['success' => true, 'platform_post_id' => $data['id'] ?? '', 'response' => $data]; + } + + return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + } + + public function validateCredentials(array $credentials): array + { + $token = $credentials['access_token'] ?? ''; + + if (empty($token)) { + return ['valid' => false, 'message' => 'Missing access token', 'account_name' => '']; + } + + $ch = curl_init('https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true'); + curl_setopt_array($ch, [ + CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + ]); + + $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); + $data = json_decode($response, true) ?: []; + + if (!empty($data['items'][0]['snippet']['title'])) { + return ['valid' => true, 'message' => 'Connected', 'account_name' => $data['items'][0]['snippet']['title']]; + } + + return ['valid' => false, 'message' => 'Invalid token or no channel found', 'account_name' => '']; + } + + public function getSupportedMediaTypes(): array + { + return ['image', 'video']; + } +} diff --git a/source/packages/plg_mokosuitecross_youtube/src/Extension/index.html b/source/packages/plg_mokosuitecross_youtube/src/Extension/index.html new file mode 100644 index 00000000..2efb97f3 --- /dev/null +++ b/source/packages/plg_mokosuitecross_youtube/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/plg_mokosuitecross_youtube/src/index.html b/source/packages/plg_mokosuitecross_youtube/src/index.html new file mode 100644 index 00000000..2efb97f3 --- /dev/null +++ b/source/packages/plg_mokosuitecross_youtube/src/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/plg_mokosuitecross_youtube/youtube.php b/source/packages/plg_mokosuitecross_youtube/youtube.php new file mode 100644 index 00000000..9b76408c --- /dev/null +++ b/source/packages/plg_mokosuitecross_youtube/youtube.php @@ -0,0 +1,11 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +defined('_JEXEC') or die; diff --git a/source/packages/plg_mokosuitecross_youtube/youtube.xml b/source/packages/plg_mokosuitecross_youtube/youtube.xml new file mode 100644 index 00000000..217d838f --- /dev/null +++ b/source/packages/plg_mokosuitecross_youtube/youtube.xml @@ -0,0 +1,39 @@ + + + MokoSuiteCross - Youtube + 01.04.02-dev + 2026-06-23 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_MOKOSUITECROSS_YOUTUBE_DESCRIPTION + + Joomla\Plugin\MokoSuiteCross\Youtube + + + youtube.php + src + services + language + + + + language/en-GB/plg_mokosuitecross_youtube.ini + language/en-GB/plg_mokosuitecross_youtube.sys.ini + + + +
+ +
+
+
+
diff --git a/source/pkg_mokosuitecross.xml b/source/pkg_mokosuitecross.xml index d0383aeb..8762b3d4 100644 --- a/source/pkg_mokosuitecross.xml +++ b/source/pkg_mokosuitecross.xml @@ -60,6 +60,8 @@ plg_mokosuitecross_tiktok.zip plg_mokosuitecross_mokosuitecalendar.zip plg_mokosuitecross_mokosuitegallery.zip + plg_mokosuitecross_instagram.zip + plg_mokosuitecross_youtube.zip plg_system_mokosuitecross_events.zip