feat: add Instagram + YouTube plugins, re-apply deep scan fixes (#140, #141)
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 11s

New plugins:
- plg_mokosuitecross_instagram: Meta Content Publishing API (2-step flow)
- plg_mokosuitecross_youtube: YouTube Data API v3 channel bulletins

Bug fixes (re-applied after rebase loss):
- ConvertKit/Brevo/ConstantContact: duplicate curl_setopt_array removed
- Mailchimp: campaign creation accepts 2xx range (not just 200)
- Medium: getUserId() returns '' on error (not array)
- Bluesky: sha256 instead of md5 for cache key
- ServiceController: generic error message instead of exception details

Closes #140, closes #141
This commit is contained in:
Jonathan Miller
2026-06-23 07:38:41 -05:00
parent dc5feaa9aa
commit 1cb5c77bec
33 changed files with 543 additions and 31 deletions
+13
View File
@@ -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
@@ -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();
@@ -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];
@@ -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) {
@@ -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) {
@@ -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) {
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1,11 @@
<?php
/**
* @package MokoSuiteCross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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;
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Instagram</name>
<version>01.04.02-dev</version>
<creationDate>2026-06-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<description>PLG_MOKOSUITECROSS_INSTAGRAM_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\MokoSuiteCross\Instagram</namespace>
<files>
<filename plugin="instagram">instagram.php</filename>
<folder>src</folder>
<folder>services</folder>
<folder>language</folder>
</files>
<languages>
<language tag="en-GB">language/en-GB/plg_mokosuitecross_instagram.ini</language>
<language tag="en-GB">language/en-GB/plg_mokosuitecross_instagram.sys.ini</language>
</languages>
<config>
<fields name="params">
<fieldset name="basic" label="PLG_MOKOSUITECROSS_INSTAGRAM_FIELDSET_DEFAULTS">
<field
name="default_webhook_url"
type="url"
label="PLG_MOKOSUITECROSS_INSTAGRAM_DEFAULT_WEBHOOK"
description="PLG_MOKOSUITECROSS_INSTAGRAM_DEFAULT_WEBHOOK_DESC"
size="60"
/>
</fieldset>
</fields>
</config>
</extension>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -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."
@@ -0,0 +1,2 @@
PLG_MOKOSUITECROSS_INSTAGRAM="MokoSuiteCross - Instagram"
PLG_MOKOSUITECROSS_INSTAGRAM_DESCRIPTION="Cross-post Joomla articles to Instagram via Meta Content Publishing API."
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1,38 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage plg_mokosuitecross_instagram
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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;
}
);
}
};
@@ -0,0 +1,188 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage plg_mokosuitecross_instagram
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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'];
}
}
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -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];
}
@@ -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);
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1,2 @@
PLG_MOKOSUITECROSS_YOUTUBE="MokoSuiteCross - YouTube"
PLG_MOKOSUITECROSS_YOUTUBE_DESCRIPTION="Cross-post Joomla articles to YouTube community posts."
@@ -0,0 +1,2 @@
PLG_MOKOSUITECROSS_YOUTUBE="MokoSuiteCross - YouTube"
PLG_MOKOSUITECROSS_YOUTUBE_DESCRIPTION="Cross-post Joomla articles to YouTube community posts."
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1,38 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage plg_mokosuitecross_youtube
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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;
}
);
}
};
@@ -0,0 +1,137 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage plg_mokosuitecross_youtube
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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'];
}
}
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1,11 @@
<?php
/**
* @package MokoSuiteCross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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;
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Youtube</name>
<version>01.04.02-dev</version>
<creationDate>2026-06-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<description>PLG_MOKOSUITECROSS_YOUTUBE_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\MokoSuiteCross\Youtube</namespace>
<files>
<filename plugin="youtube">youtube.php</filename>
<folder>src</folder>
<folder>services</folder>
<folder>language</folder>
</files>
<languages>
<language tag="en-GB">language/en-GB/plg_mokosuitecross_youtube.ini</language>
<language tag="en-GB">language/en-GB/plg_mokosuitecross_youtube.sys.ini</language>
</languages>
<config>
<fields name="params">
<fieldset name="basic" label="PLG_MOKOSUITECROSS_YOUTUBE_FIELDSET_DEFAULTS">
<field
name="default_webhook_url"
type="url"
label="PLG_MOKOSUITECROSS_YOUTUBE_DEFAULT_WEBHOOK"
description="PLG_MOKOSUITECROSS_YOUTUBE_DEFAULT_WEBHOOK_DESC"
size="60"
/>
</fieldset>
</fields>
</config>
</extension>
+2
View File
@@ -60,6 +60,8 @@
<file type="plugin" id="tiktok" group="mokosuitecross">plg_mokosuitecross_tiktok.zip</file>
<file type="plugin" id="mokosuitecalendar" group="mokosuitecross">plg_mokosuitecross_mokosuitecalendar.zip</file>
<file type="plugin" id="mokosuitegallery" group="mokosuitecross">plg_mokosuitecross_mokosuitegallery.zip</file>
<file type="plugin" id="instagram" group="mokosuitecross">plg_mokosuitecross_instagram.zip</file>
<file type="plugin" id="youtube" group="mokosuitecross">plg_mokosuitecross_youtube.zip</file>
<!-- Content Source Plugins (system group) -->
<file type="plugin" id="mokosuitecross_events" group="system">plg_system_mokosuitecross_events.zip</file>