Files
Jonathan Miller 430d6a79f4
Universal: Auto Version Bump / Version Bump (push) Successful in 4s
Update Server / Update Server (push) Successful in 12s
feat: complete service credential fields + fix Twitter OAuth 1.0a
Fix Twitter posting by replacing Bearer token (app-only, read-only)
with OAuth 1.0a HMAC-SHA1 signing using all 4 keys. Add credential
fields for 19 previously missing services and optional fields for
7 existing services. Add Developer Guide wiki page.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:25 -05:00

338 lines
11 KiB
Markdown

# Developer Guide
This guide covers building new service plugins for MokoJoomCross — from directory structure through testing.
## Plugin Directory Structure
Each service plugin lives in its own package under `src/packages/`:
```
plg_mokojoomcross_myservice/
├── myservice.xml ← Joomla manifest (type="plugin", group="mokojoomcross")
├── myservice.php ← Legacy loader stub (empty, required by Joomla)
├── services/
│ └── provider.php ← DI container: registers the Extension class
└── src/
└── Extension/
└── MyServiceService.php ← Main class: implements the interface
```
## MokoJoomCrossServiceInterface
Every service plugin **must** implement `MokoJoomCrossServiceInterface`. The interface defines 5 methods:
```php
namespace Joomla\Component\MokoJoomCross\Administrator\Service;
interface MokoJoomCrossServiceInterface
{
/**
* Unique identifier matching the service_type in service.xml.
* Must match exactly (e.g. 'mastodon', 'telegram').
*/
public function getServiceType(): string;
/**
* Human-readable display name (e.g. 'Mastodon', 'Telegram').
*/
public function getServiceName(): string;
/**
* Post content to the platform.
*
* @param string $message Rendered message text (already template-processed)
* @param array $media Array of media file paths (images)
* @param array $credentials Decrypted credential key-value pairs from the service record
* @param array $params Plugin params + service params merged
* @return array ['success' => bool, 'platform_post_id' => string, 'response' => array]
*/
public function publish(string $message, array $media, array $credentials, array $params): array;
/**
* Test whether the stored credentials are valid.
*
* @param array $credentials Decrypted credential key-value pairs
* @return array ['valid' => bool, 'message' => string, 'account_name' => string]
*/
public function validateCredentials(array $credentials): array;
/**
* Platform character limit (0 = unlimited).
*/
public function getMaxLength(): int;
/**
* Whether this service supports image/media attachments.
*/
public function supportsMedia(): bool;
}
```
## Step-by-Step: Creating a New Service Plugin
### 1. Create the manifest (`myservice.xml`)
```xml
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokojoomcross" method="upgrade">
<name>plg_mokojoomcross_myservice</name>
<author>Moko Consulting</author>
<version>1.0.0</version>
<description>MyService integration for MokoJoomCross</description>
<namespace path="src">Joomla\Plugin\MokoJoomCross\MyService</namespace>
<files>
<filename plugin="myservice">myservice.php</filename>
<folder>services</folder>
<folder>src</folder>
</files>
<!-- Optional: plugin-level params (e.g. default bot tokens) -->
<config>
<fields name="params">
<fieldset name="basic">
<field name="default_token" type="password"
label="Default Bot Token"
description="Pre-configured token for default mode" />
</fieldset>
</fields>
</config>
</extension>
```
### 2. Create the legacy stub (`myservice.php`)
```php
<?php
// Legacy stub — required by Joomla plugin loader. Intentionally empty.
```
### 3. Create the DI provider (`services/provider.php`)
```php
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Joomla\Plugin\MokoJoomCross\MyService\Extension\MyServiceService;
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 MyServiceService($dispatcher, (array) PluginHelper::getPlugin('mokojoomcross', 'myservice'));
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
```
### 4. Create the Extension class
```php
<?php
namespace Joomla\Plugin\MokoJoomCross\MyService\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface;
use Joomla\Event\SubscriberInterface;
class MyServiceService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface
{
public static function getSubscribedEvents(): array
{
return [
'onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices',
];
}
public function onMokoJoomCrossGetServices(&$services): void
{
$services[] = $this;
}
public function getServiceType(): string
{
return 'myservice';
}
public function getServiceName(): string
{
return 'My Service';
}
public function publish(string $message, array $media, array $credentials, array $params): array
{
// Your API integration here
// $credentials contains the decrypted values from service.xml fields
// e.g. $credentials['api_key'], $credentials['webhook_url']
return [
'success' => true,
'platform_post_id' => 'abc123',
'response' => ['status' => 'ok'],
];
}
public function validateCredentials(array $credentials): array
{
// Test the credentials against the platform API
return [
'valid' => true,
'message' => 'Connected',
'account_name' => 'MyAccount',
];
}
public function getMaxLength(): int
{
return 0; // 0 = no limit
}
public function supportsMedia(): bool
{
return false;
}
}
```
### 5. Add credential fields to `service.xml`
In `src/packages/com_mokojoomcross/forms/service.xml`, add your fields with `showon`:
```xml
<!-- ======== MY SERVICE ======== -->
<field
name="cred_myservice_api_key"
type="password"
label="COM_MOKOJOOMCROSS_CRED_MYSERVICE_KEY"
showon="service_type:myservice"
size="60"
/>
```
### 6. Add language strings to `com_mokojoomcross.ini`
```ini
COM_MOKOJOOMCROSS_CRED_MYSERVICE_KEY="API Key"
```
### 7. Add to the service_type dropdown (if not already listed)
In the `<field name="service_type">` list in `service.xml`, add:
```xml
<option value="myservice">My Service</option>
```
## How `showon` Credential Fields Work
Joomla's `showon` attribute controls field visibility client-side via JavaScript:
| Pattern | Meaning |
|---------|---------|
| `showon="service_type:telegram"` | Show when service type is Telegram |
| `showon="service_type:telegram[AND]cred_mode:custom"` | Show when Telegram AND custom mode |
| `showon="service_type:webhook[AND]cred_webhook_auth_type:bearer,basic"` | Show when webhook AND auth is bearer or basic |
Fields are hidden/shown without page reloads. The form data for hidden fields is still submitted but ignored by the component.
## Dispatch Pipeline
The cross-posting flow works like this:
1. **Article published** → System plugin (`plg_system_mokojoomcross`) catches `onContentAfterSave`
2. **Queue creation** → For each enabled service, a `#__mokojoomcross_posts` row is created with status `queued`
3. **Queue processing** → Either the Scheduled Task or page-load fallback picks up queued posts
4. **Service dispatch**`QueueProcessor` fires `onMokoJoomCrossGetServices` event in the `mokojoomcross` plugin group
5. **Plugin response** → Each registered service plugin adds itself to the `$services` array
6. **Matching** → The processor finds the plugin whose `getServiceType()` matches the service record's `service_type`
7. **Publishing**`publish()` is called with the rendered message, media paths, decrypted credentials, and params
8. **Result** → The post record is updated with `posted`/`failed` status and the platform response
## Default Bot Mode
Some services (Telegram, Discord, Slack, Teams, Facebook, Threads) support a **default mode** where pre-configured MokoWaaS credentials are used. This is controlled by:
1. The `cred_mode` field in `service.xml` (shown for services listed in its `showon`)
2. Plugin-level params in the plugin manifest (`<config>` section) that store default tokens
3. The service plugin's `publish()` method checks `$credentials['mode']`:
- `'default'` → use plugin params (`$this->params->get('default_token')`)
- `'custom'` → use the per-service credentials from `$credentials`
## OAuth Integration
For services requiring OAuth (Facebook, LinkedIn, Twitter, Pinterest, etc.):
1. **OAuthHelper** (`src/packages/com_mokojoomcross/src/Helper/OAuthHelper.php`) handles:
- Authorization URL generation with state parameter
- Code-to-token exchange
- Token storage back to the service record's credentials
2. **OauthController** provides two endpoints:
- `task=oauth.authorize` → redirects to the platform's auth page
- `task=oauth.callback` → handles the redirect, exchanges code for token
3. Plugin params store the OAuth Client ID and Secret (set in Extensions → Plugins)
4. In `edit.php`, services listed in `$oauthServices` get a "Connect to {Service}" button
## Testing Your Plugin
1. **Syntax check**: `php -l src/packages/plg_mokojoomcross_myservice/src/Extension/MyServiceService.php`
2. **Install**: Include the plugin in `pkg_mokojoomcross.xml` or install the plugin ZIP standalone
3. **Enable**: Extensions → Plugins → search "mokojoomcross myservice" → Enable
4. **Add service**: Components → MokoJoomCross → Services → New → select your service type
5. **Verify fields**: Confirm your credential fields appear when your service type is selected
6. **Test post**: Publish an article and check the Post Queue for results
## Example: Building a "Fediverse" Service
Imagine building a service for a Mastodon-compatible platform:
```php
public function publish(string $message, array $media, array $credentials, array $params): array
{
$instanceUrl = rtrim($credentials['instance_url'] ?? '', '/');
$token = $credentials['access_token'] ?? '';
$ch = curl_init($instanceUrl . '/api/v1/statuses');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['status' => $message]),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $token,
],
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 && !empty($data['id'])) {
return ['success' => true, 'platform_post_id' => $data['id'], 'response' => $data];
}
return ['success' => false, 'platform_post_id' => '', 'response' => $data];
}
```
This pattern — curl to API, check response code, return structured result — is the same for every service plugin. The only differences are the API endpoint, authentication method, and payload format.