diff --git a/CHANGELOG.md b/CHANGELOG.md
index cb7b58c3..2afffbd3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- `branch-cleanup.yml`: auto-delete merged feature branches after PR merge
+- `plg_webservices_perfectpublisher`: REST API for Perfect Publisher (com_autotweet) — channels, posts, requests, rules, feeds, and stats
### Planned
- License/subscription check
diff --git a/README.md b/README.md
index cfd372c9..7b526ef6 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
- VERSION: 02.13.01
+ VERSION: 02.13.02
PATH: /README.md
BRIEF: MokoWaaS platform plugin for Joomla
-->
diff --git a/src/packages/com_mokowaas/mokowaas.xml b/src/packages/com_mokowaas/mokowaas.xml
index 347d1079..d4945372 100644
--- a/src/packages/com_mokowaas/mokowaas.xml
+++ b/src/packages/com_mokowaas/mokowaas.xml
@@ -7,7 +7,7 @@
GPL-3.0-or-later
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.13.01
+ 02.13.02
Minimal API-only component for MokoWaaS. Provides REST endpoints for site health, cache, updates, and backups.
Moko\Component\MokoWaaS\Api
diff --git a/src/packages/plg_system_mokowaas/mokowaas.xml b/src/packages/plg_system_mokowaas/mokowaas.xml
index 332cae08..7139d84c 100644
--- a/src/packages/plg_system_mokowaas/mokowaas.xml
+++ b/src/packages/plg_system_mokowaas/mokowaas.xml
@@ -30,7 +30,7 @@
GNU General Public License version 3 or later; see LICENSE.md
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.13.01
+ 02.13.02
This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform.
Moko\Plugin\System\MokoWaaS
script.php
diff --git a/src/packages/plg_webservices_mokowaas/mokowaas.xml b/src/packages/plg_webservices_mokowaas/mokowaas.xml
index e7a5e26c..2ad8d3b6 100644
--- a/src/packages/plg_webservices_mokowaas/mokowaas.xml
+++ b/src/packages/plg_webservices_mokowaas/mokowaas.xml
@@ -7,7 +7,7 @@
GPL-3.0-or-later
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.13.01
+ 02.13.02
Joomla Web Services API routes for MokoWaaS site management — health checks, cache, updates, backups, and site info.
Moko\Plugin\WebServices\MokoWaaS
diff --git a/src/packages/plg_webservices_perfectpublisher/perfectpublisher.xml b/src/packages/plg_webservices_perfectpublisher/perfectpublisher.xml
new file mode 100644
index 00000000..06638098
--- /dev/null
+++ b/src/packages/plg_webservices_perfectpublisher/perfectpublisher.xml
@@ -0,0 +1,17 @@
+
+
+ Web Services - Perfect Publisher
+ Moko Consulting
+ 2026-05-28
+ Copyright (C) 2026 Moko Consulting. All rights reserved.
+ GPL-3.0-or-later
+ hello@mokoconsulting.tech
+ https://mokoconsulting.tech
+ 02.13.02
+ Joomla Web Services API routes for Perfect Publisher (com_autotweet) — channels, posts, requests, rules, and feeds.
+ Moko\Plugin\WebServices\PerfectPublisher
+
+ services
+ src
+
+
diff --git a/src/packages/plg_webservices_perfectpublisher/services/provider.php b/src/packages/plg_webservices_perfectpublisher/services/provider.php
new file mode 100644
index 00000000..dd88ea91
--- /dev/null
+++ b/src/packages/plg_webservices_perfectpublisher/services/provider.php
@@ -0,0 +1,42 @@
+
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * FILE INFORMATION
+ * DEFGROUP: Joomla.Plugin
+ * INGROUP: MokoWaaS
+ * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
+ * PATH: /src/packages/plg_webservices_perfectpublisher/services/provider.php
+ * VERSION: 02.13.01
+ * BRIEF: DI service provider for Perfect Publisher Web Services plugin
+ */
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\Extension\PluginInterface;
+use Joomla\CMS\Factory;
+use Joomla\CMS\Plugin\PluginHelper;
+use Joomla\DI\Container;
+use Joomla\DI\ServiceProviderInterface;
+use Joomla\Event\DispatcherInterface;
+use Moko\Plugin\WebServices\PerfectPublisher\Extension\PerfectPublisherApi;
+
+return new class implements ServiceProviderInterface
+{
+ public function register(Container $container): void
+ {
+ $container->set(
+ PluginInterface::class,
+ function (Container $container) {
+ $dispatcher = $container->get(DispatcherInterface::class);
+ $plugin = new PerfectPublisherApi(
+ $dispatcher,
+ (array) PluginHelper::getPlugin('webservices', 'perfectpublisher')
+ );
+ $plugin->setApplication(Factory::getApplication());
+ return $plugin;
+ }
+ );
+ }
+};
diff --git a/src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php b/src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php
new file mode 100644
index 00000000..a0543cff
--- /dev/null
+++ b/src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php
@@ -0,0 +1,539 @@
+
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * FILE INFORMATION
+ * DEFGROUP: Joomla.Plugin
+ * INGROUP: MokoWaaS
+ * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
+ * PATH: /src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php
+ * VERSION: 02.13.01
+ * BRIEF: Web Services API plugin for Perfect Publisher (com_autotweet)
+ */
+
+namespace Moko\Plugin\WebServices\PerfectPublisher\Extension;
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\Factory;
+use Joomla\CMS\Plugin\CMSPlugin;
+use Joomla\CMS\Event\Application\BeforeApiRouteEvent;
+use Joomla\CMS\Router\ApiRouter;
+use Joomla\Event\SubscriberInterface;
+
+/**
+ * Perfect Publisher Web Services API Plugin
+ *
+ * Registers REST API routes for Perfect Publisher (com_autotweet) data.
+ * Provides read access to channels, posts, requests, rules, and feeds.
+ * Provides write access to create publish requests.
+ *
+ * Routes:
+ * GET /v1/perfectpublisher/channels List social channels
+ * GET /v1/perfectpublisher/channels/:id Get channel detail
+ * GET /v1/perfectpublisher/posts List published posts
+ * GET /v1/perfectpublisher/posts/:id Get post detail
+ * GET /v1/perfectpublisher/requests List pending requests
+ * POST /v1/perfectpublisher/requests Create a publish request
+ * GET /v1/perfectpublisher/rules List publishing rules
+ * GET /v1/perfectpublisher/feeds List RSS feeds
+ * GET /v1/perfectpublisher/channeltypes List channel type definitions
+ * GET /v1/perfectpublisher/stats Dashboard statistics
+ *
+ * @since 02.13.01
+ */
+final class PerfectPublisherApi extends CMSPlugin implements SubscriberInterface
+{
+ /**
+ * @return array
+ */
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ 'onBeforeApiRoute' => 'onBeforeApiRoute',
+ ];
+ }
+
+ /**
+ * Register API routes.
+ *
+ * @param BeforeApiRouteEvent $event The API route event
+ *
+ * @return void
+ */
+ public function onBeforeApiRoute(BeforeApiRouteEvent $event): void
+ {
+ $router = $event->getRouter();
+
+ // All routes are handled by this plugin directly via custom callbacks
+ // because com_autotweet uses FOF, not standard Joomla MVC
+
+ $router->addRoute(
+ new \Joomla\Router\Route(
+ ['GET'],
+ 'v1/perfectpublisher/channels',
+ [$this, 'getChannels']
+ )
+ );
+
+ $router->addRoute(
+ new \Joomla\Router\Route(
+ ['GET'],
+ 'v1/perfectpublisher/channels/:id',
+ [$this, 'getChannel']
+ )
+ );
+
+ $router->addRoute(
+ new \Joomla\Router\Route(
+ ['GET'],
+ 'v1/perfectpublisher/posts',
+ [$this, 'getPosts']
+ )
+ );
+
+ $router->addRoute(
+ new \Joomla\Router\Route(
+ ['GET'],
+ 'v1/perfectpublisher/posts/:id',
+ [$this, 'getPost']
+ )
+ );
+
+ $router->addRoute(
+ new \Joomla\Router\Route(
+ ['GET'],
+ 'v1/perfectpublisher/requests',
+ [$this, 'getRequests']
+ )
+ );
+
+ $router->addRoute(
+ new \Joomla\Router\Route(
+ ['POST'],
+ 'v1/perfectpublisher/requests',
+ [$this, 'createRequest']
+ )
+ );
+
+ $router->addRoute(
+ new \Joomla\Router\Route(
+ ['GET'],
+ 'v1/perfectpublisher/rules',
+ [$this, 'getRules']
+ )
+ );
+
+ $router->addRoute(
+ new \Joomla\Router\Route(
+ ['GET'],
+ 'v1/perfectpublisher/feeds',
+ [$this, 'getFeeds']
+ )
+ );
+
+ $router->addRoute(
+ new \Joomla\Router\Route(
+ ['GET'],
+ 'v1/perfectpublisher/channeltypes',
+ [$this, 'getChannelTypes']
+ )
+ );
+
+ $router->addRoute(
+ new \Joomla\Router\Route(
+ ['GET'],
+ 'v1/perfectpublisher/stats',
+ [$this, 'getStats']
+ )
+ );
+ }
+
+ /**
+ * GET /v1/perfectpublisher/channels
+ *
+ * @return void
+ */
+ public function getChannels(): void
+ {
+ $db = Factory::getDbo();
+ $app = Factory::getApplication();
+ $limit = (int) $app->input->get('limit', 20);
+ $offset = (int) $app->input->get('offset', 0);
+
+ $query = $db->getQuery(true)
+ ->select('c.*, ct.name AS channeltype_name, ct.max_chars')
+ ->from($db->quoteName('#__autotweet_channels', 'c'))
+ ->leftJoin(
+ $db->quoteName('#__autotweet_channeltypes', 'ct')
+ . ' ON ' . $db->quoteName('c.channeltype_id')
+ . ' = ' . $db->quoteName('ct.id')
+ )
+ ->order($db->quoteName('c.ordering') . ' ASC');
+
+ $published = $app->input->get('published', null);
+ if ($published !== null) {
+ $query->where($db->quoteName('c.published') . ' = ' . (int) $published);
+ }
+
+ $db->setQuery($query, $offset, $limit);
+
+ $this->sendJsonResponse($db->loadObjectList());
+ }
+
+ /**
+ * GET /v1/perfectpublisher/channels/:id
+ *
+ * @return void
+ */
+ public function getChannel(): void
+ {
+ $id = (int) Factory::getApplication()->input->get('id', 0);
+ $db = Factory::getDbo();
+
+ $query = $db->getQuery(true)
+ ->select('c.*, ct.name AS channeltype_name, ct.max_chars, ct.description AS channeltype_desc')
+ ->from($db->quoteName('#__autotweet_channels', 'c'))
+ ->leftJoin(
+ $db->quoteName('#__autotweet_channeltypes', 'ct')
+ . ' ON ' . $db->quoteName('c.channeltype_id')
+ . ' = ' . $db->quoteName('ct.id')
+ )
+ ->where($db->quoteName('c.id') . ' = ' . $id);
+
+ $db->setQuery($query);
+ $result = $db->loadObject();
+
+ if (!$result) {
+ $this->sendJsonError('Channel not found', 404);
+ return;
+ }
+
+ // Strip sensitive OAuth params
+ if (isset($result->params)) {
+ $params = json_decode($result->params, true);
+ if (is_array($params)) {
+ foreach (['access_token', 'access_secret', 'client_secret', 'api_secret', 'password'] as $key) {
+ if (isset($params[$key])) {
+ $params[$key] = '***';
+ }
+ }
+ $result->params = json_encode($params);
+ }
+ }
+
+ $this->sendJsonResponse($result);
+ }
+
+ /**
+ * GET /v1/perfectpublisher/posts
+ *
+ * @return void
+ */
+ public function getPosts(): void
+ {
+ $db = Factory::getDbo();
+ $app = Factory::getApplication();
+ $limit = (int) $app->input->get('limit', 20);
+ $offset = (int) $app->input->get('offset', 0);
+
+ $query = $db->getQuery(true)
+ ->select('p.*, c.name AS channel_name')
+ ->from($db->quoteName('#__autotweet_posts', 'p'))
+ ->leftJoin(
+ $db->quoteName('#__autotweet_channels', 'c')
+ . ' ON ' . $db->quoteName('p.channel_id')
+ . ' = ' . $db->quoteName('c.id')
+ )
+ ->order($db->quoteName('p.postdate') . ' DESC');
+
+ $pubstate = $app->input->get('pubstate', '');
+ if ($pubstate !== '') {
+ $query->where($db->quoteName('p.pubstate') . ' = ' . $db->quote($pubstate));
+ }
+
+ $channel = (int) $app->input->get('channel_id', 0);
+ if ($channel > 0) {
+ $query->where($db->quoteName('p.channel_id') . ' = ' . $channel);
+ }
+
+ $db->setQuery($query, $offset, $limit);
+
+ $this->sendJsonResponse($db->loadObjectList());
+ }
+
+ /**
+ * GET /v1/perfectpublisher/posts/:id
+ *
+ * @return void
+ */
+ public function getPost(): void
+ {
+ $id = (int) Factory::getApplication()->input->get('id', 0);
+ $db = Factory::getDbo();
+
+ $query = $db->getQuery(true)
+ ->select('p.*, c.name AS channel_name, ct.name AS channeltype_name')
+ ->from($db->quoteName('#__autotweet_posts', 'p'))
+ ->leftJoin(
+ $db->quoteName('#__autotweet_channels', 'c')
+ . ' ON ' . $db->quoteName('p.channel_id')
+ . ' = ' . $db->quoteName('c.id')
+ )
+ ->leftJoin(
+ $db->quoteName('#__autotweet_channeltypes', 'ct')
+ . ' ON ' . $db->quoteName('c.channeltype_id')
+ . ' = ' . $db->quoteName('ct.id')
+ )
+ ->where($db->quoteName('p.id') . ' = ' . $id);
+
+ $db->setQuery($query);
+ $result = $db->loadObject();
+
+ if (!$result) {
+ $this->sendJsonError('Post not found', 404);
+ return;
+ }
+
+ $this->sendJsonResponse($result);
+ }
+
+ /**
+ * GET /v1/perfectpublisher/requests
+ *
+ * @return void
+ */
+ public function getRequests(): void
+ {
+ $db = Factory::getDbo();
+ $app = Factory::getApplication();
+ $limit = (int) $app->input->get('limit', 20);
+ $offset = (int) $app->input->get('offset', 0);
+
+ $query = $db->getQuery(true)
+ ->select('*')
+ ->from($db->quoteName('#__autotweet_requests'))
+ ->order($db->quoteName('publish_up') . ' ASC');
+
+ $published = $app->input->get('published', null);
+ if ($published !== null) {
+ $query->where($db->quoteName('published') . ' = ' . (int) $published);
+ }
+
+ $db->setQuery($query, $offset, $limit);
+
+ $this->sendJsonResponse($db->loadObjectList());
+ }
+
+ /**
+ * POST /v1/perfectpublisher/requests
+ *
+ * Create a new publish request. Required fields: description.
+ * Optional: url, image_url, publish_up, plugin, priority.
+ *
+ * @return void
+ */
+ public function createRequest(): void
+ {
+ $app = Factory::getApplication();
+ $db = Factory::getDbo();
+ $data = json_decode($app->input->json->getRaw(), true);
+
+ if (empty($data['description'])) {
+ $this->sendJsonError('Field "description" is required', 400);
+ return;
+ }
+
+ $now = Factory::getDate()->toSql();
+ $user = Factory::getUser();
+
+ $row = (object) [
+ 'ref_id' => $data['ref_id'] ?? null,
+ 'plugin' => $data['plugin'] ?? 'manual-api',
+ 'priority' => (int) ($data['priority'] ?? 5),
+ 'publish_up' => $data['publish_up'] ?? $now,
+ 'description' => $data['description'],
+ 'typeinfo' => (int) ($data['typeinfo'] ?? 0),
+ 'url' => $data['url'] ?? null,
+ 'image_url' => $data['image_url'] ?? null,
+ 'created' => $now,
+ 'created_by' => $user->id,
+ 'params' => json_encode($data['params'] ?? []),
+ 'published' => (int) ($data['published'] ?? 1),
+ ];
+
+ $db->insertObject('#__autotweet_requests', $row, 'id');
+
+ $this->sendJsonResponse(
+ ['id' => $row->id, 'status' => 'created'],
+ 201
+ );
+ }
+
+ /**
+ * GET /v1/perfectpublisher/rules
+ *
+ * @return void
+ */
+ public function getRules(): void
+ {
+ $db = Factory::getDbo();
+
+ $query = $db->getQuery(true)
+ ->select('r.*, rt.name AS ruletype_name, rt.description AS ruletype_desc, c.name AS channel_name')
+ ->from($db->quoteName('#__autotweet_rules', 'r'))
+ ->leftJoin(
+ $db->quoteName('#__autotweet_ruletypes', 'rt')
+ . ' ON ' . $db->quoteName('r.ruletype_id')
+ . ' = ' . $db->quoteName('rt.id')
+ )
+ ->leftJoin(
+ $db->quoteName('#__autotweet_channels', 'c')
+ . ' ON ' . $db->quoteName('r.channel_id')
+ . ' = ' . $db->quoteName('c.id')
+ )
+ ->order($db->quoteName('r.ordering') . ' ASC');
+
+ $db->setQuery($query);
+
+ $this->sendJsonResponse($db->loadObjectList());
+ }
+
+ /**
+ * GET /v1/perfectpublisher/feeds
+ *
+ * @return void
+ */
+ public function getFeeds(): void
+ {
+ $db = Factory::getDbo();
+
+ $query = $db->getQuery(true)
+ ->select('*')
+ ->from($db->quoteName('#__autotweet_feeds'))
+ ->order($db->quoteName('ordering') . ' ASC');
+
+ $db->setQuery($query);
+
+ $this->sendJsonResponse($db->loadObjectList());
+ }
+
+ /**
+ * GET /v1/perfectpublisher/channeltypes
+ *
+ * @return void
+ */
+ public function getChannelTypes(): void
+ {
+ $db = Factory::getDbo();
+
+ $query = $db->getQuery(true)
+ ->select('*')
+ ->from($db->quoteName('#__autotweet_channeltypes'))
+ ->order($db->quoteName('id') . ' ASC');
+
+ $db->setQuery($query);
+
+ $this->sendJsonResponse($db->loadObjectList());
+ }
+
+ /**
+ * GET /v1/perfectpublisher/stats
+ *
+ * Dashboard statistics: post counts by status, channel counts, recent activity.
+ *
+ * @return void
+ */
+ public function getStats(): void
+ {
+ $db = Factory::getDbo();
+
+ // Posts by status
+ $db->setQuery(
+ $db->getQuery(true)
+ ->select('pubstate, COUNT(*) AS total')
+ ->from($db->quoteName('#__autotweet_posts'))
+ ->group($db->quoteName('pubstate'))
+ );
+ $postsByStatus = $db->loadObjectList('pubstate');
+
+ // Active channels
+ $db->setQuery(
+ $db->getQuery(true)
+ ->select('COUNT(*) AS total')
+ ->from($db->quoteName('#__autotweet_channels'))
+ ->where($db->quoteName('published') . ' = 1')
+ );
+ $activeChannels = (int) $db->loadResult();
+
+ // Pending requests
+ $db->setQuery(
+ $db->getQuery(true)
+ ->select('COUNT(*) AS total')
+ ->from($db->quoteName('#__autotweet_requests'))
+ ->where($db->quoteName('published') . ' = 1')
+ );
+ $pendingRequests = (int) $db->loadResult();
+
+ // Posts last 24h
+ $db->setQuery(
+ $db->getQuery(true)
+ ->select('COUNT(*) AS total')
+ ->from($db->quoteName('#__autotweet_posts'))
+ ->where($db->quoteName('postdate') . ' >= DATE_SUB(NOW(), INTERVAL 1 DAY)')
+ );
+ $posts24h = (int) $db->loadResult();
+
+ // Posts last 7d
+ $db->setQuery(
+ $db->getQuery(true)
+ ->select('COUNT(*) AS total')
+ ->from($db->quoteName('#__autotweet_posts'))
+ ->where($db->quoteName('postdate') . ' >= DATE_SUB(NOW(), INTERVAL 7 DAY)')
+ );
+ $posts7d = (int) $db->loadResult();
+
+ $this->sendJsonResponse([
+ 'posts_by_status' => $postsByStatus,
+ 'active_channels' => $activeChannels,
+ 'pending_requests' => $pendingRequests,
+ 'posts_24h' => $posts24h,
+ 'posts_7d' => $posts7d,
+ ]);
+ }
+
+ /**
+ * Send a JSON API response.
+ *
+ * @param mixed $data Response data
+ * @param int $status HTTP status code
+ *
+ * @return void
+ */
+ private function sendJsonResponse($data, int $status = 200): void
+ {
+ $app = Factory::getApplication();
+ $app->setHeader('Content-Type', 'application/json; charset=utf-8');
+ $app->setHeader('Status', (string) $status);
+ echo json_encode(['data' => $data], JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
+ $app->close();
+ }
+
+ /**
+ * Send a JSON error response.
+ *
+ * @param string $message Error message
+ * @param int $status HTTP status code
+ *
+ * @return void
+ */
+ private function sendJsonError(string $message, int $status = 400): void
+ {
+ $app = Factory::getApplication();
+ $app->setHeader('Content-Type', 'application/json; charset=utf-8');
+ $app->setHeader('Status', (string) $status);
+ echo json_encode(['error' => $message], JSON_UNESCAPED_SLASHES);
+ $app->close();
+ }
+}
diff --git a/src/pkg_mokowaas.xml b/src/pkg_mokowaas.xml
index e054e2f5..2d2e6e92 100644
--- a/src/pkg_mokowaas.xml
+++ b/src/pkg_mokowaas.xml
@@ -2,7 +2,7 @@
MokoWaaS
mokowaas
- 02.13.01
+ 02.13.02
2026-05-23
Moko Consulting
hello@mokoconsulting.tech
@@ -16,6 +16,7 @@
plg_system_mokowaas.zip
com_mokowaas.zip
plg_webservices_mokowaas.zip
+ plg_webservices_perfectpublisher.zip