{introtext}
\n', 1, 4, NOW()); +('mailchimp', 'Mailchimp Default', '{introtext}
\n', 1, 4, NOW()), +('telegram', 'Telegram Default', '{title}\n\n{introtext}\n\nRead more', 1, 5, NOW()), +('discord', 'Discord Default', '**{title}**\n\n{introtext}\n\n{url}', 1, 6, NOW()), +('slack', 'Slack Default', '*{title}*\n\n{introtext}\n\n{url}', 1, 7, NOW()), +('facebook', 'Facebook Default', '{title}\n\n{introtext}\n\n{url}', 1, 8, NOW()), +('linkedin', 'LinkedIn Default', '{title}\n\n{introtext}\n\n{url}', 1, 9, NOW()), +('bluesky', 'Bluesky Default', '{title}\n\n{url}', 1, 10, NOW()), +('threads', 'Threads Default', '{title}\n\n{introtext}\n\n{url}', 1, 11, NOW()), +('teams', 'Teams Default', '**{title}**\n\n{introtext}\n\n[Read more]({url})', 1, 12, NOW()), +('medium', 'Medium Default', '{title}\n\n{introtext}\n\n{url}', 1, 13, NOW()), +('wordpress', 'WordPress Default', '{title}\n\n{introtext}\n\n{url}', 1, 14, NOW()), +('webhook', 'Webhook Default', '{title}\n\n{introtext}\n\n{url}', 1, 15, NOW()), +('sendgrid', 'SendGrid Default', '{introtext}
\n', 1, 16, NOW()), +('brevo', 'Brevo Default', '{introtext}
\n', 1, 17, NOW()), +('ntfy', 'Ntfy Default', '{title}: {introtext}', 1, 18, NOW()), +('reddit', 'Reddit Default', '{title}', 1, 19, NOW()), +('pinterest', 'Pinterest Default', '{title} - {introtext}', 1, 20, NOW()); + +CREATE TABLE IF NOT EXISTS `#__mokosuitecross_category_rules` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `category_id` int(10) unsigned NOT NULL, + `service_id` int(10) unsigned NOT NULL, + `published` tinyint(1) NOT NULL DEFAULT 1, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_category_service` (`category_id`, `service_id`), + KEY `idx_category` (`category_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/source/packages/com_mokosuitecross/sql/uninstall.mysql.sql b/source/packages/com_mokosuitecross/sql/uninstall.mysql.sql new file mode 100644 index 0000000..94b03c5 --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/uninstall.mysql.sql @@ -0,0 +1,5 @@ +-- MokoSuiteCross — Uninstall +DROP TABLE IF EXISTS `#__mokosuitecross_logs`; +DROP TABLE IF EXISTS `#__mokosuitecross_posts`; +DROP TABLE IF EXISTS `#__mokosuitecross_templates`; +DROP TABLE IF EXISTS `#__mokosuitecross_services`; diff --git a/src/packages/com_mokojoomcross/sql/updates/index.html b/source/packages/com_mokosuitecross/sql/updates/index.html similarity index 100% rename from src/packages/com_mokojoomcross/sql/updates/index.html rename to source/packages/com_mokosuitecross/sql/updates/index.html diff --git a/src/packages/com_mokojoomcross/sql/updates/mysql/01.00.00.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.00.00.sql similarity index 50% rename from src/packages/com_mokojoomcross/sql/updates/mysql/01.00.00.sql rename to source/packages/com_mokosuitecross/sql/updates/mysql/01.00.00.sql index 8319501..d8d2fdf 100644 --- a/src/packages/com_mokojoomcross/sql/updates/mysql/01.00.00.sql +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.00.00.sql @@ -1,2 +1,2 @@ --- MokoJoomCross 01.00.00 — Initial release +-- MokoSuiteCross 01.00.00 — Initial release -- No update queries needed for initial version diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.01.00.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.01.00.sql new file mode 100644 index 0000000..189059a --- /dev/null +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.01.00.sql @@ -0,0 +1,14 @@ +-- MokoSuiteCross 01.01.00 — Category routing rules +-- Copyright (C) 2026 Moko Consulting. All rights reserved. +-- SPDX-License-Identifier: GPL-3.0-or-later +-- Note: also in install.mysql.sql for fresh installs; IF NOT EXISTS prevents conflicts + +CREATE TABLE IF NOT EXISTS `#__mokosuitecross_category_rules` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `category_id` int(10) unsigned NOT NULL, + `service_id` int(10) unsigned NOT NULL, + `published` tinyint(1) NOT NULL DEFAULT 1, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_category_service` (`category_id`, `service_id`), + KEY `idx_category` (`category_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/packages/com_mokojoomcross/sql/updates/mysql/index.html b/source/packages/com_mokosuitecross/sql/updates/mysql/index.html similarity index 100% rename from src/packages/com_mokojoomcross/sql/updates/mysql/index.html rename to source/packages/com_mokosuitecross/sql/updates/mysql/index.html diff --git a/source/packages/com_mokosuitecross/src/Controller/DashboardController.php b/source/packages/com_mokosuitecross/src/Controller/DashboardController.php new file mode 100644 index 0000000..a872de2 --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Controller/DashboardController.php @@ -0,0 +1,61 @@ + + * @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\Component\MokoSuiteCross\Administrator\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Controller\BaseController; +use Joomla\CMS\Router\Route; +use Joomla\Component\MokoSuiteCross\Administrator\Helper\MigrationHelper; + +class DashboardController extends BaseController +{ + /** + * Run Perfect Publisher Pro migration. + * + * @return void + */ + public function migrate(): void + { + $this->checkToken(); + + // Check ACL + if (!$this->app->getIdentity()->authorise('mokosuitecross.migrate', 'com_mokosuitecross')) { + $this->setRedirect( + Route::_('index.php?option=com_mokosuitecross&view=dashboard', false), + Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), + 'error' + ); + + return; + } + + $result = MigrationHelper::migrate(); + + if (!empty($result['errors'])) { + $this->setRedirect( + Route::_('index.php?option=com_mokosuitecross&view=dashboard', false), + Text::sprintf('COM_MOKOSUITECROSS_MIGRATION_ERROR', implode('; ', $result['errors'])), + 'error' + ); + + return; + } + + $this->setRedirect( + Route::_('index.php?option=com_mokosuitecross&view=dashboard', false), + Text::sprintf('COM_MOKOSUITECROSS_MIGRATION_SUCCESS', $result['migrated'], $result['skipped']), + 'success' + ); + } +} diff --git a/source/packages/com_mokosuitecross/src/Controller/DispatchController.php b/source/packages/com_mokosuitecross/src/Controller/DispatchController.php new file mode 100644 index 0000000..8f08367 --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Controller/DispatchController.php @@ -0,0 +1,262 @@ + + * @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\Component\MokoSuiteCross\Administrator\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Controller\BaseController; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\CMS\Uri\Uri; +use Joomla\Component\MokoSuiteCross\Administrator\Helper\CrossPostDispatcher; +use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface; + +/** + * REST API controller for dispatching cross-posts. + * + * Endpoint: POST /api/index.php/v1/mokosuitecross/dispatch + * + * JSON body: + * { + * "article_id": 123, + * "service_ids": [1, 2, 3] // optional — omit to post to all enabled services + * } + * + * Returns JSON with the created post IDs and status. + * + * Authentication is handled by Joomla's API application (token or session). + * The webservices plugin routes POST requests here via the API router. + */ +class DispatchController extends BaseController +{ + /** + * Dispatch cross-posts for an article to one or more services. + * + * @return void + */ + public function dispatch(): void + { + $app = $this->app; + + // Enforce POST method — this is a state-changing action endpoint + if (strtoupper($this->input->getMethod()) !== 'POST') { + $this->sendJsonResponse(['error' => 'Method not allowed. Use POST.'], 405); + + return; + } + + // ACL check — require core.manage on the component + if (!Factory::getApplication()->getIdentity()->authorise('core.manage', 'com_mokosuitecross')) { + $this->sendJsonResponse(['error' => 'Forbidden'], 403); + + return; + } + + // Read JSON body + $input = json_decode(file_get_contents('php://input'), true) ?: []; + $articleId = (int) ($input['article_id'] ?? 0); + $serviceIds = $input['service_ids'] ?? null; + + if ($articleId < 1) { + $this->sendJsonResponse(['error' => Text::_('COM_MOKOSUITECROSS_DISPATCH_MISSING_ARTICLE')], 400); + + return; + } + + // Validate service_ids if provided + if ($serviceIds !== null) { + if (!is_array($serviceIds) || empty($serviceIds)) { + $this->sendJsonResponse(['error' => Text::_('COM_MOKOSUITECROSS_DISPATCH_INVALID_SERVICES')], 400); + + return; + } + + $serviceIds = array_map('intval', $serviceIds); + } + + $db = Factory::getDbo(); + + // Load the article + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__content')) + ->where($db->quoteName('id') . ' = ' . $articleId); + + $db->setQuery($query); + $article = $db->loadObject(); + + if (!$article) { + $this->sendJsonResponse(['error' => Text::_('COM_MOKOSUITECROSS_DISPATCH_ARTICLE_NOT_FOUND')], 404); + + return; + } + + // Load enabled services, optionally filtered by service_ids + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitecross_services')) + ->where($db->quoteName('published') . ' = 1') + ->order($db->quoteName('ordering') . ' ASC'); + + if ($serviceIds !== null) { + $query->where($db->quoteName('id') . ' IN (' . implode(',', $serviceIds) . ')'); + } + + $db->setQuery($query); + $services = $db->loadObjectList() ?: []; + + if (empty($services)) { + $this->sendJsonResponse(['error' => Text::_('COM_MOKOSUITECROSS_DISPATCH_NO_SERVICES')], 404); + + return; + } + + // Import service plugins and build type-to-plugin map. + // In Joomla 5+ with SubscriberInterface, plugins receive the Event object + // as their first argument. When they do $services[] = $this, they append to + // the Event via ArrayAccess at numeric indices starting at 1. + PluginHelper::importPlugin('mokosuitecross'); + + $servicePlugins = []; + $event = new \Joomla\Event\Event('onMokoSuiteCrossGetServices', [$servicePlugins]); + + try { + $app->getDispatcher()->dispatch('onMokoSuiteCrossGetServices', $event); + } catch (\Throwable $e) { + // Dispatcher may not be available + } + + // Read plugins back from the Event's ArrayAccess indices + $idx = 1; + + while (isset($event[$idx])) { + $servicePlugins[] = $event[$idx]; + $idx++; + } + + $pluginMap = []; + + foreach ($servicePlugins as $plugin) { + if ($plugin instanceof MokoSuiteCrossServiceInterface) { + $pluginMap[$plugin->getServiceType()] = $plugin; + } + } + + // Create queue entries + $now = Factory::getDate()->toSql(); + $createdIds = []; + $skipped = []; + + // Build article URL + $articleUrl = Uri::root() . 'index.php?option=com_content&view=article&id=' . $article->id; + + if (!empty($article->catid)) { + $articleUrl .= '&catid=' . $article->catid; + } + + // Extract intro image for media + $media = []; + $images = json_decode($article->images ?? '{}'); + + if (!empty($images->image_intro)) { + $media[] = Uri::root() . ltrim($images->image_intro, '/'); + } + + foreach ($services as $service) { + // Duplicate guard — skip if article already posted/queued for this service + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokosuitecross_posts')) + ->where($db->quoteName('article_id') . ' = ' . (int) $article->id) + ->where($db->quoteName('service_id') . ' = ' . (int) $service->id) + ->where($db->quoteName('status') . ' IN (' . $db->quote('queued') . ',' . $db->quote('posted') . ',' . $db->quote('posting') . ')'); + + $db->setQuery($query); + + if ((int) $db->loadResult() > 0) { + $skipped[] = [ + 'service_id' => (int) $service->id, + 'service_type' => $service->service_type, + 'reason' => 'duplicate', + ]; + + continue; + } + + // Render template via shared dispatcher logic + $message = CrossPostDispatcher::renderTemplate($article, $service); + + // Create queue entry + $post = (object) [ + 'article_id' => (int) $article->id, + 'service_id' => (int) $service->id, + 'status' => 'queued', + 'message' => $message, + 'platform_post_id' => '', + 'platform_response' => '', + 'error_message' => '', + 'retry_count' => 0, + 'created' => $now, + 'modified' => $now, + ]; + + $db->insertObject('#__mokosuitecross_posts', $post); + $postId = (int) $db->insertid(); + + $createdIds[] = [ + 'post_id' => $postId, + 'service_id' => (int) $service->id, + 'service_type' => $service->service_type, + 'status' => 'queued', + ]; + + // Write log entry + $log = (object) [ + 'post_id' => $postId, + 'service_id' => (int) $service->id, + 'level' => 'info', + 'message' => sprintf('API dispatch: queued article %d to %s', $article->id, $service->service_type), + 'context' => '{}', + 'created' => $now, + ]; + + $db->insertObject('#__mokosuitecross_logs', $log); + } + + $this->sendJsonResponse([ + 'article_id' => (int) $article->id, + 'dispatched' => $createdIds, + 'skipped' => $skipped, + ], 200); + } + + /** + * Send a JSON response and close the application. + * + * @param array $data Response data + * @param int $httpCode HTTP status code + * + * @return void + */ + private function sendJsonResponse(array $data, int $httpCode): void + { + $app = $this->app; + + $app->setHeader('Content-Type', 'application/json; charset=utf-8'); + $app->setHeader('Status', (string) $httpCode); + + echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + $app->close(); + } +} diff --git a/src/packages/com_mokojoomcross/src/Controller/DisplayController.php b/source/packages/com_mokosuitecross/src/Controller/DisplayController.php similarity index 79% rename from src/packages/com_mokojoomcross/src/Controller/DisplayController.php rename to source/packages/com_mokosuitecross/src/Controller/DisplayController.php index 8eaf818..507992b 100644 --- a/src/packages/com_mokojoomcross/src/Controller/DisplayController.php +++ b/source/packages/com_mokosuitecross/src/Controller/DisplayController.php @@ -1,15 +1,15 @@ * @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\Component\MokoJoomCross\Administrator\Controller; +namespace Joomla\Component\MokoSuiteCross\Administrator\Controller; defined('_JEXEC') or die; diff --git a/source/packages/com_mokosuitecross/src/Controller/OauthController.php b/source/packages/com_mokosuitecross/src/Controller/OauthController.php new file mode 100644 index 0000000..fce3cfb --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Controller/OauthController.php @@ -0,0 +1,198 @@ + + * @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\Component\MokoSuiteCross\Administrator\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Controller\BaseController; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\CMS\Router\Route; +use Joomla\Component\MokoSuiteCross\Administrator\Helper\OAuthHelper; + +/** + * OAuth controller for handling browser-based authorization flows. + * + * Endpoints: + * task=oauth.authorize — Initiate OAuth flow (redirect to platform) + * task=oauth.callback — Handle platform redirect with auth code + */ +class OauthController extends BaseController +{ + /** + * Initiate OAuth authorization for a service. + * + * Expects: service_id (int) in request + */ + public function authorize(): void + { + $this->checkToken(); + + $serviceId = $this->input->getInt('service_id', 0); + + if (!$serviceId) { + $this->setRedirect( + Route::_('index.php?option=com_mokosuitecross&view=services', false), + Text::_('COM_MOKOSUITECROSS_OAUTH_NO_SERVICE'), + 'error' + ); + + return; + } + + $db = \Joomla\CMS\Factory::getDbo(); + + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitecross_services')) + ->where($db->quoteName('id') . ' = ' . $serviceId); + + $db->setQuery($query); + $service = $db->loadObject(); + + if (!$service) { + $this->setRedirect( + Route::_('index.php?option=com_mokosuitecross&view=services', false), + Text::_('COM_MOKOSUITECROSS_OAUTH_SERVICE_NOT_FOUND'), + 'error' + ); + + return; + } + + // Get client ID from plugin params + PluginHelper::importPlugin('mokosuitecross'); + $pluginParams = PluginHelper::getPlugin('mokosuitecross', $service->service_type); + $params = json_decode($pluginParams->params ?? '{}', true) ?: []; + + $clientId = $params['client_id'] ?? ''; + + if (empty($clientId)) { + $this->setRedirect( + Route::_('index.php?option=com_mokosuitecross&view=services', false), + Text::sprintf('COM_MOKOSUITECROSS_OAUTH_NO_CLIENT_ID', ucfirst($service->service_type)), + 'error' + ); + + return; + } + + // Generate CSRF nonce and store in session + $nonce = bin2hex(random_bytes(16)); + Factory::getApplication()->getSession()->set('mokosuitecross.oauth_nonce', $nonce); + + $url = OAuthHelper::getAuthorizeUrl($service->service_type, $serviceId, $clientId, $nonce); + + if (!$url) { + $this->setRedirect( + Route::_('index.php?option=com_mokosuitecross&view=services', false), + Text::sprintf('COM_MOKOSUITECROSS_OAUTH_NOT_SUPPORTED', ucfirst($service->service_type)), + 'error' + ); + + return; + } + + $this->app->redirect($url); + } + + /** + * Handle OAuth callback from platform. + * + * Expects: code (string), state (base64 JSON with service_id) + */ + public function callback(): void + { + $code = $this->input->getString('code', ''); + $state = $this->input->getString('state', ''); + $error = $this->input->getString('error', ''); + + if ($error) { + $this->setRedirect( + Route::_('index.php?option=com_mokosuitecross&view=services', false), + Text::sprintf('COM_MOKOSUITECROSS_OAUTH_PLATFORM_ERROR', $error), + 'error' + ); + + return; + } + + if (empty($code) || empty($state)) { + $this->setRedirect( + Route::_('index.php?option=com_mokosuitecross&view=services', false), + Text::_('COM_MOKOSUITECROSS_OAUTH_INVALID_CALLBACK'), + 'error' + ); + + return; + } + + $stateData = json_decode(base64_decode($state), true); + $serviceId = (int) ($stateData['service_id'] ?? 0); + $serviceType = $stateData['type'] ?? ''; + $stateNonce = $stateData['nonce'] ?? ''; + + if (!$serviceId || !$serviceType) { + $this->setRedirect( + Route::_('index.php?option=com_mokosuitecross&view=services', false), + Text::_('COM_MOKOSUITECROSS_OAUTH_INVALID_STATE'), + 'error' + ); + + return; + } + + // CSRF nonce validation — compare state nonce against session + $session = Factory::getApplication()->getSession(); + $sessionNonce = $session->get('mokosuitecross.oauth_nonce', ''); + $session->clear('mokosuitecross.oauth_nonce'); + + if (empty($stateNonce) || !hash_equals($sessionNonce, $stateNonce)) { + $this->setRedirect( + Route::_('index.php?option=com_mokosuitecross&view=services', false), + Text::_('COM_MOKOSUITECROSS_OAUTH_INVALID_STATE'), + 'error' + ); + + return; + } + + // Get client credentials from plugin params + PluginHelper::importPlugin('mokosuitecross'); + $pluginParams = PluginHelper::getPlugin('mokosuitecross', $serviceType); + $params = json_decode($pluginParams->params ?? '{}', true) ?: []; + + $clientId = $params['client_id'] ?? ''; + $clientSecret = $params['client_secret'] ?? ''; + + $tokenData = OAuthHelper::exchangeCode($serviceType, $code, $clientId, $clientSecret); + + if (!empty($tokenData['error'])) { + $this->setRedirect( + Route::_('index.php?option=com_mokosuitecross&task=service.edit&id=' . $serviceId, false), + Text::sprintf('COM_MOKOSUITECROSS_OAUTH_TOKEN_ERROR', $tokenData['error']), + 'error' + ); + + return; + } + + OAuthHelper::storeToken($serviceId, $tokenData); + + $this->setRedirect( + Route::_('index.php?option=com_mokosuitecross&task=service.edit&id=' . $serviceId, false), + Text::sprintf('COM_MOKOSUITECROSS_OAUTH_SUCCESS', ucfirst($serviceType)), + 'success' + ); + } +} diff --git a/src/packages/com_mokojoomcross/src/Controller/PostController.php b/source/packages/com_mokosuitecross/src/Controller/PostController.php similarity index 74% rename from src/packages/com_mokojoomcross/src/Controller/PostController.php rename to source/packages/com_mokosuitecross/src/Controller/PostController.php index 9ea2f46..bdfea31 100644 --- a/src/packages/com_mokojoomcross/src/Controller/PostController.php +++ b/source/packages/com_mokosuitecross/src/Controller/PostController.php @@ -1,15 +1,15 @@ * @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\Component\MokoJoomCross\Administrator\Controller; +namespace Joomla\Component\MokoSuiteCross\Administrator\Controller; defined('_JEXEC') or die; diff --git a/source/packages/com_mokosuitecross/src/Controller/PostsController.php b/source/packages/com_mokosuitecross/src/Controller/PostsController.php new file mode 100644 index 0000000..9371c9d --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Controller/PostsController.php @@ -0,0 +1,258 @@ + + * @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\Component\MokoSuiteCross\Administrator\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Controller\AdminController; +use Joomla\CMS\Router\Route; + +class PostsController extends AdminController +{ + public function getModel($name = 'Post', $prefix = 'Administrator', $config = ['ignore_request' => true]) + { + return parent::getModel($name, $prefix, $config); + } + + /** + * Schedule selected posts for a future date/time. + * + * @return void + */ + public function schedule(): void + { + $this->checkToken(); + + $ids = $this->input->get('cid', [], 'array'); + $scheduledAt = $this->input->getString('scheduled_at', ''); + + if (empty($ids)) { + $this->setRedirect( + Route::_('index.php?option=com_mokosuitecross&view=posts', false), + Text::_('COM_MOKOSUITECROSS_POSTS_NO_ITEM_SELECTED'), + 'warning' + ); + return; + } + + if (empty($scheduledAt)) { + $this->setRedirect( + Route::_('index.php?option=com_mokosuitecross&view=posts', false), + Text::_('COM_MOKOSUITECROSS_SCHEDULE_NO_DATE'), + 'warning' + ); + return; + } + + try { + $scheduledDate = Factory::getDate($scheduledAt); + $scheduledAt = $scheduledDate->toSql(); + } catch (\Throwable $e) { + $this->setRedirect( + Route::_('index.php?option=com_mokosuitecross&view=posts', false), + Text::_('COM_MOKOSUITECROSS_SCHEDULE_INVALID_DATE'), + 'error' + ); + return; + } + + $db = Factory::getDbo(); + $now = Factory::getDate()->toSql(); + + foreach ($ids as $id) { + $query = $db->getQuery(true) + ->update($db->quoteName('#__mokosuitecross_posts')) + ->set($db->quoteName('scheduled_at') . ' = ' . $db->quote($scheduledAt)) + ->set($db->quoteName('status') . ' = ' . $db->quote('queued')) + ->set($db->quoteName('modified') . ' = ' . $db->quote($now)) + ->where($db->quoteName('id') . ' = ' . (int) $id) + ->where($db->quoteName('status') . ' IN (' + . $db->quote('queued') . ',' . $db->quote('failed') . ',' + . $db->quote('permanently_failed') . ',' . $db->quote('cancelled') . ')'); + + $db->setQuery($query); + $db->execute(); + } + + $this->setRedirect( + Route::_('index.php?option=com_mokosuitecross&view=posts', false), + Text::sprintf('COM_MOKOSUITECROSS_POSTS_N_SCHEDULED', count($ids)), + 'success' + ); + } + + /** + * Retry selected failed/permanently_failed posts. + * + * @return void + */ + public function retrySelected(): void + { + $this->checkToken(); + + $ids = $this->input->get('cid', [], 'array'); + + if (empty($ids)) { + $this->setRedirect( + Route::_('index.php?option=com_mokosuitecross&view=posts', false), + Text::_('COM_MOKOSUITECROSS_POSTS_NO_ITEM_SELECTED'), + 'warning' + ); + return; + } + + $count = \Joomla\Component\MokoSuiteCross\Administrator\Helper\QueueProcessor::retryPosts($ids); + + $this->setRedirect( + Route::_('index.php?option=com_mokosuitecross&view=posts', false), + Text::sprintf('COM_MOKOSUITECROSS_POSTS_N_RETRIED', $count), + 'success' + ); + } + + /** + * Re-queue all failed posts by resetting their status to queued and retry count to 0. + * + * @return void + */ + public function retryFailed(): void + { + $this->checkToken(); + + $db = Factory::getDbo(); + + $query = $db->getQuery(true) + ->update($db->quoteName('#__mokosuitecross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('queued')) + ->set($db->quoteName('retry_count') . ' = 0') + ->set($db->quoteName('error_message') . ' = ' . $db->quote('')) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('status') . ' IN (' . $db->quote('failed') . ',' . $db->quote('permanently_failed') . ',' . $db->quote('cancelled') . ')'); + + $db->setQuery($query); + $db->execute(); + + $count = $db->getAffectedRows(); + + $this->setRedirect( + Route::_('index.php?option=com_mokosuitecross&view=posts', false), + Text::plural('COM_MOKOSUITECROSS_POSTS_N_RETRIED', $count), + 'success' + ); + } + + /** + * Export posts as CSV download. + * + * @return void + */ + public function exportCsv(): void + { + $this->checkToken('get'); + + if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitecross')) { + throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403); + } + + $app = $this->app; + $db = Factory::getDbo(); + + $query = $db->getQuery(true) + ->select([ + $db->quoteName('c.title', 'article_title'), + 'CONCAT(' . $db->quoteName('s.title') . ', ' . $db->quote(' (') . ', ' + . $db->quoteName('s.service_type') . ', ' . $db->quote(')') . ') AS service', + $db->quoteName('a.status'), + $db->quoteName('a.message'), + $db->quoteName('a.posted_at'), + $db->quoteName('a.error_message'), + $db->quoteName('a.platform_post_id'), + $db->quoteName('a.created'), + ]) + ->from($db->quoteName('#__mokosuitecross_posts', 'a')) + ->join('LEFT', $db->quoteName('#__content', 'c') + . ' ON ' . $db->quoteName('c.id') . ' = ' . $db->quoteName('a.article_id')) + ->join('LEFT', $db->quoteName('#__mokosuitecross_services', 's') + . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('a.service_id')) + ->order($db->quoteName('a.created') . ' DESC'); + + // Apply current filters + $status = $app->input->get('filter_status', '', 'string'); + + if (!empty($status)) { + $query->where($db->quoteName('a.status') . ' = ' . $db->quote($status)); + } + + $serviceId = $app->input->getInt('filter_service_id', 0); + + if (!empty($serviceId)) { + $query->where($db->quoteName('a.service_id') . ' = ' . (int) $serviceId); + } + + $search = $app->input->get('filter_search', '', 'string'); + + if (!empty($search)) { + $search = '%' . $db->escape(trim($search), true) . '%'; + $query->where('(' . $db->quoteName('c.title') . ' LIKE ' . $db->quote($search) + . ' OR ' . $db->quoteName('a.message') . ' LIKE ' . $db->quote($search) . ')'); + } + + $db->setQuery($query); + $rows = $db->loadAssocList() ?: []; + + $filename = 'mokosuitecross-posts-' . Factory::getDate()->format('Y-m-d') . '.csv'; + + $app->setHeader('Content-Type', 'text/csv; charset=utf-8'); + $app->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"'); + $app->sendHeaders(); + + $fp = fopen('php://output', 'w'); + fputcsv($fp, ['Article', 'Service', 'Status', 'Message', 'Posted At', 'Error', 'Platform Post ID', 'Created']); + + foreach ($rows as $row) { + fputcsv($fp, $row); + } + + fclose($fp); + + $app->close(); + } + + /** + * Purge (delete) all posts with status 'posted'. + * + * @return void + */ + public function purgePosted(): void + { + $this->checkToken(); + + $db = Factory::getDbo(); + + $query = $db->getQuery(true) + ->delete($db->quoteName('#__mokosuitecross_posts')) + ->where($db->quoteName('status') . ' = ' . $db->quote('posted')); + + $db->setQuery($query); + $db->execute(); + + $count = $db->getAffectedRows(); + + $this->setRedirect( + Route::_('index.php?option=com_mokosuitecross&view=posts', false), + Text::plural('COM_MOKOSUITECROSS_POSTS_N_PURGED', $count), + 'success' + ); + } +} diff --git a/source/packages/com_mokosuitecross/src/Controller/ServiceController.php b/source/packages/com_mokosuitecross/src/Controller/ServiceController.php new file mode 100644 index 0000000..882c13a --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Controller/ServiceController.php @@ -0,0 +1,104 @@ + + * @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\Component\MokoSuiteCross\Administrator\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Controller\FormController; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\CMS\Response\JsonResponse; +use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface; + +class ServiceController extends FormController +{ + /** + * Test connection to a service by validating its credentials. + * + * @return void + */ + public function testConnection(): void + { + $this->checkToken(); + + if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitecross')) { + throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403); + } + + $app = $this->app; + $id = (int) $this->input->getInt('id', 0); + + try { + if ($id <= 0) { + throw new \RuntimeException(Text::_('COM_MOKOSUITECROSS_TEST_CONNECTION_NO_SERVICE')); + } + + // Load the service record + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitecross_services')) + ->where($db->quoteName('id') . ' = ' . $id); + $db->setQuery($query); + $service = $db->loadObject(); + + if (!$service) { + throw new \RuntimeException(Text::_('COM_MOKOSUITECROSS_TEST_CONNECTION_NOT_FOUND')); + } + + // Get service plugins via dispatcher (Joomla 5+ Event ArrayAccess pattern) + PluginHelper::importPlugin('mokosuitecross'); + + $servicePlugins = []; + $event = new \Joomla\Event\Event('onMokoSuiteCrossGetServices', [$servicePlugins]); + $app->getDispatcher()->dispatch('onMokoSuiteCrossGetServices', $event); + + $idx = 1; + + while (isset($event[$idx])) { + $servicePlugins[] = $event[$idx]; + $idx++; + } + + // Find the matching plugin + $plugin = null; + + foreach ($servicePlugins as $sp) { + if ($sp instanceof MokoSuiteCrossServiceInterface && $sp->getServiceType() === $service->service_type) { + $plugin = $sp; + break; + } + } + + if (!$plugin) { + throw new \RuntimeException(Text::sprintf('COM_MOKOSUITECROSS_TEST_CONNECTION_NO_PLUGIN', $service->service_type)); + } + + // Decode credentials and validate + $credentials = \Joomla\Component\MokoSuiteCross\Administrator\Helper\CredentialHelper::decrypt($service->credentials ?: ''); + $result = $plugin->validateCredentials($credentials); + + $app->mimeType = 'application/json'; + $app->setHeader('Content-Type', 'application/json; charset=utf-8'); + + echo new JsonResponse($result); + } catch (\Throwable $e) { + $app->mimeType = 'application/json'; + $app->setHeader('Content-Type', 'application/json; charset=utf-8'); + + echo new JsonResponse($e); + } + + $app->close(); + } +} diff --git a/src/packages/com_mokojoomcross/src/Controller/ServicesController.php b/source/packages/com_mokosuitecross/src/Controller/ServicesController.php similarity index 81% rename from src/packages/com_mokojoomcross/src/Controller/ServicesController.php rename to source/packages/com_mokosuitecross/src/Controller/ServicesController.php index 65b708d..80d75eb 100644 --- a/src/packages/com_mokojoomcross/src/Controller/ServicesController.php +++ b/source/packages/com_mokosuitecross/src/Controller/ServicesController.php @@ -1,15 +1,15 @@ * @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\Component\MokoJoomCross\Administrator\Controller; +namespace Joomla\Component\MokoSuiteCross\Administrator\Controller; defined('_JEXEC') or die; diff --git a/src/packages/com_mokojoomcross/src/Controller/ServiceController.php b/source/packages/com_mokosuitecross/src/Controller/TemplateController.php similarity index 65% rename from src/packages/com_mokojoomcross/src/Controller/ServiceController.php rename to source/packages/com_mokosuitecross/src/Controller/TemplateController.php index e9a3258..df31894 100644 --- a/src/packages/com_mokojoomcross/src/Controller/ServiceController.php +++ b/source/packages/com_mokosuitecross/src/Controller/TemplateController.php @@ -1,20 +1,20 @@ * @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\Component\MokoJoomCross\Administrator\Controller; +namespace Joomla\Component\MokoSuiteCross\Administrator\Controller; defined('_JEXEC') or die; use Joomla\CMS\MVC\Controller\FormController; -class ServiceController extends FormController +class TemplateController extends FormController { } diff --git a/src/packages/com_mokojoomcross/src/Controller/PostsController.php b/source/packages/com_mokosuitecross/src/Controller/TemplatesController.php similarity index 58% rename from src/packages/com_mokojoomcross/src/Controller/PostsController.php rename to source/packages/com_mokosuitecross/src/Controller/TemplatesController.php index 79adc59..591a5a6 100644 --- a/src/packages/com_mokojoomcross/src/Controller/PostsController.php +++ b/source/packages/com_mokosuitecross/src/Controller/TemplatesController.php @@ -1,23 +1,23 @@ * @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\Component\MokoJoomCross\Administrator\Controller; +namespace Joomla\Component\MokoSuiteCross\Administrator\Controller; defined('_JEXEC') or die; use Joomla\CMS\MVC\Controller\AdminController; -class PostsController extends AdminController +class TemplatesController extends AdminController { - public function getModel($name = 'Post', $prefix = 'Administrator', $config = ['ignore_request' => true]) + public function getModel($name = 'Template', $prefix = 'Administrator', $config = ['ignore_request' => true]) { return parent::getModel($name, $prefix, $config); } diff --git a/src/packages/com_mokojoomcross/src/Controller/index.html b/source/packages/com_mokosuitecross/src/Controller/index.html similarity index 100% rename from src/packages/com_mokojoomcross/src/Controller/index.html rename to source/packages/com_mokosuitecross/src/Controller/index.html diff --git a/src/packages/com_mokojoomcross/src/Extension/MokoJoomCrossComponent.php b/source/packages/com_mokosuitecross/src/Extension/MokoSuiteCrossComponent.php similarity index 64% rename from src/packages/com_mokojoomcross/src/Extension/MokoJoomCrossComponent.php rename to source/packages/com_mokosuitecross/src/Extension/MokoSuiteCrossComponent.php index ca58a96..88b9112 100644 --- a/src/packages/com_mokojoomcross/src/Extension/MokoJoomCrossComponent.php +++ b/source/packages/com_mokosuitecross/src/Extension/MokoSuiteCrossComponent.php @@ -1,20 +1,20 @@ * @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\Component\MokoJoomCross\Administrator\Extension; +namespace Joomla\Component\MokoSuiteCross\Administrator\Extension; defined('_JEXEC') or die; use Joomla\CMS\Extension\MVCComponent; -class MokoJoomCrossComponent extends MVCComponent +class MokoSuiteCrossComponent extends MVCComponent { } diff --git a/src/packages/com_mokojoomcross/src/Extension/index.html b/source/packages/com_mokosuitecross/src/Extension/index.html similarity index 100% rename from src/packages/com_mokojoomcross/src/Extension/index.html rename to source/packages/com_mokosuitecross/src/Extension/index.html diff --git a/source/packages/com_mokosuitecross/src/Helper/CredentialHelper.php b/source/packages/com_mokosuitecross/src/Helper/CredentialHelper.php new file mode 100644 index 0000000..4926aa5 --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Helper/CredentialHelper.php @@ -0,0 +1,110 @@ + + * @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\Component\MokoSuiteCross\Administrator\Helper; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; + +/** + * Encrypts and decrypts service credentials using libsodium. + * + * Uses Joomla's $secret from configuration.php as the key source. + * Falls back to plaintext JSON if sodium is unavailable or decryption + * fails (backward compat with existing unencrypted credentials). + */ +class CredentialHelper +{ + private const PREFIX = 'enc:sodium:'; + + /** + * Encrypt a credentials array to a storable string. + * + * @param array $credentials Credentials to encrypt + * + * @return string Encrypted string prefixed with "enc:sodium:", or plain JSON as fallback + */ + public static function encrypt(array $credentials): string + { + $json = json_encode($credentials); + + if (!function_exists('sodium_crypto_secretbox')) { + return $json; + } + + try { + $key = self::deriveKey(); + $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $cipher = sodium_crypto_secretbox($json, $nonce, $key); + + return self::PREFIX . base64_encode($nonce . $cipher); + } catch (\Throwable $e) { + return $json; + } + } + + /** + * Decrypt a credentials string back to an array. + * + * Handles both encrypted (prefixed) and legacy plaintext JSON. + * + * @param string $stored Stored credential string + * + * @return array Decoded credentials + */ + public static function decrypt(string $stored): array + { + if (empty($stored)) { + return []; + } + + // Legacy plaintext JSON — no prefix + if (!str_starts_with($stored, self::PREFIX)) { + return json_decode($stored, true) ?: []; + } + + if (!function_exists('sodium_crypto_secretbox_open')) { + return []; + } + + try { + $key = self::deriveKey(); + $payload = base64_decode(substr($stored, strlen(self::PREFIX))); + + if ($payload === false || strlen($payload) < SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) { + return []; + } + + $nonce = substr($payload, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $cipher = substr($payload, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $plain = sodium_crypto_secretbox_open($cipher, $nonce, $key); + + if ($plain === false) { + return []; + } + + return json_decode($plain, true) ?: []; + } catch (\Throwable $e) { + return []; + } + } + + /** + * Derive a 32-byte encryption key from Joomla's secret. + */ + private static function deriveKey(): string + { + $secret = Factory::getApplication()->get('secret', ''); + + return sodium_crypto_generichash($secret, '', SODIUM_CRYPTO_SECRETBOX_KEYBYTES); + } +} diff --git a/source/packages/com_mokosuitecross/src/Helper/CrossPostDispatcher.php b/source/packages/com_mokosuitecross/src/Helper/CrossPostDispatcher.php new file mode 100644 index 0000000..e6d4d09 --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Helper/CrossPostDispatcher.php @@ -0,0 +1,494 @@ + + * @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\Component\MokoSuiteCross\Administrator\Helper; + +defined('_JEXEC') or die; + +use Joomla\CMS\Component\ComponentHelper; +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\CMS\Uri\Uri; +use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface; + +/** + * Static dispatcher for cross-posting content from any source plugin. + * + * Centralises the dispatch logic that was previously only in the system plugin, + * so content-type source plugins (articles, calendar events, gallery items) can + * trigger cross-posts without coupling to plg_system_mokosuitecross. + */ +class CrossPostDispatcher +{ + /** + * Dispatch an article-like payload to all enabled cross-post services. + * + * @param object $article Article or article-like object + * @param string $articleUrl Canonical URL for the content item + * @param string|null $contentType Content type context (e.g. 'com_content.article') + */ + public static function dispatch(object $article, string $articleUrl = '', ?string $contentType = null): void + { + $db = Factory::getDbo(); + + // Load all enabled services + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitecross_services')) + ->where($db->quoteName('published') . ' = 1') + ->order($db->quoteName('ordering') . ' ASC'); + + $db->setQuery($query); + $services = $db->loadObjectList(); + + if (empty($services)) { + return; + } + + // Import service plugins so they register with the dispatcher + PluginHelper::importPlugin('mokosuitecross'); + + // Collect registered service plugin instances. + // In Joomla 5+ with SubscriberInterface, plugins receive the Event object + // as their first argument. When they do $services[] = $this, they append to + // the Event via ArrayAccess at numeric indices starting at 1. + $servicePlugins = []; + $event = new \Joomla\Event\Event('onMokoSuiteCrossGetServices', [$servicePlugins]); + + try { + Factory::getApplication()->getDispatcher()->dispatch('onMokoSuiteCrossGetServices', $event); + } catch (\Throwable $e) { + // Dispatcher may not be available in all contexts + } + + // Read plugins back from the Event's ArrayAccess indices + $idx = 1; + + while (isset($event[$idx])) { + $servicePlugins[] = $event[$idx]; + $idx++; + } + + // Index by service type for lookup + $pluginMap = []; + + foreach ($servicePlugins as $plugin) { + if ($plugin instanceof MokoSuiteCrossServiceInterface) { + $pluginMap[$plugin->getServiceType()] = $plugin; + } + } + + $componentParams = ComponentHelper::getParams('com_mokosuitecross'); + + // Per-article selective cross-posting (#19) + $attribs = json_decode($article->attribs ?? '{}', true) ?: []; + $selectedServiceIds = $attribs['mokosuitecross_services'] ?? null; + $skipCrossPost = !empty($attribs['mokosuitecross_skip']); + + if ($skipCrossPost) { + return; + } + + // If specific services selected, convert to array of ints for filtering + if (is_array($selectedServiceIds) && !empty($selectedServiceIds)) { + $selectedServiceIds = array_map('intval', $selectedServiceIds); + } else { + $selectedServiceIds = null; // null = post to all + } + + // Category routing rules — whitelist services by category + $categoryServiceIds = null; + + if (!empty($article->catid)) { + $query = $db->getQuery(true) + ->select('service_id') + ->from($db->quoteName('#__mokosuitecross_category_rules')) + ->where($db->quoteName('category_id') . ' = ' . (int) $article->catid) + ->where($db->quoteName('published') . ' = 1'); + $db->setQuery($query); + $ruleIds = $db->loadColumn(); + + if (!empty($ruleIds)) { + $categoryServiceIds = array_map('intval', $ruleIds); + } + } + + // Determine service type filter from content type property + $serviceTypeFilter = $article->_content_type ?? null; + + // Batch duplicate guard — single query for all services (fixes N query overhead) + $serviceIdList = implode(',', array_map(function ($s) { return (int) $s->id; }, $services)); + $query = $db->getQuery(true) + ->select($db->quoteName('service_id')) + ->from($db->quoteName('#__mokosuitecross_posts')) + ->where($db->quoteName('article_id') . ' = ' . (int) $article->id) + ->where($db->quoteName('service_id') . ' IN (' . $serviceIdList . ')') + ->where($db->quoteName('status') . ' IN (' . $db->quote('queued') . ',' . $db->quote('posted') . ',' . $db->quote('posting') . ')'); + $db->setQuery($query); + $existingServiceIds = array_map('intval', $db->loadColumn() ?: []); + + // Batch template loading — single query for all needed service types + default + $serviceTypes = array_unique(array_column($services, 'service_type')); + $typeQuotes = array_map([$db, 'quote'], $serviceTypes); + $typeQuotes[] = $db->quote('default'); + $query = $db->getQuery(true) + ->select([$db->quoteName('service_type'), $db->quoteName('template_body')]) + ->from($db->quoteName('#__mokosuitecross_templates')) + ->where($db->quoteName('published') . ' = 1') + ->where($db->quoteName('service_type') . ' IN (' . implode(',', $typeQuotes) . ')') + ->order($db->quoteName('service_type') . ' ASC'); + $db->setQuery($query); + $templateRows = $db->loadObjectList() ?: []; + + $templateMap = []; + + foreach ($templateRows as $row) { + $templateMap[$row->service_type] = $row->template_body; + } + + // Pre-build article metadata once (category, author, tags) — avoids N queries per service + $articleMeta = self::buildArticleMeta($article); + + foreach ($services as $service) { + // Category routing filter — if rules exist, only post to whitelisted services + if ($categoryServiceIds !== null && !in_array((int) $service->id, $categoryServiceIds, true)) { + continue; + } + // Service type filter for non-article content types + if ($serviceTypeFilter !== null && $service->service_type !== $serviceTypeFilter) { + continue; + } + + // Per-article filter + if ($selectedServiceIds !== null && !in_array((int) $service->id, $selectedServiceIds, true)) { + continue; + } + + // Batch duplicate guard check + if (in_array((int) $service->id, $existingServiceIds, true)) { + continue; + } + + $message = self::renderTemplate($article, $service, $templateMap, $articleMeta); + + // Extract intro image for media attachment + $media = []; + $images = json_decode($article->images ?? '{}'); + + if (!empty($images->image_intro)) { + $media[] = Uri::root() . ltrim($images->image_intro, '/'); + } + + // Create queue entry + $post = (object) [ + 'article_id' => (int) $article->id, + 'service_id' => (int) $service->id, + 'status' => 'queued', + 'message' => $message, + 'platform_post_id' => '', + 'platform_response' => '', + 'error_message' => '', + 'retry_count' => 0, + 'created' => Factory::getDate()->toSql(), + 'modified' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokosuitecross_posts', $post); + $postId = $db->insertid(); + + // Resolve article URL + $url = $article->_article_url ?? $articleUrl; + + if (empty($url)) { + $url = Uri::root() . 'index.php?option=com_content&view=article&id=' . $article->id + . (!empty($article->catid) ? '&catid=' . $article->catid : ''); + } + + // Attempt immediate dispatch if service plugin is available + $plugin = $pluginMap[$service->service_type] ?? null; + + if ($plugin) { + self::executePost($db, $postId, $plugin, $message, $service, $media, $url); + } else { + self::log($db, $postId, $service->id, 'warning', + sprintf('No service plugin found for type "%s" — post remains queued', $service->service_type)); + } + } + } + + /** + * Execute a cross-post via the service plugin. + */ + private static function executePost($db, int $postId, MokoSuiteCrossServiceInterface $plugin, string $message, object $service, array $media = [], string $articleUrl = ''): void + { + // Mark as posting + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokosuitecross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('posting')) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . $postId) + ); + $db->execute(); + + $credentials = CredentialHelper::decrypt($service->credentials ?: ''); + $params = json_decode($service->params ?: '{}', true) ?: []; + + if (!empty($articleUrl)) { + $params['_article_url'] = $articleUrl; + } + + // Lifecycle event: before post + $cancel = false; + $dispatcher = Factory::getApplication()->getDispatcher(); + + try { + $beforeEvent = new \Joomla\Event\Event('onMokoSuiteCrossBeforePost', [$postId, &$message, $service->service_type, &$cancel]); + $dispatcher->dispatch('onMokoSuiteCrossBeforePost', $beforeEvent); + } catch (\Throwable $e) { + // Dispatcher may not be available + } + + if ($cancel) { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokosuitecross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('cancelled')) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . $postId) + ); + $db->execute(); + + self::log($db, $postId, $service->id, 'info', + sprintf('Post to %s cancelled by onMokoSuiteCrossBeforePost event', $service->service_type)); + + return; + } + + try { + $result = $plugin->publish($message, $media, $credentials, $params); + + if (!empty($result['success'])) { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokosuitecross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('posted')) + ->set($db->quoteName('platform_post_id') . ' = ' . $db->quote($result['platform_post_id'] ?? '')) + ->set($db->quoteName('platform_response') . ' = ' . $db->quote(json_encode($result['response'] ?? []))) + ->set($db->quoteName('posted_at') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . $postId) + ); + $db->execute(); + + self::log($db, $postId, $service->id, 'info', + sprintf('Posted to %s (platform ID: %s)', $service->service_type, $result['platform_post_id'] ?? 'n/a')); + + try { + $afterEvent = new \Joomla\Event\Event('onMokoSuiteCrossAfterPost', [$postId, $service->service_type, $result]); + $dispatcher->dispatch('onMokoSuiteCrossAfterPost', $afterEvent); + } catch (\Throwable $e) { + // Non-critical + } + } else { + $errorMsg = $result['response']['error'] ?? json_encode($result['response'] ?? []); + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokosuitecross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('failed')) + ->set($db->quoteName('error_message') . ' = ' . $db->quote(mb_substr($errorMsg, 0, 1000))) + ->set($db->quoteName('platform_response') . ' = ' . $db->quote(json_encode($result['response'] ?? []))) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . $postId) + ); + $db->execute(); + + self::log($db, $postId, $service->id, 'error', + sprintf('Failed to post to %s: %s', $service->service_type, $errorMsg)); + + try { + $failedEvent = new \Joomla\Event\Event('onMokoSuiteCrossPostFailed', [$postId, $service->service_type, $errorMsg]); + $dispatcher->dispatch('onMokoSuiteCrossPostFailed', $failedEvent); + } catch (\Throwable $e) { + // Non-critical + } + } + } catch (\Throwable $e) { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokosuitecross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('failed')) + ->set($db->quoteName('error_message') . ' = ' . $db->quote(mb_substr($e->getMessage(), 0, 1000))) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . $postId) + ); + $db->execute(); + + self::log($db, $postId, $service->id, 'error', + sprintf('Exception posting to %s: %s', $service->service_type, $e->getMessage())); + + try { + $failedEvent = new \Joomla\Event\Event('onMokoSuiteCrossPostFailed', [$postId, $service->service_type, $e->getMessage()]); + $dispatcher->dispatch('onMokoSuiteCrossPostFailed', $failedEvent); + } catch (\Throwable $ex) { + // Non-critical + } + } + } + + /** + * Build article metadata (category, author, tags, image) for template rendering. + * Call once per article, then pass to renderTemplate() for each service. + * + * @param object $article Article object + * + * @return array Pre-resolved metadata for template placeholders + */ + public static function buildArticleMeta(object $article): array + { + $db = Factory::getDbo(); + + $url = $article->_article_url + ?? (Uri::root() . 'index.php?option=com_content&view=article&id=' . $article->id + . (!empty($article->catid) ? '&catid=' . $article->catid : '')); + + $categoryName = ''; + + if (!empty($article->catid)) { + $query = $db->getQuery(true) + ->select($db->quoteName('title')) + ->from($db->quoteName('#__categories')) + ->where($db->quoteName('id') . ' = ' . (int) $article->catid); + $db->setQuery($query); + $categoryName = $db->loadResult() ?: ''; + } + + $authorName = ''; + + if (!empty($article->created_by)) { + $query = $db->getQuery(true) + ->select($db->quoteName('name')) + ->from($db->quoteName('#__users')) + ->where($db->quoteName('id') . ' = ' . (int) $article->created_by); + $db->setQuery($query); + $authorName = $db->loadResult() ?: ''; + } + + $introImage = ''; + $images = json_decode($article->images ?? '{}'); + + if (!empty($images->image_intro)) { + $introImage = Uri::root() . ltrim($images->image_intro, '/'); + } + + $tagNames = []; + + if (!empty($article->id)) { + $query = $db->getQuery(true) + ->select($db->quoteName('t.title')) + ->from($db->quoteName('#__tags', 't')) + ->join('INNER', $db->quoteName('#__contentitem_tag_map', 'm') + . ' ON ' . $db->quoteName('m.tag_id') . ' = ' . $db->quoteName('t.id')) + ->where($db->quoteName('m.type_alias') . ' = ' . $db->quote('com_content.article')) + ->where($db->quoteName('m.content_item_id') . ' = ' . (int) $article->id) + ->where($db->quoteName('t.published') . ' = 1'); + $db->setQuery($query); + $tagNames = $db->loadColumn() ?: []; + } + + $tagsComma = implode(', ', $tagNames); + $hashtags = implode(' ', array_map(function ($tag) { + return '#' . preg_replace('/\s+/', '', $tag); + }, $tagNames)); + + return [ + '{title}' => $article->title ?? '', + '{introtext}' => strip_tags(mb_substr($article->introtext ?? '', 0, 280)), + '{fulltext}' => strip_tags(mb_substr($article->fulltext ?? '', 0, 500)), + '{url}' => $url, + '{image}' => $introImage, + '{category}' => $categoryName, + '{author}' => $authorName, + '{date}' => Factory::getDate($article->publish_up ?? 'now')->format('Y-m-d'), + '{tags}' => $tagsComma, + '{hashtags}' => $hashtags, + ]; + } + + /** + * Render the message template for a service. + * + * @param object $article Article object + * @param object $service Service object + * @param array $templateMap Pre-loaded template map (service_type => body) + * @param array $articleMeta Pre-built article metadata from buildArticleMeta() + */ + public static function renderTemplate(object $article, object $service, array $templateMap = [], array $articleMeta = []): string + { + $db = Factory::getDbo(); + + // Use pre-loaded template map if available, otherwise query + if (!empty($templateMap)) { + $template = $templateMap[$service->service_type] ?? $templateMap['default'] ?? "{title}\n\n{url}"; + } else { + $query = $db->getQuery(true) + ->select($db->quoteName('template_body')) + ->from($db->quoteName('#__mokosuitecross_templates')) + ->where($db->quoteName('published') . ' = 1') + ->where('(' . $db->quoteName('service_type') . ' = ' . $db->quote($service->service_type) + . ' OR ' . $db->quoteName('service_type') . ' = ' . $db->quote('default') . ')') + ->order('CASE WHEN ' . $db->quoteName('service_type') . ' = ' + . $db->quote($service->service_type) . ' THEN 0 ELSE 1 END') + ->setLimit(1); + + $db->setQuery($query); + $template = $db->loadResult() ?: "{title}\n\n{url}"; + } + + // Use pre-built metadata if available, otherwise build on the fly + $replacements = !empty($articleMeta) ? $articleMeta : self::buildArticleMeta($article); + + $message = str_replace(array_keys($replacements), array_values($replacements), $template); + + // Resolve custom field placeholders: {field:field_name} + $message = preg_replace_callback('/\{field:([a-zA-Z0-9_-]+)\}/', function ($matches) use ($db, $article) { + $fieldName = $matches[1]; + $query = $db->getQuery(true) + ->select('fv.value') + ->from($db->quoteName('#__fields_values', 'fv')) + ->join('INNER', $db->quoteName('#__fields', 'f') . ' ON f.id = fv.field_id') + ->where('f.name = ' . $db->quote($fieldName)) + ->where('fv.item_id = ' . (int) $article->id); + $db->setQuery($query); + return $db->loadResult() ?: ''; + }, $message); + + return $message; + } + + /** + * Write an entry to the activity log. + */ + private static function log($db, ?int $postId, ?int $serviceId, string $level, string $message): void + { + $log = (object) [ + 'post_id' => $postId, + 'service_id' => $serviceId, + 'level' => $level, + 'message' => mb_substr($message, 0, 2000), + 'context' => '{}', + 'created' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokosuitecross_logs', $log); + } +} diff --git a/source/packages/com_mokosuitecross/src/Helper/MigrationHelper.php b/source/packages/com_mokosuitecross/src/Helper/MigrationHelper.php new file mode 100644 index 0000000..0b8128a --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Helper/MigrationHelper.php @@ -0,0 +1,388 @@ + + * @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\Component\MokoSuiteCross\Administrator\Helper; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; + +/** + * Migration helper for importing settings from Perfect Publisher Pro (com_autotweet). + * + * PP Pro stores channels in #__autotweet_channels with a channeltype_id FK + * to #__autotweet_channeltypes. Each channel has a JSON params column + * containing OAuth tokens, API keys, webhook URLs, etc. + * + * This helper reads those channels and creates MokoSuiteCross service records. + */ +class MigrationHelper +{ + /** + * Channel type name → MokoSuiteCross service type mapping. + * PP Pro channeltype names vary; we match common patterns. + */ + private const CHANNEL_MAP = [ + 'facebook' => 'facebook', + 'fb' => 'facebook', + 'twitter' => 'twitter', + 'tw' => 'twitter', + 'linkedin' => 'linkedin', + 'li' => 'linkedin', + 'telegram' => 'telegram', + 'tg' => 'telegram', + 'discord' => 'discord', + 'slack' => 'slack', + 'mastodon' => 'mastodon', + ]; + + /** + * Run the full migration from Perfect Publisher Pro. + * + * Strategy: + * 1. Try reading #__autotweet_channels (PP Pro's channel table) + * 2. Fall back to reading component params if table doesn't exist + * 3. Create disabled MokoSuiteCross service records + * + * @return array ['migrated' => int, 'skipped' => int, 'errors' => string[]] + */ + public static function migrate(): array + { + $db = Factory::getDbo(); + $result = ['migrated' => 0, 'skipped' => 0, 'errors' => []]; + + // Check if PP Pro is installed + if (!self::isPPProInstalled($db)) { + $result['errors'][] = 'Perfect Publisher Pro (com_autotweet) is not installed.'; + + return $result; + } + + // Try channel-based migration first (PP Pro stores configs in #__autotweet_channels) + if (self::hasChannelTable($db)) { + $result = self::migrateFromChannels($db, $result); + } else { + // Fall back to component params extraction + $result = self::migrateFromParams($db, $result); + } + + // Clear migration flag from MokoSuiteCross params + self::clearMigrationFlag($db); + + return $result; + } + + /** + * Check if PP Pro is installed. + */ + private static function isPPProInstalled($db): bool + { + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__extensions')) + ->where('(' . $db->quoteName('element') . ' = ' . $db->quote('com_autotweet') + . ' OR ' . $db->quoteName('element') . ' LIKE ' . $db->quote('%perfectpublisher%') . ')') + ->where($db->quoteName('type') . ' = ' . $db->quote('component')); + + $db->setQuery($query); + + return (int) $db->loadResult() > 0; + } + + /** + * Check if the autotweet_channels table exists. + */ + private static function hasChannelTable($db): bool + { + $prefix = $db->getPrefix(); + + try { + $db->setQuery('SHOW TABLES LIKE ' . $db->quote($prefix . 'autotweet_channels')); + + return !empty($db->loadResult()); + } catch (\Throwable $e) { + return false; + } + } + + /** + * Migrate from #__autotweet_channels table (primary method). + */ + private static function migrateFromChannels($db, array $result): array + { + // Load channels with their type names + $query = $db->getQuery(true) + ->select('c.id, c.name, c.published, c.params') + ->select($db->quoteName('ct.name', 'type_name')) + ->from($db->quoteName('#__autotweet_channels', 'c')) + ->join('LEFT', $db->quoteName('#__autotweet_channeltypes', 'ct') + . ' ON ' . $db->quoteName('ct.id') . ' = ' . $db->quoteName('c.channeltype_id')); + + $db->setQuery($query); + $channels = $db->loadObjectList(); + + if (empty($channels)) { + $result['errors'][] = 'No channels found in Perfect Publisher Pro.'; + + return $result; + } + + foreach ($channels as $channel) { + $typeName = strtolower(trim($channel->type_name ?? '')); + + // Match to MokoSuiteCross service type + $mjcType = null; + + foreach (self::CHANNEL_MAP as $pattern => $serviceType) { + if (str_contains($typeName, $pattern)) { + $mjcType = $serviceType; + break; + } + } + + if (!$mjcType) { + $result['skipped']++; + continue; + } + + // Check for duplicate (same type + migrated alias) + $alias = $mjcType . '-pp-' . $channel->id; + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokosuitecross_services')) + ->where($db->quoteName('alias') . ' = ' . $db->quote($alias)); + $db->setQuery($query); + + if ((int) $db->loadResult() > 0) { + $result['skipped']++; + continue; + } + + // Parse channel params to extract credentials + $channelParams = json_decode($channel->params ?: '{}', true) ?: []; + $credentials = self::mapChannelCredentials($mjcType, $channelParams); + + if (empty($credentials)) { + $result['skipped']++; + continue; + } + + // Create MokoSuiteCross service record + $service = (object) [ + 'title' => $channel->name ?: ucfirst($mjcType) . ' (PP Pro #' . $channel->id . ')', + 'alias' => $alias, + 'service_type' => $mjcType, + 'credentials' => json_encode($credentials), + 'params' => '{}', + 'published' => 0, // Disabled — user must verify before enabling + 'ordering' => 0, + 'created' => Factory::getDate()->toSql(), + 'modified' => Factory::getDate()->toSql(), + 'created_by' => Factory::getApplication()->getIdentity()->id ?? 0, + ]; + + try { + $db->insertObject('#__mokosuitecross_services', $service); + $result['migrated']++; + } catch (\Throwable $e) { + $result['errors'][] = sprintf('Failed to create %s service: %s', $mjcType, $e->getMessage()); + } + } + + return $result; + } + + /** + * Map PP Pro channel params to MokoSuiteCross credential format. + * + * PP Pro stores various keys in channel params depending on the type. + * We normalize them to MokoSuiteCross's expected credential structure. + */ + private static function mapChannelCredentials(string $serviceType, array $channelParams): array + { + $creds = ['mode' => 'custom']; + + // Common OAuth fields PP Pro uses + $oauthFields = ['access_token', 'access_secret', 'client_id', 'client_secret', + 'api_key', 'api_secret', 'app_id', 'app_secret', 'token']; + + switch ($serviceType) { + case 'facebook': + $creds['page_access_token'] = $channelParams['access_token'] ?? $channelParams['token'] ?? ''; + $creds['page_id'] = $channelParams['page_id'] ?? $channelParams['pageid'] ?? ''; + break; + + case 'twitter': + $creds['bearer_token'] = $channelParams['bearer_token'] ?? ''; + $creds['api_key'] = $channelParams['api_key'] ?? $channelParams['consumer_key'] ?? ''; + $creds['api_secret'] = $channelParams['api_secret'] ?? $channelParams['consumer_secret'] ?? ''; + $creds['access_token'] = $channelParams['access_token'] ?? ''; + $creds['access_token_secret'] = $channelParams['access_secret'] ?? $channelParams['access_token_secret'] ?? ''; + break; + + case 'linkedin': + $creds['access_token'] = $channelParams['access_token'] ?? $channelParams['token'] ?? ''; + $creds['organization_id'] = $channelParams['company_id'] ?? $channelParams['organization_id'] ?? ''; + $creds['person_id'] = $channelParams['person_id'] ?? $channelParams['member_id'] ?? ''; + break; + + case 'telegram': + $creds['bot_token'] = $channelParams['bot_token'] ?? $channelParams['token'] ?? $channelParams['api_key'] ?? ''; + $creds['chat_id'] = $channelParams['chat_id'] ?? $channelParams['channel_id'] ?? ''; + break; + + case 'discord': + $creds['webhook_url'] = $channelParams['webhook_url'] ?? $channelParams['webhook'] ?? ''; + break; + + case 'slack': + $creds['webhook_url'] = $channelParams['webhook_url'] ?? $channelParams['webhook'] ?? ''; + break; + + case 'mastodon': + $creds['instance_url'] = $channelParams['instance_url'] ?? $channelParams['server'] ?? ''; + $creds['access_token'] = $channelParams['access_token'] ?? $channelParams['token'] ?? ''; + break; + + default: + // Generic: copy all non-empty params + foreach ($channelParams as $key => $value) { + if (!empty($value) && is_string($value)) { + $creds[$key] = $value; + } + } + } + + // Remove empty credential values and the mode key for check + $check = array_filter($creds, fn($v, $k) => $k !== 'mode' && !empty($v), ARRAY_FILTER_USE_BOTH); + + return empty($check) ? [] : $creds; + } + + /** + * Fallback: migrate from component params when channel table doesn't exist. + */ + private static function migrateFromParams($db, array $result): array + { + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where('(' . $db->quoteName('element') . ' = ' . $db->quote('com_autotweet') + . ' OR ' . $db->quoteName('element') . ' LIKE ' . $db->quote('%perfectpublisher%') . ')') + ->where($db->quoteName('type') . ' = ' . $db->quote('component')); + + $db->setQuery($query); + $rawParams = $db->loadResult(); + + if (!$rawParams) { + $result['errors'][] = 'No PP Pro configuration found.'; + + return $result; + } + + $params = json_decode($rawParams, true); + + if (!is_array($params)) { + $result['errors'][] = 'Could not parse PP Pro configuration.'; + + return $result; + } + + // Extract services from component params using prefix patterns + $servicePatterns = [ + 'facebook' => ['facebook_', 'fb_'], + 'twitter' => ['twitter_', 'tw_'], + 'linkedin' => ['linkedin_', 'li_'], + 'telegram' => ['telegram_', 'tg_'], + ]; + + foreach ($servicePatterns as $mjcType => $prefixes) { + $credentials = ['mode' => 'custom']; + $found = false; + + foreach ($params as $key => $value) { + foreach ($prefixes as $prefix) { + if (str_starts_with($key, $prefix) && !empty($value)) { + $cleanKey = substr($key, strlen($prefix)); + $credentials[$cleanKey] = $value; + $found = true; + } + } + } + + if (!$found) { + $result['skipped']++; + continue; + } + + // Duplicate check + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokosuitecross_services')) + ->where($db->quoteName('service_type') . ' = ' . $db->quote($mjcType)) + ->where($db->quoteName('alias') . ' LIKE ' . $db->quote('%-migrated%')); + $db->setQuery($query); + + if ((int) $db->loadResult() > 0) { + $result['skipped']++; + continue; + } + + $service = (object) [ + 'title' => ucfirst($mjcType) . ' (migrated from PP Pro)', + 'alias' => $mjcType . '-migrated', + 'service_type' => $mjcType, + 'credentials' => json_encode($credentials), + 'params' => '{}', + 'published' => 0, + 'ordering' => 0, + 'created' => Factory::getDate()->toSql(), + 'modified' => Factory::getDate()->toSql(), + 'created_by' => Factory::getApplication()->getIdentity()->id ?? 0, + ]; + + try { + $db->insertObject('#__mokosuitecross_services', $service); + $result['migrated']++; + } catch (\Throwable $e) { + $result['errors'][] = sprintf('Failed to create %s: %s', $mjcType, $e->getMessage()); + } + } + + return $result; + } + + /** + * Clear the migration flag from MokoSuiteCross component params. + */ + private static function clearMigrationFlag($db): void + { + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuitecross')); + + $db->setQuery($query); + $params = json_decode($db->loadResult() ?: '{}', true) ?: []; + + unset($params['migration_available'], $params['migration_source_params']); + + $query = $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params))) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuitecross')); + + $db->setQuery($query); + $db->execute(); + } +} diff --git a/source/packages/com_mokosuitecross/src/Helper/MokoSuiteCrossHelper.php b/source/packages/com_mokosuitecross/src/Helper/MokoSuiteCrossHelper.php new file mode 100644 index 0000000..2762fc1 --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Helper/MokoSuiteCrossHelper.php @@ -0,0 +1,74 @@ + + * @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\Component\MokoSuiteCross\Administrator\Helper; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; + +/** + * Component helper — renders the admin submenu. + * + * Uses Joomla 5+ toolbar submenu API when available, falling back to the + * deprecated Sidebar API for Joomla 4 compatibility. + */ +class MokoSuiteCrossHelper +{ + /** + * Configure the submenu links. + * + * Called from each view's display() to highlight the active item. + * + * @param string $activeView The current view name + * + * @return void + */ + public static function addSubmenu(string $activeView): void + { + $views = [ + 'dashboard' => 'COM_MOKOSUITECROSS_SUBMENU_DASHBOARD', + 'posts' => 'COM_MOKOSUITECROSS_SUBMENU_POSTS', + 'services' => 'COM_MOKOSUITECROSS_SUBMENU_SERVICES', + 'templates' => 'COM_MOKOSUITECROSS_SUBMENU_TEMPLATES', + 'logs' => 'COM_MOKOSUITECROSS_SUBMENU_LOGS', + ]; + + // Joomla 5+ toolbar submenu + if (class_exists('Joomla\CMS\Toolbar\Toolbar')) { + try { + $toolbar = Factory::getApplication()->getDocument()->getToolbar('submenu'); + + if ($toolbar && method_exists($toolbar, 'linkButton')) { + foreach ($views as $view => $langKey) { + $toolbar->linkButton($view, Text::_($langKey)) + ->url('index.php?option=com_mokosuitecross&view=' . $view) + ->active($activeView === $view); + } + + return; + } + } catch (\Throwable $e) { + // Fall through to legacy sidebar + } + } + + // Legacy fallback for Joomla 4 + foreach ($views as $view => $langKey) { + \Joomla\CMS\HTML\Sidebar::addEntry( + Text::_($langKey), + 'index.php?option=com_mokosuitecross&view=' . $view, + $activeView === $view + ); + } + } +} diff --git a/source/packages/com_mokosuitecross/src/Helper/OAuthHelper.php b/source/packages/com_mokosuitecross/src/Helper/OAuthHelper.php new file mode 100644 index 0000000..0d51b3b --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Helper/OAuthHelper.php @@ -0,0 +1,311 @@ + + * @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\Component\MokoSuiteCross\Administrator\Helper; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\CMS\Uri\Uri; + +/** + * OAuth helper for services requiring browser-based authorization. + * + * Handles the OAuth 2.0 authorization code flow: + * 1. Generate authorize URL → redirect user to platform + * 2. Platform redirects back with auth code + * 3. Exchange code for access token + * 4. Store token in service credentials + * + * Each platform has its own endpoints and scopes. The service plugin + * provides these via OAuthConfigInterface (if it supports OAuth). + */ +class OAuthHelper +{ + /** + * OAuth endpoint configs per service type. + */ + private const OAUTH_CONFIGS = [ + 'facebook' => [ + 'authorize_url' => 'https://www.facebook.com/v19.0/dialog/oauth', + 'token_url' => 'https://graph.facebook.com/v19.0/oauth/access_token', + 'scopes' => 'pages_manage_posts,pages_read_engagement', + ], + 'linkedin' => [ + 'authorize_url' => 'https://www.linkedin.com/oauth/v2/authorization', + 'token_url' => 'https://www.linkedin.com/oauth/v2/accessToken', + 'scopes' => 'w_member_social', + ], + 'twitter' => [ + 'authorize_url' => 'https://twitter.com/i/oauth2/authorize', + 'token_url' => 'https://api.twitter.com/2/oauth2/token', + 'scopes' => 'tweet.read tweet.write users.read', + ], + ]; + + /** + * Build the authorization URL for a given service. + * + * @param string $serviceType Service type (facebook, linkedin, twitter) + * @param int $serviceId Service record ID (passed through state param) + * @param string $clientId OAuth client/app ID + * + * @return string|null Authorization URL or null if not supported + */ + public static function getAuthorizeUrl(string $serviceType, int $serviceId, string $clientId, string $nonce = ''): ?string + { + $config = self::OAUTH_CONFIGS[$serviceType] ?? null; + + if (!$config) { + return null; + } + + $redirectUri = self::getCallbackUrl(); + $statePayload = ['service_id' => $serviceId, 'type' => $serviceType]; + + if (!empty($nonce)) { + $statePayload['nonce'] = $nonce; + } + + $state = base64_encode(json_encode($statePayload)); + + $params = [ + 'client_id' => $clientId, + 'redirect_uri' => $redirectUri, + 'response_type' => 'code', + 'scope' => $config['scopes'], + 'state' => $state, + ]; + + // Twitter uses PKCE + if ($serviceType === 'twitter') { + $verifier = bin2hex(random_bytes(32)); + $challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); + + // Store verifier in session for token exchange + Factory::getApplication()->getSession()->set('mokosuitecross.pkce_verifier', $verifier); + + $params['code_challenge'] = $challenge; + $params['code_challenge_method'] = 'S256'; + } + + return $config['authorize_url'] . '?' . http_build_query($params); + } + + /** + * Exchange authorization code for access token. + * + * @param string $serviceType Service type + * @param string $code Authorization code from callback + * @param string $clientId OAuth client ID + * @param string $clientSecret OAuth client secret + * + * @return array ['access_token' => '...', 'expires_in' => N, ...] or ['error' => '...'] + */ + public static function exchangeCode(string $serviceType, string $code, string $clientId, string $clientSecret): array + { + $config = self::OAUTH_CONFIGS[$serviceType] ?? null; + + if (!$config) { + return ['error' => 'Unsupported service type for OAuth']; + } + + $postData = [ + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => self::getCallbackUrl(), + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + ]; + + // Twitter PKCE + if ($serviceType === 'twitter') { + $verifier = Factory::getApplication()->getSession()->get('mokosuitecross.pkce_verifier', ''); + $postData['code_verifier'] = $verifier; + } + + $ch = curl_init($config['token_url']); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query($postData), + CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded', 'Accept: application/json'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if ($httpCode >= 200 && $httpCode < 300 && !empty($data['access_token'])) { + return $data; + } + + return ['error' => $data['error_description'] ?? $data['error'] ?? 'Token exchange failed']; + } + + /** + * Store OAuth token in the service credentials. + * + * @param int $serviceId Service record ID + * @param array $tokenData Token response from platform + * + * @return bool + */ + public static function storeToken(int $serviceId, array $tokenData): bool + { + $db = Factory::getDbo(); + + $query = $db->getQuery(true) + ->select($db->quoteName('credentials')) + ->from($db->quoteName('#__mokosuitecross_services')) + ->where($db->quoteName('id') . ' = ' . $serviceId); + + $db->setQuery($query); + $credentials = json_decode($db->loadResult() ?: '{}', true) ?: []; + + $credentials['access_token'] = $tokenData['access_token']; + $credentials['mode'] = 'custom'; + + if (!empty($tokenData['refresh_token'])) { + $credentials['refresh_token'] = $tokenData['refresh_token']; + } + + if (!empty($tokenData['expires_in'])) { + $credentials['token_expires'] = time() + (int) $tokenData['expires_in']; + } + + $query = $db->getQuery(true) + ->update($db->quoteName('#__mokosuitecross_services')) + ->set($db->quoteName('credentials') . ' = ' . $db->quote(CredentialHelper::encrypt($credentials))) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . $serviceId); + + $db->setQuery($query); + $db->execute(); + + return true; + } + + /** + * Refresh an OAuth token if it has expired. + * + * Checks `token_expires` in the credentials array. If the token is expired + * and a refresh_token is available, performs the refresh grant and updates + * both the DB and the passed-in credentials array. + * + * @param int $serviceId Service record ID + * @param array &$credentials Credentials array (updated by reference on refresh) + * + * @return bool True if token was refreshed, false otherwise + */ + public static function refreshTokenIfNeeded(int $serviceId, array &$credentials): bool + { + // No expiry set — nothing to refresh + if (empty($credentials['token_expires'])) { + return false; + } + + // Token not yet expired + if ((int) $credentials['token_expires'] >= time()) { + return false; + } + + // Expired but no refresh token available + if (empty($credentials['refresh_token'])) { + return false; + } + + // Look up the service type from DB + $db = Factory::getDbo(); + + $query = $db->getQuery(true) + ->select($db->quoteName('service_type')) + ->from($db->quoteName('#__mokosuitecross_services')) + ->where($db->quoteName('id') . ' = ' . $serviceId); + $db->setQuery($query); + $serviceType = $db->loadResult(); + + if (!$serviceType) { + return false; + } + + // Get OAuth config for this service type + $config = self::OAUTH_CONFIGS[$serviceType] ?? null; + + if (!$config || empty($config['token_url'])) { + return false; + } + + // POST refresh token grant + $postData = [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $credentials['refresh_token'], + ]; + + // Include client credentials if available + if (!empty($credentials['client_id'])) { + $postData['client_id'] = $credentials['client_id']; + } + + if (!empty($credentials['client_secret'])) { + $postData['client_secret'] = $credentials['client_secret']; + } + + $ch = curl_init($config['token_url']); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query($postData), + CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded', 'Accept: application/json'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if ($httpCode >= 200 && $httpCode < 300 && !empty($data['access_token'])) { + // Store updated token in DB + self::storeToken($serviceId, $data); + + // Update credentials by reference + $credentials['access_token'] = $data['access_token']; + + if (!empty($data['refresh_token'])) { + $credentials['refresh_token'] = $data['refresh_token']; + } + + if (!empty($data['expires_in'])) { + $credentials['token_expires'] = time() + (int) $data['expires_in']; + } + + return true; + } + + return false; + } + + /** + * Get the OAuth callback URL for this Joomla installation. + * + * @return string + */ + public static function getCallbackUrl(): string + { + return Uri::root() . 'administrator/index.php?option=com_mokosuitecross&task=oauth.callback'; + } +} diff --git a/source/packages/com_mokosuitecross/src/Helper/QueueProcessor.php b/source/packages/com_mokosuitecross/src/Helper/QueueProcessor.php new file mode 100644 index 0000000..c515ca4 --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Helper/QueueProcessor.php @@ -0,0 +1,903 @@ + + * @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\Component\MokoSuiteCross\Administrator\Helper; + +defined('_JEXEC') or die; + +use Joomla\CMS\Component\ComponentHelper; +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\CMS\Uri\Uri; +use Joomla\Component\MokoSuiteCross\Administrator\Helper\CredentialHelper; +use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface; + +/** + * Shared queue processor used by: + * - System plugin onAfterRender (page-load processing) + * - Task scheduler plugin (Joomla scheduled task) + * + * Handles: queued posts, failed retries, scheduled posts, and log cleanup. + * Uses a simple DB-based lock to prevent concurrent execution. + */ +class QueueProcessor +{ + /** + * Process the post queue: dispatch queued posts, retry failed, fire scheduled. + * + * @param int $batchSize Max posts to process per run + * + * @return array ['processed' => int, 'succeeded' => int, 'failed' => int, 'skipped' => int] + */ + public static function processQueue(int $batchSize = 10): array + { + $result = ['processed' => 0, 'succeeded' => 0, 'failed' => 0, 'skipped' => 0]; + + if (!self::acquireLock()) { + $result['skipped'] = -1; + + return $result; + } + + try { + $db = Factory::getDbo(); + $componentParams = ComponentHelper::getParams('com_mokosuitecross'); + $maxRetry = (int) $componentParams->get('retry_max', 3); + $retryDelay = (int) $componentParams->get('retry_delay', 300); + $now = Factory::getDate()->toSql(); + + // Build service plugin map + $pluginMap = self::getServicePluginMap(); + + // 1. Process queued posts + $query = $db->getQuery(true) + ->select('p.*, s.service_type, s.credentials, s.params AS service_params') + ->from($db->quoteName('#__mokosuitecross_posts', 'p')) + ->join('INNER', $db->quoteName('#__mokosuitecross_services', 's') + . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id')) + ->where($db->quoteName('p.status') . ' = ' . $db->quote('queued')) + ->where('(' . $db->quoteName('p.scheduled_at') . ' IS NULL OR ' + . $db->quoteName('p.scheduled_at') . ' <= ' . $db->quote($now) . ')') + ->where($db->quoteName('s.published') . ' = 1') + ->order($db->quoteName('p.created') . ' ASC') + ->setLimit($batchSize); + + $db->setQuery($query); + $queuedPosts = $db->loadObjectList() ?: []; + + // 2. Process failed posts eligible for retry (exponential backoff) + // Retry 1 waits retryDelay, retry 2 waits retryDelay*2, retry 3 waits retryDelay*4, etc. + $query = $db->getQuery(true) + ->select('p.*, s.service_type, s.credentials, s.params AS service_params') + ->from($db->quoteName('#__mokosuitecross_posts', 'p')) + ->join('INNER', $db->quoteName('#__mokosuitecross_services', 's') + . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id')) + ->where($db->quoteName('p.status') . ' = ' . $db->quote('failed')) + ->where($db->quoteName('p.retry_count') . ' < ' . $maxRetry) + ->where($db->quoteName('p.modified') . ' <= DATE_SUB(NOW(), INTERVAL (' + . (int) $retryDelay . ' * POW(2, ' . $db->quoteName('p.retry_count') . ')) SECOND)') + ->where($db->quoteName('s.published') . ' = 1') + ->order($db->quoteName('p.modified') . ' ASC') + ->setLimit($batchSize); + + $db->setQuery($query); + $retryPosts = $db->loadObjectList() ?: []; + + $allPosts = array_merge($queuedPosts, $retryPosts); + + foreach ($allPosts as $post) { + $result['processed']++; + + $plugin = $pluginMap[$post->service_type] ?? null; + + if (!$plugin) { + $result['skipped']++; + continue; + } + + $isRetry = ($post->status === 'failed'); + + if ($isRetry) { + $newRetryCount = (int) $post->retry_count + 1; + + // If this is the last retry attempt, mark permanently failed on failure + if ($newRetryCount >= $maxRetry) { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokosuitecross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('permanently_failed')) + ->set($db->quoteName('retry_count') . ' = ' . $newRetryCount) + ->set($db->quoteName('error_message') . ' = CONCAT(' . $db->quoteName('error_message') . ', ' . $db->quote(' [max retries exceeded]') . ')') + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . (int) $post->id) + ); + $db->execute(); + + self::log($db, (int) $post->id, (int) $post->service_id, 'error', + sprintf('Permanently failed %s: max retries (%d) exceeded', $post->service_type, $maxRetry)); + + $result['failed']++; + continue; + } + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokosuitecross_posts')) + ->set($db->quoteName('retry_count') . ' = ' . $newRetryCount) + ->where($db->quoteName('id') . ' = ' . (int) $post->id) + ); + $db->execute(); + } + + // Mark as posting + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokosuitecross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('posting')) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . (int) $post->id) + ); + $db->execute(); + + $credentials = CredentialHelper::decrypt($post->credentials ?: ''); + $params = json_decode($post->service_params ?: '{}', true) ?: []; + + // Token auto-refresh before posting + OAuthHelper::refreshTokenIfNeeded((int) $post->service_id, $credentials); + + // Extract intro image for media attachment + $media = []; + + if (!empty($post->article_id)) { + $imgQuery = $db->getQuery(true) + ->select($db->quoteName('images')) + ->from($db->quoteName('#__content')) + ->where($db->quoteName('id') . ' = ' . (int) $post->article_id); + $db->setQuery($imgQuery); + $imgJson = $db->loadResult(); + + if ($imgJson) { + $imgData = json_decode($imgJson); + + if (!empty($imgData->image_intro)) { + $media[] = Uri::root() . ltrim($imgData->image_intro, '/'); + } + } + } + + // Lifecycle event: before post + $cancel = false; + $message = $post->message; + + try { + $dispatcher = Factory::getApplication()->getDispatcher(); + $beforeEvent = new \Joomla\Event\Event('onMokoSuiteCrossBeforePost', [(int) $post->id, &$message, $post->service_type, &$cancel]); + $dispatcher->dispatch('onMokoSuiteCrossBeforePost', $beforeEvent); + } catch (\Throwable $e) { + // Dispatcher may not be available + } + + if ($cancel) { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokosuitecross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('cancelled')) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . (int) $post->id) + ); + $db->execute(); + + self::log($db, (int) $post->id, (int) $post->service_id, 'info', + sprintf('Post to %s cancelled by onMokoSuiteCrossBeforePost event', $post->service_type)); + + $result['skipped']++; + continue; + } + + try { + $apiResult = $plugin->publish($message, $media, $credentials, $params); + + if (!empty($apiResult['success'])) { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokosuitecross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('posted')) + ->set($db->quoteName('platform_post_id') . ' = ' . $db->quote($apiResult['platform_post_id'] ?? '')) + ->set($db->quoteName('platform_response') . ' = ' . $db->quote(json_encode($apiResult['response'] ?? []))) + ->set($db->quoteName('posted_at') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . (int) $post->id) + ); + $db->execute(); + + self::log($db, (int) $post->id, (int) $post->service_id, 'info', + sprintf('%s to %s (ID: %s)', $isRetry ? 'Retry succeeded' : 'Posted', $post->service_type, $apiResult['platform_post_id'] ?? 'n/a')); + + // Lifecycle event: after successful post + try { + $afterEvent = new \Joomla\Event\Event('onMokoSuiteCrossAfterPost', [(int) $post->id, $post->service_type, $apiResult]); + $dispatcher->dispatch('onMokoSuiteCrossAfterPost', $afterEvent); + } catch (\Throwable $e) { + // Non-critical + } + + $result['succeeded']++; + } else { + $errorMsg = $apiResult['response']['error'] ?? json_encode($apiResult['response'] ?? []); + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokosuitecross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('failed')) + ->set($db->quoteName('error_message') . ' = ' . $db->quote(mb_substr($errorMsg, 0, 1000))) + ->set($db->quoteName('platform_response') . ' = ' . $db->quote(json_encode($apiResult['response'] ?? []))) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . (int) $post->id) + ); + $db->execute(); + + self::log($db, (int) $post->id, (int) $post->service_id, 'error', + sprintf('Failed %s: %s', $post->service_type, mb_substr($errorMsg, 0, 500))); + + // Lifecycle event: post failed + try { + $failedEvent = new \Joomla\Event\Event('onMokoSuiteCrossPostFailed', [(int) $post->id, $post->service_type, $errorMsg]); + $dispatcher->dispatch('onMokoSuiteCrossPostFailed', $failedEvent); + } catch (\Throwable $e) { + // Non-critical + } + + $result['failed']++; + } + } catch (\Throwable $e) { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokosuitecross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('failed')) + ->set($db->quoteName('error_message') . ' = ' . $db->quote(mb_substr($e->getMessage(), 0, 1000))) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . (int) $post->id) + ); + $db->execute(); + + self::log($db, (int) $post->id, (int) $post->service_id, 'error', + sprintf('Exception %s: %s', $post->service_type, mb_substr($e->getMessage(), 0, 500))); + + // Lifecycle event: post failed (exception) + try { + $failedEvent = new \Joomla\Event\Event('onMokoSuiteCrossPostFailed', [(int) $post->id, $post->service_type, $e->getMessage()]); + $dispatcher->dispatch('onMokoSuiteCrossPostFailed', $failedEvent); + } catch (\Throwable $ex) { + // Non-critical + } + + $result['failed']++; + } + } + + // 3. Clean up old logs + self::cleanupLogs($db, $componentParams); + + } finally { + self::releaseLock(); + } + + return $result; + } + + /** + * Process evergreen re-shares: find articles marked as evergreen whose last + * successful post to each service was longer ago than the configured interval, + * and create new queue entries for them. + * + * @return array ['queued' => int] + */ + public static function processEvergreen(): array + { + $result = ['queued' => 0]; + + $componentParams = ComponentHelper::getParams('com_mokosuitecross'); + + if (!$componentParams->get('evergreen_enabled', 1)) { + return $result; + } + + $defaultInterval = (int) $componentParams->get('evergreen_default_interval', 30); + $maxPerRun = (int) $componentParams->get('evergreen_max_per_run', 3); + + $db = Factory::getDbo(); + $now = Factory::getDate()->toSql(); + + // Find published articles with evergreen=1 in attribs + $query = $db->getQuery(true) + ->select('c.id, c.attribs') + ->from($db->quoteName('#__content', 'c')) + ->where($db->quoteName('c.state') . ' = 1') + ->where('JSON_EXTRACT(' . $db->quoteName('c.attribs') . ', ' . $db->quote('$.mokosuitecross_evergreen') . ') = ' . $db->quote('1')); + + $db->setQuery($query); + $articles = $db->loadObjectList() ?: []; + + if (empty($articles)) { + return $result; + } + + // Load all published services + $query = $db->getQuery(true) + ->select('id, service_type') + ->from($db->quoteName('#__mokosuitecross_services')) + ->where($db->quoteName('published') . ' = 1'); + + $db->setQuery($query); + $services = $db->loadObjectList() ?: []; + + if (empty($services)) { + return $result; + } + + // Import service plugins (not used for direct dispatch here, but ensures + // they are loaded in case any lifecycle events depend on them) + PluginHelper::importPlugin('mokosuitecross'); + + // Batch pre-load: latest posted_at per article+service (eliminates N*M queries) + $articleIds = implode(',', array_map(function ($a) { return (int) $a->id; }, $articles)); + $serviceIds = implode(',', array_map(function ($s) { return (int) $s->id; }, $services)); + + $query = $db->getQuery(true) + ->select(['article_id', 'service_id', 'MAX(' . $db->quoteName('posted_at') . ') AS last_posted']) + ->from($db->quoteName('#__mokosuitecross_posts')) + ->where($db->quoteName('article_id') . ' IN (' . $articleIds . ')') + ->where($db->quoteName('service_id') . ' IN (' . $serviceIds . ')') + ->where($db->quoteName('status') . ' = ' . $db->quote('posted')) + ->group(['article_id', 'service_id']); + $db->setQuery($query); + $lastPostedRows = $db->loadObjectList() ?: []; + + $lastPostedMap = []; + foreach ($lastPostedRows as $row) { + $lastPostedMap[$row->article_id . ':' . $row->service_id] = $row->last_posted; + } + + // Batch pre-load: existing queued/posting entries + $query = $db->getQuery(true) + ->select(['article_id', 'service_id']) + ->from($db->quoteName('#__mokosuitecross_posts')) + ->where($db->quoteName('article_id') . ' IN (' . $articleIds . ')') + ->where($db->quoteName('service_id') . ' IN (' . $serviceIds . ')') + ->where($db->quoteName('status') . ' IN (' . $db->quote('queued') . ',' . $db->quote('posting') . ')'); + $db->setQuery($query); + $pendingRows = $db->loadObjectList() ?: []; + + $pendingSet = []; + foreach ($pendingRows as $row) { + $pendingSet[$row->article_id . ':' . $row->service_id] = true; + } + + foreach ($articles as $article) { + if ($result['queued'] >= $maxPerRun) { + break; + } + + $attribs = json_decode($article->attribs ?? '{}', true) ?: []; + $interval = (int) ($attribs['mokosuitecross_evergreen_interval'] ?? $defaultInterval); + + if ($interval < 1) { + $interval = $defaultInterval; + } + + // Per-article service filter + $selectedServiceIds = $attribs['mokosuitecross_services'] ?? null; + + if (is_array($selectedServiceIds) && !empty($selectedServiceIds)) { + $selectedServiceIds = array_map('intval', $selectedServiceIds); + } else { + $selectedServiceIds = null; + } + + // Load the full article for template rendering + $fullArticle = null; + + foreach ($services as $service) { + if ($result['queued'] >= $maxPerRun) { + break; + } + + // Per-article service filter + if ($selectedServiceIds !== null && !in_array((int) $service->id, $selectedServiceIds, true)) { + continue; + } + + $key = $article->id . ':' . $service->id; + + // Check last successful post from batch-loaded map + $lastPosted = $lastPostedMap[$key] ?? null; + + if (empty($lastPosted)) { + // Never posted — skip, the initial cross-post will handle it + continue; + } + + // Check if interval has elapsed + $dueDate = Factory::getDate($lastPosted . ' + ' . $interval . ' days'); + + if ($dueDate->toUnix() > Factory::getDate()->toUnix()) { + continue; + } + + // Skip if there's already a queued/posting entry + if (isset($pendingSet[$key])) { + continue; + } + + // Load full article if not already loaded + if ($fullArticle === null) { + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__content')) + ->where($db->quoteName('id') . ' = ' . (int) $article->id); + $db->setQuery($query); + $fullArticle = $db->loadObject(); + + if (!$fullArticle) { + break; + } + } + + // Render message using default template + $template = $componentParams->get('default_template', "{title}\n\n{url}"); + $message = self::renderEvergreenMessage($db, $fullArticle, $template); + + // Create queue entry + $post = (object) [ + 'article_id' => (int) $article->id, + 'service_id' => (int) $service->id, + 'status' => 'queued', + 'message' => $message, + 'platform_post_id' => '', + 'platform_response' => '', + 'error_message' => '', + 'retry_count' => 0, + 'created' => $now, + 'modified' => $now, + ]; + + $db->insertObject('#__mokosuitecross_posts', $post); + + self::log($db, $db->insertid(), (int) $service->id, 'info', + sprintf('Evergreen re-share queued for article %d to %s (interval: %d days)', + $article->id, $service->service_type, $interval)); + + $result['queued']++; + } + } + + return $result; + } + + /** + * Render a message for an evergreen re-share using the default template. + */ + private static function renderEvergreenMessage($db, object $article, string $template): string + { + $url = \Joomla\CMS\Uri\Uri::root() . 'index.php?option=com_content&view=article&id=' . $article->id; + + if (!empty($article->catid)) { + $url .= '&catid=' . $article->catid; + } + + $categoryName = ''; + + if (!empty($article->catid)) { + $query = $db->getQuery(true) + ->select($db->quoteName('title')) + ->from($db->quoteName('#__categories')) + ->where($db->quoteName('id') . ' = ' . (int) $article->catid); + $db->setQuery($query); + $categoryName = $db->loadResult() ?: ''; + } + + $authorName = ''; + + if (!empty($article->created_by)) { + $query = $db->getQuery(true) + ->select($db->quoteName('name')) + ->from($db->quoteName('#__users')) + ->where($db->quoteName('id') . ' = ' . (int) $article->created_by); + $db->setQuery($query); + $authorName = $db->loadResult() ?: ''; + } + + $introImage = ''; + $images = json_decode($article->images ?? '{}'); + + if (!empty($images->image_intro)) { + $introImage = \Joomla\CMS\Uri\Uri::root() . ltrim($images->image_intro, '/'); + } + + // Resolve article tags + $tagNames = []; + + if (!empty($article->id)) { + $query = $db->getQuery(true) + ->select($db->quoteName('t.title')) + ->from($db->quoteName('#__tags', 't')) + ->join('INNER', $db->quoteName('#__contentitem_tag_map', 'm') + . ' ON ' . $db->quoteName('m.tag_id') . ' = ' . $db->quoteName('t.id')) + ->where($db->quoteName('m.type_alias') . ' = ' . $db->quote('com_content.article')) + ->where($db->quoteName('m.content_item_id') . ' = ' . (int) $article->id) + ->where($db->quoteName('t.published') . ' = 1'); + $db->setQuery($query); + $tagNames = $db->loadColumn() ?: []; + } + + $tagsComma = implode(', ', $tagNames); + $hashtags = implode(' ', array_map(function ($tag) { + return '#' . preg_replace('/\s+/', '', $tag); + }, $tagNames)); + + $replacements = [ + '{title}' => $article->title ?? '', + '{introtext}' => strip_tags(mb_substr($article->introtext ?? '', 0, 280)), + '{fulltext}' => strip_tags(mb_substr($article->fulltext ?? '', 0, 500)), + '{url}' => $url, + '{image}' => $introImage, + '{category}' => $categoryName, + '{author}' => $authorName, + '{date}' => Factory::getDate($article->publish_up ?? 'now')->format('Y-m-d'), + '{tags}' => $tagsComma, + '{hashtags}' => $hashtags, + ]; + + $message = str_replace(array_keys($replacements), array_values($replacements), $template); + + // Resolve custom field placeholders: {field:field_name} + $message = preg_replace_callback('/\{field:([a-zA-Z0-9_-]+)\}/', function ($matches) use ($db, $article) { + $fieldName = $matches[1]; + $query = $db->getQuery(true) + ->select('fv.value') + ->from($db->quoteName('#__fields_values', 'fv')) + ->join('INNER', $db->quoteName('#__fields', 'f') . ' ON f.id = fv.field_id') + ->where('f.name = ' . $db->quote($fieldName)) + ->where('fv.item_id = ' . (int) $article->id); + $db->setQuery($query); + return $db->loadResult() ?: ''; + }, $message); + + return $message; + } + + /** + * Manually retry one or more failed/permanently_failed posts. + * + * Resets status to 'queued' and retry_count to 0 so the queue processor + * picks them up on the next run. + * + * @param array $postIds Post IDs to retry + * + * @return int Number of posts re-queued + */ + public static function retryPosts(array $postIds): int + { + if (empty($postIds)) { + return 0; + } + + $db = Factory::getDbo(); + $now = Factory::getDate()->toSql(); + $ids = implode(',', array_map('intval', $postIds)); + + $query = $db->getQuery(true) + ->update($db->quoteName('#__mokosuitecross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('queued')) + ->set($db->quoteName('retry_count') . ' = 0') + ->set($db->quoteName('error_message') . ' = ' . $db->quote('')) + ->set($db->quoteName('modified') . ' = ' . $db->quote($now)) + ->where($db->quoteName('id') . ' IN (' . $ids . ')') + ->where($db->quoteName('status') . ' IN (' . $db->quote('failed') . ',' . $db->quote('permanently_failed') . ')'); + + $db->setQuery($query); + $db->execute(); + $count = $db->getAffectedRows(); + + if ($count > 0) { + self::log($db, null, null, 'info', sprintf('Manual retry: %d post(s) re-queued', $count)); + } + + return $count; + } + + /** + * Retry all failed posts for a specific service. + * + * @param int $serviceId Service ID + * + * @return int Number of posts re-queued + */ + public static function retryService(int $serviceId): int + { + $db = Factory::getDbo(); + $now = Factory::getDate()->toSql(); + + $query = $db->getQuery(true) + ->update($db->quoteName('#__mokosuitecross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('queued')) + ->set($db->quoteName('retry_count') . ' = 0') + ->set($db->quoteName('error_message') . ' = ' . $db->quote('')) + ->set($db->quoteName('modified') . ' = ' . $db->quote($now)) + ->where($db->quoteName('service_id') . ' = ' . $serviceId) + ->where($db->quoteName('status') . ' IN (' . $db->quote('failed') . ',' . $db->quote('permanently_failed') . ')'); + + $db->setQuery($query); + $db->execute(); + $count = $db->getAffectedRows(); + + if ($count > 0) { + self::log($db, null, $serviceId, 'info', sprintf('Bulk retry: %d post(s) re-queued for service %d', $count, $serviceId)); + } + + return $count; + } + + /** + * Check if there are pending items in the queue. + * + * @return bool + */ + public static function hasPendingWork(): bool + { + $db = Factory::getDbo(); + + $componentParams = ComponentHelper::getParams('com_mokosuitecross'); + $maxRetry = (int) $componentParams->get('retry_max', 3); + $retryDelay = (int) $componentParams->get('retry_delay', 300); + $now = Factory::getDate()->toSql(); + + // Queued posts ready to go + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokosuitecross_posts')) + ->where($db->quoteName('status') . ' = ' . $db->quote('queued')) + ->where('(' . $db->quoteName('scheduled_at') . ' IS NULL OR ' + . $db->quoteName('scheduled_at') . ' <= ' . $db->quote($now) . ')'); + $db->setQuery($query); + $queued = (int) $db->loadResult(); + + // Failed posts eligible for retry (exponential backoff matching processQueue) + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokosuitecross_posts')) + ->where($db->quoteName('status') . ' = ' . $db->quote('failed')) + ->where($db->quoteName('retry_count') . ' < ' . $maxRetry) + ->where($db->quoteName('modified') . ' <= DATE_SUB(NOW(), INTERVAL (' + . (int) $retryDelay . ' * POW(2, ' . $db->quoteName('retry_count') . ')) SECOND)'); + $db->setQuery($query); + $retryable = (int) $db->loadResult(); + + return ($queued + $retryable) > 0; + } + + /** + * Import mokosuitecross plugins and build a type → plugin instance map. + * + * @return arrayactive_services; ?>
+queued_count; ?>
+posted_count; ?>
+failed_count; ?>
+| + | + | + | + | + | + |
|---|---|---|---|---|---|
| + + + + + | ++ | + | + | + | % | +
total; ?>
+posted; ?>
+failed; ?>
+%
+| + | + | + | + |
|---|---|---|---|
|
+
+ escape(ucfirst($post['status'])); ?>
+
+ 0) : ?>
+ Retries: + + |
+ + + escape($post['article_title'] ?? 'Article #' . $post['id']); ?> + + | ++ + | ++ + escape(mb_substr($post['error_message'], 0, 100)); ?> + + — + + | +
Originally published at ' + . htmlspecialchars($articleUrl, ENT_QUOTES, 'UTF-8') + . '
'; + } + + $payload = json_encode([ + 'title' => $title, + 'content' => $content, + 'status' => $status, + ]); + + $ch = curl_init($apiUrl); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => [ + 'Authorization: Basic ' . base64_encode($username . ':' . $appPassword), + '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 === 201 && !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 + { + $siteUrl = rtrim($credentials['site_url'] ?? '', '/'); + $username = $credentials['username'] ?? ''; + $appPassword = $credentials['app_password'] ?? ''; + + if (empty($siteUrl) || empty($username) || empty($appPassword)) { + return ['valid' => false, 'message' => 'Site URL, username, and application password are required.', 'account_name' => '']; + } + + $ch = curl_init($siteUrl . '/wp-json/wp/v2/users/me'); + curl_setopt_array($ch, [ + CURLOPT_HTTPHEADER => ['Authorization: Basic ' . base64_encode($username . ':' . $appPassword)], + 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['name'])) { + return ['valid' => true, 'message' => 'Connected', 'account_name' => $data['name']]; + } + + return ['valid' => false, 'message' => $data['message'] ?? 'Failed to verify credentials.', 'account_name' => '']; + } + + public function getSupportedMediaTypes(): array + { + return ['image']; + } +} diff --git a/source/packages/plg_mokosuitecross_wordpress/src/Extension/index.html b/source/packages/plg_mokosuitecross_wordpress/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/source/packages/plg_mokosuitecross_wordpress/src/Extension/index.html @@ -0,0 +1 @@ +MOKO-XXXX-XXXX-XXXX-XXXX) in the Download Key field '
+ . 'for the MokoSuiteCross update site.',
+ 'warning'
+ );
+ } catch (\Throwable $e) {
+ // Don't break admin over a license check
+ }
+ }
+
+ /**
+ * Store the last page-load run timestamp.
+ */
+ private function updateLastRunTimestamp(): void
+ {
+ $db = Factory::getDbo();
+ $now = time();
+
+ // Use JSON_SET for atomic update without read-modify-write race
+ try {
+ $db->setQuery(
+ 'UPDATE ' . $db->quoteName('#__extensions')
+ . ' SET ' . $db->quoteName('params') . ' = JSON_SET('
+ . $db->quoteName('params') . ', ' . $db->quote('$._pageload_last_run') . ', ' . $now . ')'
+ . ' WHERE ' . $db->quoteName('type') . ' = ' . $db->quote('component')
+ . ' AND ' . $db->quoteName('element') . ' = ' . $db->quote('com_mokosuitecross')
+ );
+ $db->execute();
+ } catch (\Throwable $e) {
+ // Fallback for databases without JSON_SET
+ $query = $db->getQuery(true)
+ ->select($db->quoteName('params'))
+ ->from($db->quoteName('#__extensions'))
+ ->where($db->quoteName('type') . ' = ' . $db->quote('component'))
+ ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuitecross'));
+
+ $db->setQuery($query);
+ $params = json_decode($db->loadResult() ?: '{}', true) ?: [];
+ $params['_pageload_last_run'] = $now;
+
+ $query = $db->getQuery(true)
+ ->update($db->quoteName('#__extensions'))
+ ->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params)))
+ ->where($db->quoteName('type') . ' = ' . $db->quote('component'))
+ ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuitecross'));
+
+ $db->setQuery($query);
+ $db->execute();
+ }
+ }
+}
diff --git a/src/packages/plg_webservices_mokojoomcross/index.html b/source/packages/plg_system_mokosuitecross/src/Extension/index.html
similarity index 100%
rename from src/packages/plg_webservices_mokojoomcross/index.html
rename to source/packages/plg_system_mokosuitecross/src/Extension/index.html
diff --git a/src/packages/plg_webservices_mokojoomcross/language/en-GB/index.html b/source/packages/plg_system_mokosuitecross/src/index.html
similarity index 100%
rename from src/packages/plg_webservices_mokojoomcross/language/en-GB/index.html
rename to source/packages/plg_system_mokosuitecross/src/index.html
diff --git a/source/packages/plg_system_mokosuitecross_events/language/en-GB/plg_system_mokosuitecross_events.ini b/source/packages/plg_system_mokosuitecross_events/language/en-GB/plg_system_mokosuitecross_events.ini
new file mode 100644
index 0000000..24712bb
--- /dev/null
+++ b/source/packages/plg_system_mokosuitecross_events/language/en-GB/plg_system_mokosuitecross_events.ini
@@ -0,0 +1,2 @@
+PLG_SYSTEM_MOKOSUITECROSS_EVENTS="System - MokoSuiteCross Events"
+PLG_SYSTEM_MOKOSUITECROSS_EVENTS_DESCRIPTION="Cross-posts MokoSuiteCalendar events to social media and messaging platforms via MokoSuiteCross."
diff --git a/source/packages/plg_system_mokosuitecross_events/language/en-GB/plg_system_mokosuitecross_events.sys.ini b/source/packages/plg_system_mokosuitecross_events/language/en-GB/plg_system_mokosuitecross_events.sys.ini
new file mode 100644
index 0000000..24712bb
--- /dev/null
+++ b/source/packages/plg_system_mokosuitecross_events/language/en-GB/plg_system_mokosuitecross_events.sys.ini
@@ -0,0 +1,2 @@
+PLG_SYSTEM_MOKOSUITECROSS_EVENTS="System - MokoSuiteCross Events"
+PLG_SYSTEM_MOKOSUITECROSS_EVENTS_DESCRIPTION="Cross-posts MokoSuiteCalendar events to social media and messaging platforms via MokoSuiteCross."
diff --git a/source/packages/plg_system_mokosuitecross_events/mokosuitecross_events.php b/source/packages/plg_system_mokosuitecross_events/mokosuitecross_events.php
new file mode 100644
index 0000000..e298800
--- /dev/null
+++ b/source/packages/plg_system_mokosuitecross_events/mokosuitecross_events.php
@@ -0,0 +1,12 @@
+
+ * @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_system_mokosuitecross_events/mokosuitecross_events.xml b/source/packages/plg_system_mokosuitecross_events/mokosuitecross_events.xml
new file mode 100644
index 0000000..36c0afd
--- /dev/null
+++ b/source/packages/plg_system_mokosuitecross_events/mokosuitecross_events.xml
@@ -0,0 +1,26 @@
+
+{introtext}
+ +``` + +### Telegram (HTML format) +```html +{title} + +{introtext} + +Read more +``` + +## Per-Article Override + +In the article editor, the **Cross-Posting** tab lets you: +- Skip cross-posting entirely for a specific article +- Select which services to post to (instead of all enabled services) diff --git a/wiki/user-guide/Services.md b/wiki/user-guide/Services.md new file mode 100644 index 0000000..7034215 --- /dev/null +++ b/wiki/user-guide/Services.md @@ -0,0 +1,60 @@ +# Services + +MokoSuiteCross supports 9 platforms. Each is a separate plugin that can be enabled or disabled independently. + +## Social Media + +| Platform | Plugin | Character Limit | Media | Default Bot | +|----------|--------|----------------|-------|-------------| +| **Facebook** | plg_mokosuitecross_facebook | No limit | Yes | Yes | +| **X / Twitter** | plg_mokosuitecross_twitter | 280 | Yes | No | +| **LinkedIn** | plg_mokosuitecross_linkedin | 3,000 | Yes | No | +| **Mastodon** | plg_mokosuitecross_mastodon | 500 | Yes | No | +| **Bluesky** | plg_mokosuitecross_bluesky | 300 | Yes | No | + +## Email Marketing + +| Platform | Plugin | Character Limit | Media | Default Bot | +|----------|--------|----------------|-------|-------------| +| **Mailchimp** | plg_mokosuitecross_mailchimp | No limit | Yes | No | + +## Chat / Messaging + +| Platform | Plugin | Character Limit | Media | Default Bot | +|----------|--------|----------------|-------|-------------| +| **Telegram** | plg_mokosuitecross_telegram | 4,096 | Yes | Yes (@mokosuite_bot) | +| **Discord** | plg_mokosuitecross_discord | 2,000 | Yes | Yes (webhook) | +| **Slack** | plg_mokosuitecross_slack | 40,000 | Yes | Yes (webhook) | + +## Default vs Custom Mode + +Services with "Default Bot" support offer two operating modes: + +- **Default Mode**: Uses a pre-configured bot/app token managed by Moko. The admin only needs to provide a destination (chat ID, page ID, etc.). The API key is stored in the plugin's configuration and never visible in the service record. + +- **Custom Mode**: The admin provides their own API keys, tokens, or webhook URLs. Full control, but requires setting up your own app/bot on the platform. + +Configure default tokens in **Extensions → Plugins → MokoSuiteCross - [Platform]**. + +## Adding a Service + +1. Go to **Components → MokoSuiteCross → Services** +2. Click **New** +3. Select the service type +4. Enter a title and choose credentials mode +5. For **Default mode**: enter only the destination (chat ID, channel, etc.) +6. For **Custom mode**: enter your full API credentials as JSON +7. Save and enable + +## Credentials Format + +Each service expects specific JSON fields. See the individual service pages: +- [[Telegram]] — bot_token, chat_id +- [[Facebook]] — page_access_token, page_id +- [[Discord]] — webhook_url +- [[Slack]] — webhook_url +- [[LinkedIn]] — access_token, organization_id +- [[Mastodon]] — instance_url, access_token +- [[Bluesky]] — handle, app_password +- [[Mailchimp]] — api_key, list_id +- [[Twitter (X)]] — bearer_token, api_key, api_secret diff --git a/wiki/user-guide/Troubleshooting.md b/wiki/user-guide/Troubleshooting.md new file mode 100644 index 0000000..352eb25 --- /dev/null +++ b/wiki/user-guide/Troubleshooting.md @@ -0,0 +1,48 @@ +# Troubleshooting + +## Posts Stuck in "Queued" Status + +**Cause**: The queue processor isn't running. + +**Fix**: +1. Check **Components → MokoSuiteCross → Options → Queue Processing** — ensure it's set to "Scheduler" or "Both" +2. If using Scheduler: verify a task exists in **System → Scheduled Tasks** of type "MokoSuiteCross - Process Queue" +3. If using Page-load: ensure the system plugin is enabled and check the throttle interval + +## Posts Failing + +**Cause**: Invalid credentials or platform API changes. + +**Fix**: +1. Check the error message in **Components → MokoSuiteCross → Post Queue** (hover over the red "Failed" badge) +2. Check **Activity Logs** for detailed error messages +3. Go to **Services** and verify credentials +4. For services using Default mode, check the plugin params in **Extensions → Plugins** + +## "No service plugin found" Warning + +**Cause**: The service plugin for that platform is disabled. + +**Fix**: Go to **Extensions → Plugins**, search for "MokoSuiteCross", and enable the relevant service plugin. + +## Cross-posting Not Triggering on Publish + +**Cause**: Auto-post is disabled or system plugin is inactive. + +**Fix**: +1. Check **Components → MokoSuiteCross → Options** — "Auto-post on Publish" should be "Yes" +2. Verify **Extensions → Plugins → System - MokoSuiteCross** is enabled +3. Check that at least one service is configured and enabled + +## Duplicate Posts + +MokoSuiteCross has a built-in duplicate guard. If you're seeing duplicates: +1. Check if the article was saved multiple times in quick succession +2. Check if both page-load and scheduler are running (shouldn't cause duplicates, but verify) +3. Review the **Activity Logs** for the article in question + +## OAuth Connection Failing + +1. Verify the OAuth Client ID and Secret are correct in the plugin params +2. Check that the redirect URI matches: `https://yoursite.com/administrator/index.php?option=com_mokosuitecross&task=oauth.callback` +3. Ensure your Joomla site uses HTTPS (required by most OAuth providers)