Compare commits

...

36 Commits

Author SHA1 Message Date
gitea-actions[bot] e92a963088 chore: promote changelog [Unreleased] → [01.06.00] 2026-06-23 17:09:39 +00:00
gitea-actions[bot] f3a8246e34 chore(release): build 01.06.00 [skip ci]
Publish to Composer / Publish Package (release) Failing after 5s
2026-06-23 17:09:35 +00:00
jmiller c9f50e452b Fix: duplicate license warning, wiki folder cleanup 2026-06-23 17:07:59 +00:00
Jonathan Miller c820d015e7 Merge remote-tracking branch 'origin/main' into dev
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: Auto Version Bump / Version Bump (push) Successful in 11s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 27s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 23s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 3s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 7s
Generic: Repo Health / Access control (pull_request) Successful in 3s
Universal: PR Check / Secret Scan (pull_request) Successful in 8s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 16s
Universal: PR Check / Validate PR (pull_request) Failing after 45s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 3m46s
2026-06-23 12:07:01 -05:00
gitea-actions[bot] 78cbd1f370 chore(version): pre-release bump to 01.05.02-dev [skip ci]
Publish to Composer / Publish Package (release) Failing after 38s
2026-06-23 17:01:51 +00:00
Jonathan Miller 70d2bab52d fix: remove duplicate license warning from system plugin
Universal: Auto Version Bump / Version Bump (push) Successful in 8s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 18s
The license key warning was firing twice:
1. Package install script (source/script.php) - has direct "Enter Key" button
2. System plugin (onAfterRoute) - every admin session, less actionable

Removed #2 entirely. The install script version is better UX (button links
directly to update site edit page) and only fires during install/update.
Also eliminates the uninstall bug where the warning fired during removal.
2026-06-23 11:59:56 -05:00
gitea-actions[bot] 166a6366f8 chore(version): pre-release bump to 01.05.01-dev [skip ci]
Publish to Composer / Publish Package (release) Failing after 33s
2026-06-23 16:45:37 +00:00
Jonathan Miller ac8a64c4c1 fix: license key warning no longer shows during uninstall
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 12s
Check if com_mokosuitecross is still in #__extensions before warning
about missing license key. Prevents the warning from firing during
package uninstall when the update site row is already deleted.
2026-06-23 11:45:05 -05:00
Jonathan Miller 2ee8a5e286 chore: remove wiki/ folder -- content migrated to Gitea wiki feature 2026-06-23 11:45:04 -05:00
jmiller 9d2620faea chore: sync issue-branch.yml from Template-Joomla [skip ci] 2026-06-23 16:23:09 +00:00
gitea-actions[bot] c79e8bed73 chore: promote changelog [Unreleased] → [01.05.00] 2026-06-23 16:08:01 +00:00
gitea-actions[bot] 4ddf02c7af chore(release): build 01.05.00 [skip ci]
Publish to Composer / Publish Package (release) Failing after 41s
2026-06-23 16:07:52 +00:00
jmiller 0fb95ced3f Release v01.05.00: Instagram, YouTube, Share Content, delete, templates, competitive roadmap 2026-06-23 16:07:34 +00:00
gitea-actions[bot] 62a8e9bd99 chore(version): pre-release bump to 01.04.12-dev [skip ci]
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 3s
Publish to Composer / Publish Package (release) Failing after 35s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 32s
2026-06-23 16:07:11 +00:00
Jonathan Miller de0b588be0 fix: resolve broken namespace placeholders in 7 plugin XML manifests
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 7s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 7s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 24s
Universal: PR Check / Validate PR (pull_request) Failing after 48s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 49s
2026-06-23 11:06:34 -05:00
gitea-actions[bot] 4650f9ba46 chore(version): pre-release bump to 01.04.11-dev [skip ci]
Publish to Composer / Publish Package (release) Failing after 5s
2026-06-23 15:46:32 +00:00
Jonathan Miller be58716391 feat: ntfy default server ntfy.mokoconsulting.tech with config params
Universal: Auto Version Bump / Version Bump (push) Successful in 10s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 12s
- Default server URL changed from ntfy.sh to ntfy.mokoconsulting.tech
- Added plugin config fieldset with default_server_url and default_topic
- Server URL reads from plugin params, overridable per-service in credentials
- Updated language strings
2026-06-23 10:46:10 -05:00
gitea-actions[bot] 03870dce33 chore(version): pre-release bump to 01.04.10-dev [skip ci]
Publish to Composer / Publish Package (release) Failing after 34s
2026-06-23 15:38:31 +00:00
Jonathan Miller 5fee5d7810 feat: Mastodon polls/visibility/scheduling + Bluesky threads (closes #152, #158)
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 12s
Mastodon (#152):
- Visibility levels (public/unlisted/private/direct) from params or config
- Content warnings via spoiler_text param
- Scheduled posts via scheduled_at param
- Poll creation (options, expires_in, multiple) mutually exclusive with media
- Language tag support
- Fixed broken ${CLASS_NAME} namespace in mastodon.xml

Bluesky (#158):
- Auto-thread: split long messages at sentence boundaries into reply chains
- External link card embed (app.bsky.embed.external) with article title/description
- Link card attached to last post in thread
- Dispatcher now passes article_title to service plugins

Closes #152, closes #158
2026-06-23 10:38:04 -05:00
gitea-actions[bot] c13c2a372e chore(version): pre-release bump to 01.04.09-dev [skip ci]
Publish to Composer / Publish Package (release) Failing after 7s
2026-06-23 14:08:10 +00:00
Jonathan Miller 09074e3c00 docs: update README with 38 platforms, new features, Instagram + YouTube
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 22s
2026-06-23 09:07:11 -05:00
gitea-actions[bot] 9bfbf36090 chore(version): pre-release bump to 01.04.08-dev [skip ci]
Publish to Composer / Publish Package (release) Failing after 3s
2026-06-23 13:48:28 +00:00
Jonathan Miller 7e5ff12d03 feat: UTM auto-tagging and caption rotation (closes #154, closes #155)
Universal: Auto Version Bump / Version Bump (push) Successful in 7s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 9s
UTM tracking (#154):
- New config fieldset with utm_source, utm_medium, utm_campaign, utm_content
- {platform} token in UTM values auto-replaced with service type
- {url} gets UTM params appended when enabled
- {url_raw} placeholder for clean URLs without UTM

Caption rotation (#155):
- {random:option1|option2|option3} placeholder in templates
- Picks one option at random per post render
- Great for evergreen re-shares to vary messaging

Closes #154, closes #155
2026-06-23 08:48:08 -05:00
gitea-actions[bot] 42f7a09bb3 chore(version): pre-release bump to 01.04.07-dev [skip ci]
Publish to Composer / Publish Package (release) Failing after 6s
2026-06-23 13:25:07 +00:00
Jonathan Miller 6ad536c0ef chore: remove Makefile -- builds handled by CI
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 21s
2026-06-23 08:24:33 -05:00
gitea-actions[bot] eb1b112a93 chore(version): pre-release bump to 01.04.06-dev [skip ci]
Publish to Composer / Publish Package (release) Failing after 37s
2026-06-23 13:21:41 +00:00
Jonathan Miller 4918879eec feat: delete/unpublish from remote platforms (closes #131)
Universal: Auto Version Bump / Version Bump (push) Successful in 8s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 18s
New MokoSuiteCrossDeleteInterface (separate from main interface to avoid
breaking all 38 plugins). Plugins that support deletion implement both.

deletePost() implemented for 7 platforms:
- Twitter: DELETE /2/tweets/{id} with OAuth 1.0a
- Mastodon: DELETE /api/v1/statuses/{id}
- Bluesky: com.atproto.repo.deleteRecord
- Facebook: DELETE /{post_id} via Graph API
- LinkedIn: DELETE /v2/ugcPosts/{urn}
- Telegram: POST /deleteMessage
- Discord: DELETE webhook /messages/{id}

Infrastructure:
- CrossPostDispatcher::deleteFromPlatforms() finds posted entries and
  calls deletePost() on plugins that implement the delete interface
- Content plugin hooks onContentChangeState for unpublish/trash
- New component config: 'Delete from Platforms on Unpublish'
- Post status 'deleted' added to schema

Closes #131
2026-06-23 08:21:18 -05:00
gitea-actions[bot] add973771b chore(version): pre-release bump to 01.04.05-dev [skip ci]
Publish to Composer / Publish Package (release) Failing after 7s
2026-06-23 13:07:08 +00:00
Jonathan Miller 5753c307c6 feat: Mailchimp template support + responsive email wrapper (closes #142)
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 19s
- Add template_id and template_section config fields
- When template_id set, inject content into Mailchimp template sections
- When empty, wrap HTML in responsive email skeleton (600px table layout)
- Fix broken ${CLASS_NAME} namespace placeholder in mailchimp.xml
- New language strings for template fieldset

Closes #142
2026-06-23 08:06:34 -05:00
gitea-actions[bot] bfe4432c78 chore(version): pre-release bump to 01.04.04-dev [skip ci]
Publish to Composer / Publish Package (release) Failing after 8s
2026-06-23 12:58:31 +00:00
Jonathan Miller 3c1f3a2421 feat: add per-article Share Content panel with platform-specific placeholders
Universal: Auto Version Bump / Version Bump (push) Successful in 8s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 13s
New article editor fieldset 'Share Content' with fields:
- Social Media Text ({social}) - Facebook, LinkedIn, Threads, Mastodon
- Short Text ({short}) - Twitter/X (280), Bluesky (300)
- Chat Text ({chat}) - Telegram, Discord, Slack, Teams
- Email Subject ({email_subject}) + Email Body ({email_body}) - Mailchimp, SendGrid, Brevo
- Share Image picker (intro/fulltext/custom/none)

All placeholders fall back gracefully to introtext/title if empty.
Default templates updated to use platform-specific placeholders.
2026-06-23 07:58:08 -05:00
gitea-actions[bot] 9017b06c7d chore(version): pre-release bump to 01.04.03-dev [skip ci]
Publish to Composer / Publish Package (release) Failing after 26s
2026-06-23 12:39:07 +00:00
Jonathan Miller 1cb5c77bec feat: add Instagram + YouTube plugins, re-apply deep scan fixes (#140, #141)
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 11s
New plugins:
- plg_mokosuitecross_instagram: Meta Content Publishing API (2-step flow)
- plg_mokosuitecross_youtube: YouTube Data API v3 channel bulletins

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

Closes #140, closes #141
2026-06-23 07:38:41 -05:00
jmiller c2b88e9a94 chore: sync auto-release.yml from Template-Joomla [skip ci] 2026-06-22 00:35:32 +00:00
jmiller 845ed4b53d chore: remove unused Makefile - builds handled by CI auto-release 2026-06-21 23:55:27 +00:00
gitea-actions[bot] dc5feaa9aa chore(version): pre-release bump to 01.04.02-dev [skip ci]
Publish to Composer / Publish Package (release) Failing after 48s
2026-06-21 23:40:48 +00:00
108 changed files with 1651 additions and 1493 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<display-name>Package - MokoSuiteCross</display-name>
<org>MokoConsulting</org>
<description>Cross-posting Joomla content to social media, email marketing, and chat platforms</description>
<version>01.04.01</version>
<version>01.06.00</version>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
+9
View File
@@ -30,6 +30,15 @@ on:
types: [opened, closed]
branches:
- main
paths-ignore:
- '.mokogitea/workflows/**'
- '*.md'
- 'wiki/**'
- '.editorconfig'
- '.gitignore'
- '.gitattributes'
- '.gitmessage'
- 'LICENSE'
workflow_dispatch:
inputs:
action:
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation
# VERSION: 01.00.00
# VERSION: 01.06.00
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
+33 -230
View File
@@ -1,244 +1,47 @@
# Changelog
## [Unreleased]
## [01.04.01] --- 2026-06-21
## [01.06.00] --- 2026-06-23
## [01.06.00] --- 2026-06-23
## [01.04.01] --- 2026-06-21
## [01.05.00] --- 2026-06-23
## [01.05.00] --- 2026-06-23
## [01.04.00] --- 2026-06-21
### Fixed
- **Package manifest**: Added missing `plg_system_mokosuitecross_events` and `plg_system_mokosuitecross_gallery` to `pkg_mokosuitecross.xml` — these system plugins were not installed with the package
- **Cleanup**: Removed old `src/` directory (pre-rename cruft with `mokojoomcross` files)
## [01.03.00] --- 2026-06-21
<!-- VERSION: 01.04.01 -->
All notable changes to MokoSuiteCross will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [01.02.00] --- 2026-06-21
### Added
- **Instagram plugin**: Cross-post to Instagram via Meta Content Publishing API (2-step container flow)
- **YouTube plugin**: Cross-post to YouTube via Data API v3 channel bulletins
- **Share Content panel**: Per-article editor panel with platform-specific share text fields
- **New placeholders**: {social}, {short}, {chat}, {email_subject}, {email_body} for platform-optimized templates
- **Share image control**: Choose intro image, fulltext image, custom image, or no image per article
- **Mailchimp templates**: Support Mailchimp saved templates with section injection, plus responsive email wrapper fallback
- **Delete from platforms**: New MokoSuiteCrossDeleteInterface for removing cross-posted content from remote platforms
- **Delete support**: Twitter, Mastodon, Bluesky, Facebook, LinkedIn, Telegram, Discord (7 of 38 plugins)
- **Auto-delete on unpublish**: Component config option to delete from platforms when articles are unpublished or trashed
- **UTM auto-tagging**: Append utm_source, utm_medium, utm_campaign to shared URLs with {platform} token support
- **Caption rotation**: {random:opt1|opt2|opt3} placeholder picks a random option per post
- **{url_raw} placeholder**: Clean article URL without UTM parameters
- **Mastodon enhancements**: Visibility levels, content warnings, scheduled posts, polls, language tags
- **Bluesky threads**: Auto-split long messages into reply chains at sentence boundaries
- **Bluesky link cards**: External link card embeds with article title and description
- **Ntfy default server**: Default server changed to ntfy.mokoconsulting.tech with configurable plugin params
### Changed
- **Rebrand complete**: All 1,151 language key references renamed from `MOKOJOOMCROSS` to `MOKOSUITECROSS` across .ini, .xml, and .php files
- **Event names**: All Joomla events renamed from `onMokoJoomCross*` to `onMokoSuiteCross*`
- **Telegram default bot**: Updated from @MokoWaaSBot to @mokosuite_bot with obfuscated embedded token
- **Branding**: All `MokoWaaS` references updated to `MokoSuite` across codebase, wiki, and docs
- **Wiki**: Reorganized into folder structure (getting-started/, user-guide/, services/, developer/)
- **README**: Updated with all 36 implemented service plugins and current feature list
- **PR workflow**: Added README/CHANGELOG diff check — blocks PRs that modify source without updating CHANGELOG
- **Default templates**: Updated to use platform-specific placeholders (social/short/chat/email) with graceful fallback
### Fixed
- **SendGrid**: Removed duplicate `curl_setopt_array` with undefined `$token` variable in `publish()`
- **Reddit**: Removed duplicate `curl_setopt_array` with undefined `$token` variable in `publish()`
- **TikTok**: Removed duplicate `curl_setopt_array` in `publish()`
- **Pinterest**: Removed duplicate `curl_setopt_array` in `publish()`
- **Telegram**: Added missing `<config>` section to plugin XML for parse_mode and disable_preview settings
- **Mailchimp**: Fixed broken namespace placeholder in XML manifest
- **ConvertKit**: Removed duplicate curl_setopt_array with undefined $token
- **Brevo**: Removed duplicate curl_setopt_array with undefined $token and wrong auth header
- **Constant Contact**: Removed duplicate curl_setopt_array
- **Mailchimp**: Fixed campaign creation checking HTTP 200 instead of 2xx range
- **Medium**: Fixed getUserId() returning array instead of string on error
- **Bluesky**: Replaced md5() with hash('sha256', ...) for cache key
- **ServiceController**: Exception details no longer exposed to client
- **License warning**: Removed duplicate from system plugin -- install script already shows it with direct edit link
### Fixed (previous)
- **C-1 OauthController**: Added CSRF nonce validation to OAuth callback — session-based nonce is generated during `authorize()`, embedded in the state parameter, and verified in `callback()` to prevent CSRF attacks
- **C-2 DispatchController**: Added POST method enforcement — rejects non-POST requests with 405 status
- **C-5 ServiceModel**: Credential form fields (`cred_*`) are now collected into the `credentials` JSON column on save, and expanded back into individual fields on load — previously these fields were silently discarded
- **H-1 Event pattern**: Fixed Joomla 5 SubscriberInterface incompatibility where `onMokoSuiteCrossGetServices` by-reference pattern silently lost all service plugins — dispatchers now read plugin instances from Event ArrayAccess indices after dispatch
- **H-4 ServiceTable**: Added `check()` method with alias generation, required field validation (title, service_type), timestamp management, and JSON defaults for credentials/params
- **H-9 WebhookService**: Fixed credential key mismatch — `publish()` and `validateCredentials()` now use keys matching the service.xml form fields (`url`, `method`, `auth_type`, `bearer_token`, `basic_username`, `basic_password`, `content_type`) and properly apply Bearer/Basic auth headers
- **M-4 ServiceIconHelper**: Escaped `$extraClass` parameter in `renderIcon()` with `htmlspecialchars()` to prevent XSS
- **M-5 Content plugin**: Fixed double-escaped HTML in cross-post history panel — uses `setFieldAttribute()` to inject history HTML into the note field description after XML load, avoiding XML attribute encoding
- **Content plugin**: Fixed `onContentBeforeDisplay` signature for Joomla 5/6 — now accepts `BeforeDisplayEvent` object instead of individual parameters
- **QueueProcessor**: Replaced read-then-write DB lock with MySQL advisory locks (`GET_LOCK`/`RELEASE_LOCK`) to eliminate race condition
- **Twitter/X**: Replaced Bearer token auth with OAuth 1.0a (HMAC-SHA1) — Bearer tokens are app-only and cannot create tweets
- **service.xml**: Fixed missing closing `</field>` tag on webhook method field
- **Views**: Added missing `Toolbar` and `Route` imports in Logs, Posts, Services, Template, Templates HtmlView files
- **13 service plugins**: Fixed broken `publish()` methods that had literal placeholder URLs instead of using credential values — ActivityPub, Blogger, Ghost, Google Business, Hashnode, Matrix, Medium, Nostr, RSS Feed, Threads, Tumblr, WhatsApp, WordPress
- **Ghost**: Proper JWT auth from `{id}:{secret}` admin API key format
- **WordPress**: Correct Basic Auth (not Bearer) with Application Passwords
- **Medium**: 2-step flow — fetch user ID via /v1/me, then post
- **Matrix**: PUT with transaction ID for idempotent message sending
- **Hashnode**: GraphQL mutation with proper query structure
- **Threads**: 2-step container creation + publish flow
- **WhatsApp**: Meta Cloud API with messaging_product payload
- **Nostr**: Stub with clear "not yet implemented" message (requires WebSocket)
- **RSS Feed**: Local service — no external API, always succeeds
## [01.04.01] --- 2026-06-21
### Added
- **ServiceIconHelper**: Centralised icon mapping for all 34 service types — replaces per-template icon arrays with `ServiceIconHelper::getIcon()` / `::renderIcon()`
- **Service Stats drill-down**: New `servicestats` view with per-service analytics — post counts, success rate, daily trend chart, recent posts table, and top articles list
- **Dashboard service links**: Service breakdown table rows now link to the per-service stats view with service type icons
- **Posts list icons**: Service type column in the posts list now shows the service icon
- **Category routing rules**: New `#__mokosuitecross_category_rules` table to whitelist services per Joomla category — if rules exist for a category, only those services receive posts; no rules = all services (backward compatible)
- **CrossPostDispatcher**: Category rule filtering integrated before per-article service filter in the dispatch loop
- **Template editor**: Live character counter below template body textarea with platform-aware limits (green/yellow/red badges)
- **Template editor**: Added `{tags}`, `{hashtags}`, and `{field:xxx}` rows to the placeholder reference table
- **Content plugin**: Cross-post history panel in article editor showing last 10 posts with status badges, service names, timestamps, and error messages
- **Config**: New "Category Rules" fieldset with explanatory note about the feature
- **CrossPostDispatcher**: New static helper (`com_mokosuitecross/Helper/CrossPostDispatcher`) centralising dispatch logic for reuse by all source plugins
- **Content plugin**: Added `onContentAfterSave` and `onContentChangeState` handlers with Joomla 5/6 event compatibility, dispatching via `CrossPostDispatcher`
- **plg_system_mokosuitecross_events**: New source plugin for MokoSuiteCalendar — cross-posts calendar events when published
- **plg_system_mokosuitecross_gallery**: New source plugin for MokoSuiteGallery — cross-posts galleries and images when published
- **Credential fields**: Added fields for 19 previously missing services (Pinterest, Tumblr, TikTok, Nostr, ActivityPub, Brevo, ConvertKit, Constant Contact, Hashnode, Blogger, Google Business, RSS Feed config)
- **Twitter**: Access Token and Access Token Secret fields for OAuth 1.0a
- **LinkedIn**: Refresh token field for automatic token renewal
- **Bluesky**: PDS URL field for self-hosted instances
- **Discord**: Username and avatar URL override fields
- **Mailchimp**: From name and from email fields
- **SendGrid**: From email and from name fields
- **Reddit**: Account password field for script-type OAuth
- **WordPress**: Default post status selector (draft/publish)
- **Dev.to**: Organization ID field
- **Ghost**: Default post status selector (draft/published)
- **Webhook**: Auth type selector (none/bearer/basic), auth token field, content type selector (JSON/form)
- **RSS Feed**: Feed title and max items config fields
- **OAuth services**: Added Pinterest, Tumblr, TikTok, Constant Contact, Blogger, Google Business to OAuth authorize flow
- **Developer Guide**: Comprehensive wiki page for building new service plugins
- **Help articles**: 42 KB articles on mokoconsulting.tech (overview, installation, 34 per-service guides, templates, queue, troubleshooting)
- **Service help link**: Per-service "Setup Guide" button in service edit sidebar links to the matching KB article
- **Evergreen re-sharing**: Articles can be marked as evergreen for automatic recurring cross-posts on a configurable interval (default 30 days)
- **Post edit form**: Full CRUD for queue posts — edit message, reschedule, change status, re-queue failed posts
- **Manual post creator**: New button in Post Queue toolbar to create manual cross-posts with article/service selection, custom message, and optional scheduling
- **Scheduled posts**: Calendar picker for scheduling posts to specific date/time; scheduled_at shown in queue list
- **Dashboard trend chart**: Chart.js line chart showing daily posted vs failed counts between stat cards and service breakdown
- **Dashboard date range filter**: Period selector (7/30/90 days, all time) filters service breakdown, top articles, and trend chart
- **Hashtag placeholders**: `{tags}` (comma-separated) and `{hashtags}` (#-prefixed space-separated) template placeholders from article tags
- **Posts service filter**: SQL-driven service dropdown filter in posts list, plus search filter by article title or message content
- **CSV export**: "Export CSV" toolbar button on posts list to download filtered post data as CSV
- **WordPress canonical URL**: WordPress cross-posts now include an "Originally published at" source link appended to content with the Joomla article URL
- **REST API dispatch endpoint**: `POST /api/v1/mokosuitecross/dispatch` — trigger cross-posts for an article via API with optional service filtering, duplicate guard, and template rendering
### Added (original)
#### Core Engine
- Cross-posting engine dispatches articles to service plugins on publish
- System plugin hooks `onContentAfterSave` and `onContentChangeState`
- Duplicate guard prevents re-posting to services that already received an article
- Message template rendering with 8 placeholders: `{title}`, `{url}`, `{introtext}`, `{fulltext}`, `{image}`, `{category}`, `{author}`, `{date}`
- Custom `mokosuitecross` plugin group for extensible service architecture
- `MokoSuiteCrossServiceInterface` contract for all service plugins
#### Admin Component (5 views)
- **Dashboard** — summary cards, posts-by-service analytics with success rates, top cross-posted articles, recent activity feed, PP Pro migration banner, page-load processing warning
- **Post Queue** — list with color-coded status badges, error messages, retry counts, platform post IDs, article/service columns, date filters
- **Services** — CRUD with service type selector (34 platforms organized by category), default/custom mode badges, publish toggle, credential editor
- **Templates** — CRUD for message templates, per-platform assignment, placeholder reference panel, template body preview
- **Activity Logs** — list with level badges (info/warning/error), service column, context data, level and search filters
#### Queue Processing (3 methods)
- Joomla Scheduled Task plugin (`plg_task_mokosuitecross`) — preferred, processes 20 posts per run
- Page-load fallback via system plugin `onAfterRender` — configurable throttle interval, backend/frontend/both
- Shared `QueueProcessor` helper with DB lock to prevent concurrent execution
- Failed post retry with configurable max retries and exponential delay
- Scheduled post support (`scheduled_at` column)
- Automatic log cleanup based on configurable retention period
#### Per-Article Controls
- "Cross-Posting" fieldset injected into article editor via `onContentPrepareForm`
- Skip cross-posting toggle per article
- Service selection checkboxes (unchecked = post to all enabled services)
#### OAuth 2.0
- `OAuthHelper` with authorization URL generation, code-to-token exchange, token storage
- Twitter PKCE flow support
- `OauthController` with authorize and callback endpoints
- Reads client ID/secret from service plugin params
#### Perfect Publisher Pro Migration
- Reads `#__autotweet_channels` table with per-platform credential mapping
- Fallback extraction from component params when channel table missing
- Maps Facebook, Twitter, LinkedIn, Telegram, Discord, Slack, Mastodon
- Creates services in disabled state for manual verification
- One-click migration from dashboard
#### Service Plugins (34 platforms)
**Social Media (12)**
- Facebook / Meta — Graph API v19.0, default MokoSuite app mode, page feed posting
- X / Twitter — API v2, OAuth 2.0 Bearer Token, 280 char limit
- LinkedIn — Share API v2, organization + personal profile, 3000 char limit
- Mastodon — API v1, multi-instance, hashtags, 500 char limit
- Bluesky — AT Protocol, session auth, app passwords, 300 char limit
- Threads (Meta) — Threads Publishing API, default app mode, 500 char limit
- Pinterest — Pins API v5, board selection, image-focused
- Reddit — OAuth2 link submission, subreddit selection
- Tumblr — API v2, link/text posts, OAuth 1.0a
- TikTok — Content Posting API, photo slideshows
- Nostr — NIP-01 event publishing, configurable relays
- ActivityPub — generic Fediverse (Pleroma, Akkoma, Misskey, Pixelfed)
**Chat / Messaging (8)**
- Telegram — Bot API, default @mokosuite_bot + custom bot, HTML/Markdown, 4096 chars
- Discord — Webhooks, default MokoSuite webhook mode, embeds, 2000 chars
- Slack — Incoming Webhooks, default MokoSuite webhook mode, Block Kit
- Microsoft Teams — Incoming Webhooks, default mode, Adaptive Cards
- Google Chat — Webhook API, card formatting
- WhatsApp Business — Meta Cloud API, template + free-form messages
- Matrix / Element — Client-Server API, self-hosted homeserver support
- Ntfy — Push notifications, priority levels, action buttons
**Email / Newsletter (5)**
- Mailchimp — Campaigns API, audience selection, send/draft modes
- SendGrid — Marketing Campaigns API v3, Single Send creation
- Brevo (Sendinblue) — API v3, campaign creation
- ConvertKit — API v3, broadcast creation
- Constant Contact — API v3, campaign creation
**Publishing / Blogging (6)**
- Medium — Publishing API, full HTML, canonical URL, tags
- WordPress — REST API v2, Application Passwords, category mapping
- Dev.to — Forem API, markdown, series support
- Ghost — Admin API v5, JWT auth, full HTML
- Hashnode — GraphQL API, cover image, tags
- Google Blogger — Blogger API v3, labels from categories
**Business (1)**
- Google Business Profile — API v1, local posts (UPDATE/EVENT/OFFER)
**Universal (2)**
- Generic Webhook — POST/PUT to any URL, JSON/form body, custom headers (IFTTT, Zapier, n8n, Make)
- RSS Feed — dedicated cross-post feed generation
#### Plugin Configuration
- Telegram: default bot token, parse mode, link preview toggle
- Facebook: default page access token, default page ID
- Discord: default webhook URL, embed color
- Slack: default webhook URL
- LinkedIn: OAuth client ID/secret, redirect URI
- Mastodon: default instance URL, visibility, hashtags
- Bluesky: default PDS URL, auto link cards
- Mailchimp: default sender name/email, auto-send toggle
- Microsoft Teams: default webhook URL
- Threads: default webhook URL
#### Infrastructure
- 7 CI/CD workflows: CI, auto-release, pre-release, auto-bump, update-server, cascade-dev, issue-branch
- Joomla update server (`updates.xml`) with development channel
- WebServices REST API plugin with CRUD routes for posts and services
- Database: 4 tables (services, posts, templates, logs) with default templates
- Package installer with auto-enable for core + task + service plugins
- 9 wiki documentation pages
- Windows Terminal profile in Joomla dropdown
## [01.01.00] - 2026-06-19
### Added
- Initial package structure with component, system plugin, content plugin, and webservices plugin
- Admin component with dashboard, post queue, services management, and activity logs
- System plugin triggering cross-post on article publish via `onContentAfterSave`
- Content plugin adding cross-post controls to article editor
- WebServices API plugin with REST endpoints for posts and services
- Custom `mokosuitecross` plugin group for extensible service architecture
- Service plugins: Facebook, X/Twitter, LinkedIn, Mastodon, Bluesky, Mailchimp, Telegram, Discord, Slack
- Database tables: services, posts, templates, logs
- Perfect Publisher Pro migration tool in installer script
- Message template system with per-platform placeholders
- Post queue with scheduled posting, retry logic, and delivery tracking
## [01.04.01] --- 2026-06-21
-203
View File
@@ -1,203 +0,0 @@
# Makefile for Joomla Extensions
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# MokoSuiteCross — Cross-posting Joomla content to social media, email marketing, and chat platforms
# ==============================================================================
# CONFIGURATION - Customize these for your extension
# ==============================================================================
# Extension Configuration
EXTENSION_NAME := mokosuitecross
EXTENSION_TYPE := package
# Options: module, plugin, component, package, template
EXTENSION_VERSION := 1.0.0
# Module Configuration (for modules only)
MODULE_TYPE := site
# Options: site, admin
# Plugin Configuration (for plugins only)
PLUGIN_GROUP := system
# Options: system, content, user, authentication, etc.
# Directories
SRC_DIR := src
BUILD_DIR := build
DIST_DIR := dist
DOCS_DIR := docs
# Joomla Installation (for local testing - customize paths)
JOOMLA_ROOT := /var/www/html/joomla
JOOMLA_VERSION := 4
# Tools
PHP := php
COMPOSER := composer
NPM := npm
PHPCS := vendor/bin/phpcs
PHPCBF := vendor/bin/phpcbf
PHPUNIT := vendor/bin/phpunit
ZIP := zip
# Coding Standards
PHPCS_STANDARD := Joomla
# Colors for output
COLOR_RESET := \033[0m
COLOR_GREEN := \033[32m
COLOR_YELLOW := \033[33m
COLOR_BLUE := \033[34m
COLOR_RED := \033[31m
# ==============================================================================
# TARGETS
# ==============================================================================
.PHONY: help
help: ## Show this help message
@echo "$(COLOR_BLUE)╔════════════════════════════════════════════════════════════╗$(COLOR_RESET)"
@echo "$(COLOR_BLUE)║ Joomla Extension Makefile ║$(COLOR_RESET)"
@echo "$(COLOR_BLUE)╚════════════════════════════════════════════════════════════╝$(COLOR_RESET)"
@echo ""
@echo "Extension: $(EXTENSION_NAME) ($(EXTENSION_TYPE)) v$(EXTENSION_VERSION)"
@echo ""
@echo "$(COLOR_GREEN)Available targets:$(COLOR_RESET)"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " $(COLOR_BLUE)%-20s$(COLOR_RESET) %s\n", $$1, $$2}'
@echo ""
.PHONY: install-deps
install-deps: ## Install all dependencies (Composer + npm)
@echo "$(COLOR_BLUE)Installing dependencies...$(COLOR_RESET)"
@if [ -f "composer.json" ]; then \
$(COMPOSER) install; \
echo "$(COLOR_GREEN)✓ Composer dependencies installed$(COLOR_RESET)"; \
fi
.PHONY: lint
lint: ## Run PHP linter (syntax check)
@echo "$(COLOR_BLUE)Running PHP linter...$(COLOR_RESET)"
@find . -name "*.php" ! -path "./vendor/*" ! -path "./node_modules/*" ! -path "./$(BUILD_DIR)/*" \
-exec $(PHP) -l {} \; | grep -v "No syntax errors" || true
@echo "$(COLOR_GREEN)✓ PHP linting complete$(COLOR_RESET)"
.PHONY: phpcs
phpcs: ## Run PHP CodeSniffer (Joomla standards)
@echo "$(COLOR_BLUE)Running PHP CodeSniffer...$(COLOR_RESET)"
@if [ -f "$(PHPCS)" ]; then \
$(PHPCS) --standard=$(PHPCS_STANDARD) --extensions=php --ignore=vendor,node_modules,$(BUILD_DIR) .; \
else \
echo "$(COLOR_YELLOW)⚠ PHP CodeSniffer not installed. Run: make install-deps$(COLOR_RESET)"; \
fi
.PHONY: validate
validate: lint phpcs ## Run all validation checks
@echo "$(COLOR_GREEN)✓ All validation checks passed$(COLOR_RESET)"
.PHONY: clean
clean: ## Clean build artifacts
@echo "$(COLOR_BLUE)Cleaning build artifacts...$(COLOR_RESET)"
@rm -rf $(BUILD_DIR) $(DIST_DIR)
@echo "$(COLOR_GREEN)✓ Build artifacts cleaned$(COLOR_RESET)"
MOKO_PLATFORM ?= $(or $(wildcard ../moko-platform),$(wildcard $(HOME)/moko-platform),$(wildcard /opt/moko-platform))
MINIFY_SCRIPT := $(MOKO_PLATFORM)/build/minify.js
.PHONY: minify
minify: ## Minify CSS/JS assets
@echo "Minifying assets..."
@if [ -f "$(MINIFY_SCRIPT)" ]; then \
node "$(MINIFY_SCRIPT)" $(SRC_DIR); \
elif [ -f "scripts/minify.js" ]; then \
node scripts/minify.js; \
else \
echo "No minify script found"; \
fi
.PHONY: build
build: clean validate minify ## Build extension package
@echo "$(COLOR_BLUE)Building Joomla extension package...$(COLOR_RESET)"
@mkdir -p $(DIST_DIR) $(BUILD_DIR)
# Determine package prefix based on extension type
@case "$(EXTENSION_TYPE)" in \
module) \
PACKAGE_PREFIX="mod_$(EXTENSION_NAME)"; \
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
;; \
plugin) \
PACKAGE_PREFIX="plg_$(PLUGIN_GROUP)_$(EXTENSION_NAME)"; \
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
;; \
component) \
PACKAGE_PREFIX="com_$(EXTENSION_NAME)"; \
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
;; \
package) \
PACKAGE_PREFIX="pkg_$(EXTENSION_NAME)"; \
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
;; \
template) \
PACKAGE_PREFIX="tpl_$(EXTENSION_NAME)"; \
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
;; \
*) \
echo "$(COLOR_RED)✗ Unknown extension type: $(EXTENSION_TYPE)$(COLOR_RESET)"; \
exit 1; \
;; \
esac; \
\
mkdir -p "$$BUILD_TARGET"; \
\
echo "Building $$PACKAGE_PREFIX..."; \
\
rsync -av --progress \
--exclude='$(BUILD_DIR)' \
--exclude='$(DIST_DIR)' \
--exclude='.git*' \
--exclude='vendor/' \
--exclude='node_modules/' \
--exclude='tests/' \
--exclude='Makefile' \
--exclude='composer.json' \
--exclude='composer.lock' \
--exclude='package.json' \
--exclude='package-lock.json' \
--exclude='phpunit.xml' \
--exclude='*.md' \
--exclude='.editorconfig' \
. "$$BUILD_TARGET/"; \
\
cd $(BUILD_DIR) && $(ZIP) -r "../$(DIST_DIR)/$${PACKAGE_PREFIX}-$(EXTENSION_VERSION).zip" "$${PACKAGE_PREFIX}"; \
\
echo "$(COLOR_GREEN)✓ Package created: $(DIST_DIR)/$${PACKAGE_PREFIX}-$(EXTENSION_VERSION).zip$(COLOR_RESET)"
.PHONY: package
package: build ## Alias for build
@echo "$(COLOR_GREEN)✓ Package ready for distribution$(COLOR_RESET)"
.PHONY: release
release: validate build ## Create a release (validate + build)
@echo "$(COLOR_GREEN)✓ Release package ready$(COLOR_RESET)"
.PHONY: version
version: ## Display version information
@echo "$(COLOR_BLUE)Extension Information:$(COLOR_RESET)"
@echo " Name: $(EXTENSION_NAME)"
@echo " Type: $(EXTENSION_TYPE)"
@echo " Version: $(EXTENSION_VERSION)"
.PHONY: security-check
security-check: ## Run security checks on dependencies
@echo "$(COLOR_BLUE)Running security checks...$(COLOR_RESET)"
@if [ -f "composer.json" ]; then \
$(COMPOSER) audit || echo "$(COLOR_YELLOW)⚠ Vulnerabilities found$(COLOR_RESET)"; \
fi
.PHONY: all
all: install-deps validate build ## Run complete build pipeline
@echo "$(COLOR_GREEN)✓ Complete build pipeline finished$(COLOR_RESET)"
# Default target
.DEFAULT_GOAL := help
+10 -3
View File
@@ -1,6 +1,6 @@
# MokoSuiteCross
<!-- VERSION: 01.04.01 -->
<!-- VERSION: 01.06.00 -->
Cross-posting Joomla content to social media, email marketing, and chat platforms for Joomla 5/6.
@@ -14,20 +14,27 @@ MokoSuiteCross automatically publishes your Joomla articles to multiple platform
- **Plugin-based services** — Each platform is a separate plugin; install only what you need
- **Default bot mode** — Pre-configured bots for Telegram (@mokosuite_bot), Discord, and Slack — just add your channel
- **Post queue** — Scheduled posting, retry on failure, detailed delivery logs
- **Message templates** — Customize post format per platform with placeholders ({title}, {url}, {intro}, {image}, {tags}, {field:xxx})
- **Message templates** — Customize post format per platform with placeholders ({title}, {url}, {social}, {short}, {chat}, {email_subject}, {email_body}, {field:xxx})
- **Share Content panel** — Per-article fields for platform-optimized text (social, short, chat, email) with image picker
- **Caption rotation** — {random:opt1|opt2|opt3} placeholder for varying evergreen re-shares
- **UTM tracking** — Auto-append UTM parameters to shared links with {platform} token
- **Delete from platforms** — Remove cross-posted content when articles are unpublished/trashed (7 platforms)
- **Post history** — Track what was posted where, with platform response data
- **Evergreen re-sharing** — Automatically re-share articles on a configurable interval
- **Category routing** — Route articles to specific services by Joomla category
- **Mailchimp templates** — Use saved Mailchimp templates with section injection, or built-in responsive email wrapper
- **Migration** — Import settings from Perfect Publisher Pro
- **REST API** — WebServices plugin for headless/external integration
### Supported Platforms (36)
### Supported Platforms (38)
#### Social Media
| Platform | Plugin | Status |
|----------|--------|--------|
| Facebook / Meta | `plg_mokosuitecross_facebook` | Implemented |
| X / Twitter | `plg_mokosuitecross_twitter` | Implemented |
| Instagram | `plg_mokosuitecross_instagram` | Implemented |
| YouTube | `plg_mokosuitecross_youtube` | Implemented |
| LinkedIn | `plg_mokosuitecross_linkedin` | Implemented |
| Mastodon | `plg_mokosuitecross_mastodon` | Implemented |
| Bluesky | `plg_mokosuitecross_bluesky` | Implemented |
@@ -24,6 +24,17 @@
<option value="0">JNO</option>
</field>
<field
name="delete_on_unpublish"
type="radio"
label="COM_MOKOSUITECROSS_CONFIG_DELETE_ON_UNPUBLISH"
description="COM_MOKOSUITECROSS_CONFIG_DELETE_ON_UNPUBLISH_DESC"
default="0"
class="btn-group">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="retry_max"
type="number"
@@ -64,6 +75,51 @@
/>
</fieldset>
<fieldset name="utm" label="COM_MOKOSUITECROSS_CONFIG_UTM">
<field
name="utm_enabled"
type="radio"
label="COM_MOKOSUITECROSS_CONFIG_UTM_ENABLED"
description="COM_MOKOSUITECROSS_CONFIG_UTM_ENABLED_DESC"
default="0"
class="btn-group">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="utm_source"
type="text"
label="COM_MOKOSUITECROSS_CONFIG_UTM_SOURCE"
description="COM_MOKOSUITECROSS_CONFIG_UTM_SOURCE_DESC"
default="{platform}"
showon="utm_enabled:1"
/>
<field
name="utm_medium"
type="text"
label="COM_MOKOSUITECROSS_CONFIG_UTM_MEDIUM"
description="COM_MOKOSUITECROSS_CONFIG_UTM_MEDIUM_DESC"
default="social"
showon="utm_enabled:1"
/>
<field
name="utm_campaign"
type="text"
label="COM_MOKOSUITECROSS_CONFIG_UTM_CAMPAIGN"
description="COM_MOKOSUITECROSS_CONFIG_UTM_CAMPAIGN_DESC"
default="mokosuitecross"
showon="utm_enabled:1"
/>
<field
name="utm_content"
type="text"
label="COM_MOKOSUITECROSS_CONFIG_UTM_CONTENT"
description="COM_MOKOSUITECROSS_CONFIG_UTM_CONTENT_DESC"
hint="Optional"
showon="utm_enabled:1"
/>
</fieldset>
<fieldset name="evergreen" label="COM_MOKOSUITECROSS_CONFIG_EVERGREEN">
<field
name="evergreen_enabled"
@@ -476,6 +476,19 @@ COM_MOKOSUITECROSS_DASHBOARD_QUEUE_DEPTH_WARNING_TITLE="Large queue backlog"
COM_MOKOSUITECROSS_DASHBOARD_QUEUE_DEPTH_WARNING="There are %d posts waiting in the queue. Please verify that the Joomla Task Scheduler is running and the MokoSuiteCross scheduled task is enabled in System → Scheduled Tasks."
; First-Publish-Only
COM_MOKOSUITECROSS_CONFIG_DELETE_ON_UNPUBLISH="Delete from Platforms on Unpublish"
COM_MOKOSUITECROSS_CONFIG_DELETE_ON_UNPUBLISH_DESC="When an article is unpublished or trashed, automatically delete the cross-posted content from remote platforms (where supported)."
COM_MOKOSUITECROSS_CONFIG_UTM="UTM Tracking"
COM_MOKOSUITECROSS_CONFIG_UTM_ENABLED="Enable UTM Parameters"
COM_MOKOSUITECROSS_CONFIG_UTM_ENABLED_DESC="Append UTM tracking parameters to article URLs in cross-posted content for Google Analytics tracking."
COM_MOKOSUITECROSS_CONFIG_UTM_SOURCE="UTM Source"
COM_MOKOSUITECROSS_CONFIG_UTM_SOURCE_DESC="Value for utm_source. Use {platform} to auto-insert the service type (e.g. facebook, twitter, telegram)."
COM_MOKOSUITECROSS_CONFIG_UTM_MEDIUM="UTM Medium"
COM_MOKOSUITECROSS_CONFIG_UTM_MEDIUM_DESC="Value for utm_medium. Default: social."
COM_MOKOSUITECROSS_CONFIG_UTM_CAMPAIGN="UTM Campaign"
COM_MOKOSUITECROSS_CONFIG_UTM_CAMPAIGN_DESC="Value for utm_campaign. Default: mokosuitecross."
COM_MOKOSUITECROSS_CONFIG_UTM_CONTENT="UTM Content"
COM_MOKOSUITECROSS_CONFIG_UTM_CONTENT_DESC="Optional value for utm_content. Leave empty to omit."
COM_MOKOSUITECROSS_CONFIG_FIRST_PUBLISH_ONLY="First Publish Only"
COM_MOKOSUITECROSS_CONFIG_FIRST_PUBLISH_ONLY_DESC="When enabled, articles are only cross-posted on their first save as published. Subsequent edits to already-published articles will not trigger new cross-posts."
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="component" method="upgrade">
<name>com_mokosuitecross</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -25,7 +25,7 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitecross_posts` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`article_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'FK to #__content.id',
`service_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'FK to #__mokosuitecross_services.id',
`status` varchar(20) NOT NULL DEFAULT 'queued' COMMENT 'queued, posting, posted, failed, scheduled',
`status` varchar(20) NOT NULL DEFAULT 'queued' COMMENT 'queued, posting, posted, failed, scheduled, deleted',
`message` text NOT NULL COMMENT 'Rendered message sent to platform',
`platform_post_id` varchar(255) NOT NULL DEFAULT '' COMMENT 'Post ID returned by platform',
`platform_response` text NOT NULL COMMENT 'JSON — full API response from platform',
@@ -74,25 +74,27 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitecross_logs` (
-- Insert default templates
INSERT INTO `#__mokosuitecross_templates` (`service_type`, `title`, `template_body`, `published`, `ordering`, `created`) VALUES
('default', 'Default Template', '{title}\n\n{introtext}\n\n{url}', 1, 1, NOW()),
('twitter', 'Twitter/X Default', '{title}\n\n{url}', 1, 2, NOW()),
('mastodon', 'Mastodon Default', '{title}\n\n{introtext}\n\n{url}\n\n#Joomla', 1, 3, NOW()),
('mailchimp', 'Mailchimp Default', '<h1>{title}</h1>\n<p>{introtext}</p>\n<p><a href=\"{url}\">Read more</a></p>', 1, 4, NOW()),
('telegram', 'Telegram Default', '<b>{title}</b>\n\n{introtext}\n\n<a href=\"{url}\">Read more</a>', 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()),
('twitter', 'Twitter/X Default', '{short}\n\n{url}', 1, 2, NOW()),
('mastodon', 'Mastodon Default', '{social}\n\n{url}\n\n{hashtags}', 1, 3, NOW()),
('mailchimp', 'Mailchimp Default', '<h1>{email_subject}</h1>\n{email_body}\n<p><a href=\"{url}\">Read more</a></p>', 1, 4, NOW()),
('telegram', 'Telegram Default', '<b>{title}</b>\n\n{chat}\n\n<a href=\"{url}\">Read more</a>', 1, 5, NOW()),
('discord', 'Discord Default', '**{title}**\n\n{chat}\n\n{url}', 1, 6, NOW()),
('slack', 'Slack Default', '*{title}*\n\n{chat}\n\n{url}', 1, 7, NOW()),
('facebook', 'Facebook Default', '{social}\n\n{url}', 1, 8, NOW()),
('linkedin', 'LinkedIn Default', '{social}\n\n{url}\n\n{hashtags}', 1, 9, NOW()),
('bluesky', 'Bluesky Default', '{short}\n\n{url}', 1, 10, NOW()),
('threads', 'Threads Default', '{social}\n\n{url}', 1, 11, NOW()),
('teams', 'Teams Default', '**{title}**\n\n{chat}\n\n[Read more]({url})', 1, 12, NOW()),
('medium', 'Medium Default', '{title}\n\n{social}\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', '<h1>{title}</h1>\n<p>{introtext}</p>\n<p><a href=\"{url}\">Read more</a></p>', 1, 16, NOW()),
('brevo', 'Brevo Default', '<h1>{title}</h1>\n<p>{introtext}</p>\n<p><a href=\"{url}\">Read more</a></p>', 1, 17, NOW()),
('ntfy', 'Ntfy Default', '{title}: {introtext}', 1, 18, NOW()),
('sendgrid', 'SendGrid Default', '<h1>{email_subject}</h1>\n{email_body}\n<p><a href=\"{url}\">Read more</a></p>', 1, 16, NOW()),
('brevo', 'Brevo Default', '<h1>{email_subject}</h1>\n{email_body}\n<p><a href=\"{url}\">Read more</a></p>', 1, 17, NOW()),
('ntfy', 'Ntfy Default', '{title}: {short}', 1, 18, NOW()),
('reddit', 'Reddit Default', '{title}', 1, 19, NOW()),
('pinterest', 'Pinterest Default', '{title} - {introtext}', 1, 20, NOW());
('pinterest', 'Pinterest Default', '{title} - {social}', 1, 20, NOW()),
('instagram', 'Instagram Default', '{social}\n\n{hashtags}', 1, 21, NOW()),
('youtube', 'YouTube Default', '{social}\n\n{url}', 1, 22, NOW());
CREATE TABLE IF NOT EXISTS `#__mokosuitecross_category_rules` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -96,7 +96,7 @@ class ServiceController extends FormController
$app->mimeType = 'application/json';
$app->setHeader('Content-Type', 'application/json; charset=utf-8');
echo new JsonResponse($e);
echo new JsonResponse(['error' => $e->getMessage()]);
}
$app->close();
@@ -17,6 +17,7 @@ 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\MokoSuiteCrossDeleteInterface;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
/**
@@ -243,7 +244,21 @@ class CrossPostDispatcher
$params = json_decode($service->params ?: '{}', true) ?: [];
if (!empty($articleUrl)) {
$params['_article_url'] = $articleUrl;
$params['article_url'] = $articleUrl;
}
// Pass article title for platforms that need it (e.g. Bluesky link cards)
$db2 = Factory::getDbo();
$postRow = $db2->setQuery(
$db2->getQuery(true)->select('article_id')->from('#__mokosuitecross_posts')->where('id = ' . $postId)
)->loadObject();
if ($postRow && $postRow->article_id) {
$articleTitle = $db2->setQuery(
$db2->getQuery(true)->select('title')->from('#__content')->where('id = ' . (int) $postRow->article_id)
)->loadResult();
if ($articleTitle) {
$params['article_title'] = $articleTitle;
}
}
// Lifecycle event: before post
@@ -383,11 +398,33 @@ class CrossPostDispatcher
$authorName = $db->loadResult() ?: '';
}
// Resolve share image from article attribs
$attribs = json_decode($article->attribs ?? '{}', true) ?: [];
$imageMode = $attribs['mokosuitecross_share_image'] ?? 'intro';
$images = json_decode($article->images ?? '{}');
$introImage = '';
$images = json_decode($article->images ?? '{}');
if (!empty($images->image_intro)) {
$introImage = Uri::root() . ltrim($images->image_intro, '/');
switch ($imageMode) {
case 'fulltext':
if (!empty($images->image_fulltext)) {
$introImage = Uri::root() . ltrim($images->image_fulltext, '/');
}
break;
case 'custom':
$customImg = $attribs['mokosuitecross_custom_image'] ?? '';
if (!empty($customImg)) {
$introImage = Uri::root() . ltrim($customImg, '/');
}
break;
case 'none':
$introImage = '';
break;
case 'intro':
default:
if (!empty($images->image_intro)) {
$introImage = Uri::root() . ltrim($images->image_intro, '/');
}
break;
}
$tagNames = [];
@@ -410,17 +447,54 @@ class CrossPostDispatcher
return '#' . preg_replace('/\s+/', '', $tag);
}, $tagNames));
// Per-article share text (from article editor Share Content panel)
$socialText = $attribs['mokosuitecross_social_text'] ?? '';
$shortText = $attribs['mokosuitecross_short_text'] ?? '';
$chatText = $attribs['mokosuitecross_chat_text'] ?? '';
$emailSubject = $attribs['mokosuitecross_email_subject'] ?? '';
$emailBody = $attribs['mokosuitecross_email_body'] ?? '';
$introStripped = strip_tags(mb_substr($article->introtext ?? '', 0, 280));
$titleText = $article->title ?? '';
// UTM auto-tagging (#154)
$componentParams = ComponentHelper::getParams('com_mokosuitecross');
$urlRaw = $url;
if ($componentParams->get('utm_enabled', 0)) {
$utmParams = [
'utm_source' => $componentParams->get('utm_source', '{platform}'),
'utm_medium' => $componentParams->get('utm_medium', 'social'),
'utm_campaign' => $componentParams->get('utm_campaign', 'mokosuitecross'),
];
$utmContent = $componentParams->get('utm_content', '');
if (!empty($utmContent)) {
$utmParams['utm_content'] = $utmContent;
}
$separator = (strpos($url, '?') !== false) ? '&' : '?';
$url = $url . $separator . http_build_query($utmParams);
}
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,
'{title}' => $titleText,
'{introtext}' => $introStripped,
'{fulltext}' => strip_tags(mb_substr($article->fulltext ?? '', 0, 500)),
'{url}' => $url,
'{url_raw}' => $urlRaw,
'{image}' => $introImage,
'{category}' => $categoryName,
'{author}' => $authorName,
'{date}' => Factory::getDate($article->publish_up ?? 'now')->format('Y-m-d'),
'{tags}' => $tagsComma,
'{hashtags}' => $hashtags,
// Platform-specific share content (falls back to introtext/title if empty)
'{social}' => !empty($socialText) ? $socialText : $introStripped,
'{short}' => !empty($shortText) ? $shortText : mb_substr($titleText, 0, 250),
'{chat}' => !empty($chatText) ? $chatText : $introStripped,
'{email_subject}' => !empty($emailSubject) ? $emailSubject : $titleText,
'{email_body}' => !empty($emailBody) ? $emailBody : ($article->fulltext ?? $article->introtext ?? ''),
];
}
@@ -459,6 +533,15 @@ class CrossPostDispatcher
$message = str_replace(array_keys($replacements), array_values($replacements), $template);
// Resolve {platform} token in UTM params (replaced with service_type)
$message = str_replace('{platform}', $service->service_type, $message);
// Resolve caption rotation: {random:option1|option2|option3} (#155)
$message = preg_replace_callback('/\{random:([^}]+)\}/', function ($matches) {
$options = explode('|', $matches[1]);
return $options[array_rand($options)];
}, $message);
// Resolve custom field placeholders: {field:field_name}
$message = preg_replace_callback('/\{field:([a-zA-Z0-9_-]+)\}/', function ($matches) use ($db, $article) {
$fieldName = $matches[1];
@@ -478,6 +561,82 @@ class CrossPostDispatcher
/**
* Write an entry to the activity log.
*/
/**
* Delete cross-posted content from remote platforms for a given article.
*
* Finds all posts with status 'posted' for this article, resolves the
* service plugin, and calls deletePost() if the plugin supports it.
*
* @param int $articleId The Joomla article ID
*/
public static function deleteFromPlatforms(int $articleId): void
{
$db = Factory::getDbo();
// Find all successfully posted entries for this article
$query = $db->getQuery(true)
->select('p.*, s.service_type, s.credentials')
->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.article_id') . ' = ' . $articleId)
->where($db->quoteName('p.status') . ' = ' . $db->quote('posted'))
->where($db->quoteName('p.platform_post_id') . ' != ' . $db->quote(''));
$db->setQuery($query);
$posts = $db->loadObjectList();
if (empty($posts)) {
return;
}
// Load service plugins
PluginHelper::importPlugin('mokosuitecross');
$plugins = [];
Factory::getApplication()->triggerEvent('onMokoSuiteCrossGetServices', [&$plugins]);
$pluginMap = [];
foreach ($plugins as $plugin) {
$pluginMap[$plugin->getServiceType()] = $plugin;
}
foreach ($posts as $post) {
$plugin = $pluginMap[$post->service_type] ?? null;
if (!$plugin instanceof MokoSuiteCrossDeleteInterface) {
self::log($db, $post->id, $post->service_id, 'info',
'Delete not supported for ' . $post->service_type);
continue;
}
$credentials = json_decode($post->credentials, true) ?: [];
try {
$result = $plugin->deletePost($post->platform_post_id, $credentials);
if (!empty($result['success'])) {
// Mark as deleted
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokosuitecross_posts'))
->set($db->quoteName('status') . ' = ' . $db->quote('deleted'))
->where($db->quoteName('id') . ' = ' . (int) $post->id)
);
$db->execute();
self::log($db, $post->id, $post->service_id, 'info',
'Deleted from ' . $post->service_type . ': ' . ($result['message'] ?? 'OK'));
} else {
self::log($db, $post->id, $post->service_id, 'warning',
'Delete failed on ' . $post->service_type . ': ' . ($result['message'] ?? 'Unknown error'));
}
} catch (\Throwable $e) {
self::log($db, $post->id, $post->service_id, 'error',
'Delete exception on ' . $post->service_type . ': ' . $e->getMessage());
}
}
}
private static function log($db, ?int $postId, ?int $serviceId, string $level, string $message): void
{
$log = (object) [
@@ -0,0 +1,35 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Component\MokoSuiteCross\Administrator\Service;
defined('_JEXEC') or die;
/**
* Optional interface for service plugins that support deleting posts
* from the remote platform.
*
* Plugins that implement this can be invoked when a Joomla article
* is unpublished or trashed, or when a user manually requests deletion
* from the Post Queue view.
*/
interface MokoSuiteCrossDeleteInterface
{
/**
* Delete a previously published post from the remote platform.
*
* @param string $platformPostId The platform-specific post ID
* @param array $credentials Decrypted credentials for this service
*
* @return array ['success' => bool, 'message' => string]
*/
public function deletePost(string $platformPostId, array $credentials): array;
}
@@ -11,3 +11,23 @@ PLG_CONTENT_MOKOSUITECROSS_EVERGREEN_DESC="Automatically re-share this article o
PLG_CONTENT_MOKOSUITECROSS_EVERGREEN_INTERVAL="Re-share Interval (days)"
PLG_CONTENT_MOKOSUITECROSS_EVERGREEN_INTERVAL_DESC="How many days to wait between automatic re-shares. Default: 30 days."
PLG_CONTENT_MOKOSUITECROSS_HISTORY="Cross-Post History"
PLG_CONTENT_MOKOSUITECROSS_FIELDSET_SHARE="Share Content"
PLG_CONTENT_MOKOSUITECROSS_SOCIAL_TEXT="Social Media Text"
PLG_CONTENT_MOKOSUITECROSS_SOCIAL_TEXT_DESC="Custom text for Facebook, LinkedIn, Threads. Use {social} placeholder in templates. Falls back to intro text if empty."
PLG_CONTENT_MOKOSUITECROSS_SHORT_TEXT="Short Text (Twitter/Bluesky)"
PLG_CONTENT_MOKOSUITECROSS_SHORT_TEXT_DESC="Optimized text for character-limited platforms (Twitter 280, Bluesky 300). Use {short} placeholder. Falls back to truncated title."
PLG_CONTENT_MOKOSUITECROSS_CHAT_TEXT="Chat Text"
PLG_CONTENT_MOKOSUITECROSS_CHAT_TEXT_DESC="Custom text for Telegram, Discord, Slack, Teams. Use {chat} placeholder. Falls back to intro text."
PLG_CONTENT_MOKOSUITECROSS_EMAIL_SUBJECT="Email Subject"
PLG_CONTENT_MOKOSUITECROSS_EMAIL_SUBJECT_DESC="Subject line for Mailchimp, SendGrid, Brevo campaigns. Use {email_subject} placeholder. Falls back to article title."
PLG_CONTENT_MOKOSUITECROSS_EMAIL_BODY="Email Body"
PLG_CONTENT_MOKOSUITECROSS_EMAIL_BODY_DESC="HTML content for email campaigns. Use {email_body} placeholder. Falls back to full article text."
PLG_CONTENT_MOKOSUITECROSS_SHARE_IMAGE="Share Image"
PLG_CONTENT_MOKOSUITECROSS_SHARE_IMAGE_DESC="Which image to use when cross-posting this article."
PLG_CONTENT_MOKOSUITECROSS_SHARE_IMAGE_INTRO="Intro Image"
PLG_CONTENT_MOKOSUITECROSS_SHARE_IMAGE_FULLTEXT="Full Text Image"
PLG_CONTENT_MOKOSUITECROSS_SHARE_IMAGE_CUSTOM="Custom Image"
PLG_CONTENT_MOKOSUITECROSS_SHARE_IMAGE_NONE="No Image"
PLG_CONTENT_MOKOSUITECROSS_CUSTOM_IMAGE="Custom Share Image"
PLG_CONTENT_MOKOSUITECROSS_CUSTOM_IMAGE_DESC="Select an image from the media manager to use for cross-posting."
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="content" method="upgrade">
<name>Content - MokoSuiteCross</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -140,6 +140,71 @@ class MokoSuiteCrossContent extends CMSPlugin implements SubscriberInterface
showon="mokosuitecross_skip:0[AND]mokosuitecross_evergreen:1"
/>
</fieldset>
<fieldset name="mokosuitecross_share" label="PLG_CONTENT_MOKOSUITECROSS_FIELDSET_SHARE">
<field
name="mokosuitecross_social_text"
type="textarea"
label="PLG_CONTENT_MOKOSUITECROSS_SOCIAL_TEXT"
description="PLG_CONTENT_MOKOSUITECROSS_SOCIAL_TEXT_DESC"
rows="3"
hint="Optimized for Facebook, LinkedIn, Threads. Leave empty to use intro text."
filter="string"
/>
<field
name="mokosuitecross_short_text"
type="textarea"
label="PLG_CONTENT_MOKOSUITECROSS_SHORT_TEXT"
description="PLG_CONTENT_MOKOSUITECROSS_SHORT_TEXT_DESC"
rows="2"
hint="For Twitter (280), Bluesky (300). Leave empty for auto-truncated title."
filter="string"
/>
<field
name="mokosuitecross_chat_text"
type="textarea"
label="PLG_CONTENT_MOKOSUITECROSS_CHAT_TEXT"
description="PLG_CONTENT_MOKOSUITECROSS_CHAT_TEXT_DESC"
rows="3"
hint="For Telegram, Discord, Slack, Teams. Leave empty to use intro text."
filter="string"
/>
<field
name="mokosuitecross_email_subject"
type="text"
label="PLG_CONTENT_MOKOSUITECROSS_EMAIL_SUBJECT"
description="PLG_CONTENT_MOKOSUITECROSS_EMAIL_SUBJECT_DESC"
hint="For Mailchimp, SendGrid, Brevo. Leave empty to use article title."
filter="string"
/>
<field
name="mokosuitecross_email_body"
type="editor"
label="PLG_CONTENT_MOKOSUITECROSS_EMAIL_BODY"
description="PLG_CONTENT_MOKOSUITECROSS_EMAIL_BODY_DESC"
filter="safehtml"
buttons="true"
hide="readmore,pagebreak"
height="200"
/>
<field
name="mokosuitecross_share_image"
type="list"
label="PLG_CONTENT_MOKOSUITECROSS_SHARE_IMAGE"
description="PLG_CONTENT_MOKOSUITECROSS_SHARE_IMAGE_DESC"
default="intro">
<option value="intro">PLG_CONTENT_MOKOSUITECROSS_SHARE_IMAGE_INTRO</option>
<option value="fulltext">PLG_CONTENT_MOKOSUITECROSS_SHARE_IMAGE_FULLTEXT</option>
<option value="custom">PLG_CONTENT_MOKOSUITECROSS_SHARE_IMAGE_CUSTOM</option>
<option value="none">PLG_CONTENT_MOKOSUITECROSS_SHARE_IMAGE_NONE</option>
</field>
<field
name="mokosuitecross_custom_image"
type="media"
label="PLG_CONTENT_MOKOSUITECROSS_CUSTOM_IMAGE"
description="PLG_CONTENT_MOKOSUITECROSS_CUSTOM_IMAGE_DESC"
showon="mokosuitecross_share_image:custom"
/>
</fieldset>
</fields>
</form>
XML;
@@ -325,12 +390,28 @@ XML;
$value = func_get_arg(2);
}
if ($context !== 'com_content.article' || $value !== 1) {
if ($context !== 'com_content.article') {
return;
}
$params = ComponentHelper::getParams('com_mokosuitecross');
// Unpublish/trash: delete from platforms if configured
if ($value === 0 || $value === -2) {
if ($params->get('delete_on_unpublish', 0)) {
foreach ($pks as $pk) {
CrossPostDispatcher::deleteFromPlatforms((int) $pk);
}
}
return;
}
// Publish: auto-post if configured
if ($value !== 1) {
return;
}
if (!$params->get('auto_post_on_publish', 1)) {
return;
}
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - ActivityPub (Fediverse)</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Google Blogger</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Bluesky</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -10,7 +10,7 @@
<license>GPL-3.0-or-later</license>
<description>PLG_MOKOSUITECROSS_BLUESKY_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\MokoSuiteCross${CLASS_NAME}</namespace>
<namespace path="src">Joomla\Plugin\MokoSuiteCross\Bluesky</namespace>
<files>
<filename plugin="bluesky">bluesky.php</filename>
@@ -14,6 +14,7 @@ namespace Joomla\Plugin\MokoSuiteCross\Bluesky\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossDeleteInterface;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
use Joomla\Event\SubscriberInterface;
@@ -29,7 +30,7 @@ use Joomla\Event\SubscriberInterface;
* "pds_url": "https://bsky.social" // Optional, defaults to bsky.social
* }
*/
class BlueskyService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface
class BlueskyService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface, MokoSuiteCrossDeleteInterface
{
public static function getSubscribedEvents(): array
{
@@ -65,49 +66,95 @@ class BlueskyService extends CMSPlugin implements SubscriberInterface, MokoSuite
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Authentication failed']];
}
// Create post
$postData = json_encode([
'repo' => $authData['did'],
'collection' => 'app.bsky.feed.post',
'record' => [
// Build external link card embed if URL is in params
$embed = null;
$articleUrl = $params['article_url'] ?? '';
if (!empty($articleUrl)) {
$embed = [
'$type' => 'app.bsky.embed.external',
'external' => [
'uri' => $articleUrl,
'title' => $params['article_title'] ?? '',
'description' => mb_substr(strip_tags($message), 0, 200),
],
];
}
// Auto-thread: split long messages at sentence boundaries
$chunks = $this->splitIntoThread($message, 300);
if (count($chunks) === 1) {
// Single post
return $this->createPost($pds, $authData, $chunks[0], $embed);
}
// Thread: post each chunk as a reply to the previous
$rootUri = null;
$rootCid = null;
$parentUri = null;
$parentCid = null;
$lastResult = [];
foreach ($chunks as $i => $chunk) {
$record = [
'$type' => 'app.bsky.feed.post',
'text' => mb_substr($message, 0, 300),
'text' => $chunk,
'createdAt' => gmdate('Y-m-d\TH:i:s\Z'),
],
]);
];
$ch = curl_init($pds . '/xrpc/com.atproto.repo.createRecord');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postData,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $authData['accessJwt'], 'Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS | CURLPROTO_HTTP,
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS | CURLPROTO_HTTP,
]);
// Add reply reference for thread posts after the first
if ($rootUri !== null) {
$record['reply'] = [
'root' => ['uri' => $rootUri, 'cid' => $rootCid],
'parent' => ['uri' => $parentUri, 'cid' => $parentCid],
];
}
$response = curl_exec($ch);
// Attach link card embed to last post only
if ($embed !== null && $i === count($chunks) - 1) {
$record['embed'] = $embed;
}
if ($response === false) {
$postData = json_encode([
'repo' => $authData['did'],
'collection' => 'app.bsky.feed.post',
'record' => $record,
]);
$curlError = curl_error($ch);
$ch = curl_init($pds . '/xrpc/com.atproto.repo.createRecord');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postData,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $authData['accessJwt'], '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' => $rootUri ?? '', 'response' => ['error' => 'Thread error at post ' . ($i + 1) . ': ' . $curlError]];
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true) ?: [];
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Connection error: ' . $curlError]];
if ($httpCode !== 200 || empty($data['uri'])) {
return ['success' => false, 'platform_post_id' => $rootUri ?? '', 'response' => $data];
}
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true) ?: [];
if ($httpCode === 200 && !empty($data['uri'])) {
return ['success' => true, 'platform_post_id' => $data['uri'], 'response' => $data];
if ($rootUri === null) {
$rootUri = $data['uri'];
$rootCid = $data['cid'];
}
$parentUri = $data['uri'];
$parentCid = $data['cid'];
$lastResult = $data;
}
return ['success' => false, 'platform_post_id' => '', 'response' => $data];
return ['success' => true, 'platform_post_id' => $rootUri, 'response' => array_merge($lastResult, ['thread_count' => count($chunks)])];
}
public function validateCredentials(array $credentials): array
@@ -127,7 +174,7 @@ class BlueskyService extends CMSPlugin implements SubscriberInterface, MokoSuite
private function authenticateWithCache(string $pds, string $handle, string $appPwd): array
{
$cacheKey = md5($pds . $handle);
$cacheKey = hash('sha256', $pds . $handle);
if (isset(self::$sessionCache[$cacheKey])) {
$cached = self::$sessionCache[$cacheKey];
@@ -175,6 +222,157 @@ class BlueskyService extends CMSPlugin implements SubscriberInterface, MokoSuite
return json_decode($response, true) ?: [];
}
/**
* Create a single Bluesky post (used for non-threaded messages).
*/
private function createPost(string $pds, array $authData, string $text, ?array $embed = null): array
{
$record = [
'$type' => 'app.bsky.feed.post',
'text' => $text,
'createdAt' => gmdate('Y-m-d\TH:i:s\Z'),
];
if ($embed !== null) {
$record['embed'] = $embed;
}
$postData = json_encode([
'repo' => $authData['did'],
'collection' => 'app.bsky.feed.post',
'record' => $record,
]);
$ch = curl_init($pds . '/xrpc/com.atproto.repo.createRecord');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postData,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $authData['accessJwt'], 'Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
if ($response === false) {
$curlError = curl_error($ch);
curl_close($ch);
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Connection error: ' . $curlError]];
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true) ?: [];
if ($httpCode === 200 && !empty($data['uri'])) {
return ['success' => true, 'platform_post_id' => $data['uri'], 'response' => $data];
}
return ['success' => false, 'platform_post_id' => '', 'response' => $data];
}
/**
* Split a long message into thread-sized chunks at sentence boundaries.
*/
private function splitIntoThread(string $message, int $maxLength): array
{
if (mb_strlen($message) <= $maxLength) {
return [$message];
}
$chunks = [];
$remaining = $message;
while (mb_strlen($remaining) > $maxLength) {
$segment = mb_substr($remaining, 0, $maxLength);
// Try to break at last sentence boundary (. ! ? followed by space)
$breakPos = max(
mb_strrpos($segment, '. ') ?: 0,
mb_strrpos($segment, '! ') ?: 0,
mb_strrpos($segment, '? ') ?: 0
);
if ($breakPos < $maxLength * 0.3) {
// No good sentence break; try last space
$breakPos = mb_strrpos($segment, ' ') ?: $maxLength;
} else {
$breakPos += 1; // Include the punctuation
}
$chunks[] = trim(mb_substr($remaining, 0, $breakPos));
$remaining = trim(mb_substr($remaining, $breakPos));
}
if (!empty($remaining)) {
$chunks[] = $remaining;
}
return $chunks;
}
public function deletePost(string $platformPostId, array $credentials): array
{
$pds = rtrim($credentials['pds_url'] ?? 'https://bsky.social', '/');
$handle = $credentials['handle'] ?? '';
$appPwd = $credentials['app_password'] ?? '';
if (empty($handle) || empty($appPwd)) {
return ['success' => false, 'message' => 'Missing credentials.'];
}
// Parse AT URI: at://did:plc:xxx/app.bsky.feed.post/rkey
$parts = explode('/', $platformPostId);
$rkey = end($parts);
if (empty($rkey)) {
return ['success' => false, 'message' => 'Invalid AT URI -- could not extract rkey.'];
}
// Authenticate (uses cached session if still valid)
$authData = $this->authenticateWithCache($pds, $handle, $appPwd);
if (empty($authData['accessJwt'])) {
return ['success' => false, 'message' => 'Authentication failed.'];
}
$postData = json_encode([
'repo' => $authData['did'],
'collection' => 'app.bsky.feed.post',
'rkey' => $rkey,
]);
$ch = curl_init($pds . '/xrpc/com.atproto.repo.deleteRecord');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postData,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $authData['accessJwt'], 'Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS | CURLPROTO_HTTP,
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS | CURLPROTO_HTTP,
]);
$response = curl_exec($ch);
if ($response === false) {
$curlError = curl_error($ch);
curl_close($ch);
return ['success' => false, 'message' => 'Connection error: ' . $curlError];
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
return ['success' => true, 'message' => 'Post deleted successfully.'];
}
$data = json_decode($response, true) ?: [];
return ['success' => false, 'message' => $data['message'] ?? 'Delete failed with HTTP ' . $httpCode];
}
public function getSupportedMediaTypes(): array
{
return ['image'];
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Brevo (Sendinblue)</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -70,15 +70,6 @@ class BrevoService extends CMSPlugin implements SubscriberInterface, MokoSuiteCr
CURLOPT_TIMEOUT => 30,
]);
curl_setopt_array($ch, [
CURLOPT_URL => 'https://api.brevo.com/v3/emailCampaigns',
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postData,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
if ($response === false) {
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Constant Contact</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -73,15 +73,6 @@ class ConstantcontactService extends CMSPlugin implements SubscriberInterface, M
CURLOPT_TIMEOUT => 30,
]);
curl_setopt_array($ch, [
CURLOPT_URL => 'https://api.cc.email/v3/emails',
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postData,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
if ($response === false) {
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - ConvertKit</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -66,15 +66,6 @@ class ConvertkitService extends CMSPlugin implements SubscriberInterface, MokoSu
CURLOPT_TIMEOUT => 30,
]);
curl_setopt_array($ch, [
CURLOPT_URL => 'https://api.convertkit.com/v3/broadcasts',
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postData,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
if ($response === false) {
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Dev.to</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Discord</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -10,7 +10,7 @@
<license>GPL-3.0-or-later</license>
<description>PLG_MOKOSUITECROSS_DISCORD_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\MokoSuiteCross${CLASS_NAME}</namespace>
<namespace path="src">Joomla\Plugin\MokoSuiteCross\Discord</namespace>
<files>
<filename plugin="discord">discord.php</filename>
@@ -14,6 +14,7 @@ namespace Joomla\Plugin\MokoSuiteCross\Discord\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossDeleteInterface;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
use Joomla\Event\SubscriberInterface;
@@ -30,7 +31,7 @@ use Joomla\Event\SubscriberInterface;
* "webhook_url": "https://discord.com/api/webhooks/..." // Only for custom mode
* }
*/
class DiscordService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface
class DiscordService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface, MokoSuiteCrossDeleteInterface
{
public static function getSubscribedEvents(): array
{
@@ -126,6 +127,44 @@ class DiscordService extends CMSPlugin implements SubscriberInterface, MokoSuite
return $this->params->get('default_webhook_url', '');
}
public function deletePost(string $platformPostId, array $credentials): array
{
$webhookUrl = $this->resolveWebhook($credentials);
if (empty($webhookUrl)) {
return ['success' => false, 'message' => 'Missing webhook URL.'];
}
$apiUrl = $webhookUrl . '/messages/' . $platformPostId;
$ch = curl_init($apiUrl);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
if ($response === false) {
$curlError = curl_error($ch);
curl_close($ch);
return ['success' => false, 'message' => 'Connection error: ' . $curlError];
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 204) {
return ['success' => true, 'message' => 'Message deleted successfully.'];
}
$data = json_decode($response, true) ?: [];
return ['success' => false, 'message' => $data['message'] ?? 'Delete failed (HTTP ' . $httpCode . ').'];
}
public function getSupportedMediaTypes(): array
{
return ['image', 'video'];
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Facebook / Meta</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -10,7 +10,7 @@
<license>GPL-3.0-or-later</license>
<description>PLG_MOKOSUITECROSS_FACEBOOK_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\MokoSuiteCross${CLASS_NAME}</namespace>
<namespace path="src">Joomla\Plugin\MokoSuiteCross\Facebook</namespace>
<files>
<filename plugin="facebook">facebook.php</filename>
@@ -14,6 +14,7 @@ namespace Joomla\Plugin\MokoSuiteCross\Facebook\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossDeleteInterface;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
use Joomla\Event\SubscriberInterface;
@@ -31,7 +32,7 @@ use Joomla\Event\SubscriberInterface;
* "page_id": "..." // Required — Facebook Page ID
* }
*/
class FacebookService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface
class FacebookService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface, MokoSuiteCrossDeleteInterface
{
public static function getSubscribedEvents(): array
{
@@ -161,6 +162,44 @@ class FacebookService extends CMSPlugin implements SubscriberInterface, MokoSuit
return $this->params->get('default_page_access_token', '');
}
public function deletePost(string $platformPostId, array $credentials): array
{
$token = $this->resolveToken($credentials);
if (empty($token)) {
return ['success' => false, 'message' => 'Missing access token.'];
}
$apiUrl = 'https://graph.facebook.com/v19.0/' . $platformPostId . '?access_token=' . $token;
$ch = curl_init($apiUrl);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
if ($response === false) {
$curlError = curl_error($ch);
curl_close($ch);
return ['success' => false, 'message' => 'Connection error: ' . $curlError];
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true) ?: [];
if ($httpCode === 200 && !empty($data['success'])) {
return ['success' => true, 'message' => 'Post deleted successfully.'];
}
return ['success' => false, 'message' => $data['error']['message'] ?? 'Delete failed (HTTP ' . $httpCode . ').'];
}
public function getSupportedMediaTypes(): array
{
return ['image', 'video', 'gif'];
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Ghost</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Google Business Profile</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Google Chat</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Hashnode</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1,11 @@
<?php
/**
* @package MokoSuiteCross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
defined('_JEXEC') or die;
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Instagram</name>
<version>01.06.00</version>
<creationDate>2026-06-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<description>PLG_MOKOSUITECROSS_INSTAGRAM_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\MokoSuiteCross\Instagram</namespace>
<files>
<filename plugin="instagram">instagram.php</filename>
<folder>src</folder>
<folder>services</folder>
<folder>language</folder>
</files>
<languages>
<language tag="en-GB">language/en-GB/plg_mokosuitecross_instagram.ini</language>
<language tag="en-GB">language/en-GB/plg_mokosuitecross_instagram.sys.ini</language>
</languages>
<config>
<fields name="params">
<fieldset name="basic" label="PLG_MOKOSUITECROSS_INSTAGRAM_FIELDSET_DEFAULTS">
<field
name="default_webhook_url"
type="url"
label="PLG_MOKOSUITECROSS_INSTAGRAM_DEFAULT_WEBHOOK"
description="PLG_MOKOSUITECROSS_INSTAGRAM_DEFAULT_WEBHOOK_DESC"
size="60"
/>
</fieldset>
</fields>
</config>
</extension>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1,5 @@
PLG_MOKOSUITECROSS_INSTAGRAM="MokoSuiteCross - Instagram"
PLG_MOKOSUITECROSS_INSTAGRAM_DESCRIPTION="Cross-post Joomla articles to Instagram via Meta Content Publishing API."
PLG_MOKOSUITECROSS_INSTAGRAM_FIELDSET_DEFAULTS="Default Settings"
PLG_MOKOSUITECROSS_INSTAGRAM_DEFAULT_WEBHOOK="Default Webhook URL"
PLG_MOKOSUITECROSS_INSTAGRAM_DEFAULT_WEBHOOK_DESC="Pre-configured MokoSuite webhook URL. Services using default mode will use this URL."
@@ -0,0 +1,2 @@
PLG_MOKOSUITECROSS_INSTAGRAM="MokoSuiteCross - Instagram"
PLG_MOKOSUITECROSS_INSTAGRAM_DESCRIPTION="Cross-post Joomla articles to Instagram via Meta Content Publishing API."
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1,38 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage plg_mokosuitecross_instagram
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Joomla\Plugin\MokoSuiteCross\Instagram\Extension\InstagramService;
return new class () implements ServiceProviderInterface {
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$plugin = new InstagramService(
$container->get(DispatcherInterface::class),
(array) PluginHelper::getPlugin('mokosuitecross', 'instagram')
);
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -0,0 +1,188 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage plg_mokosuitecross_instagram
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Plugin\MokoSuiteCross\Instagram\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
use Joomla\Event\SubscriberInterface;
/**
* Instagram service plugin for MokoSuiteCross.
*
* Uses the Meta Content Publishing API — a 2-step flow:
* 1. Create a media container via POST /{ig_user_id}/media
* 2. Publish the container via POST /{ig_user_id}/media_publish
*/
class InstagramService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface
{
public static function getSubscribedEvents(): array
{
return ['onMokoSuiteCrossGetServices' => 'onMokoSuiteCrossGetServices'];
}
public function onMokoSuiteCrossGetServices(&$services): void
{
$services[] = $this;
}
public function getServiceType(): string { return 'instagram'; }
public function getServiceName(): string { return 'Instagram'; }
public function getMaxLength(): int { return 2200; }
public function supportsMedia(): bool { return true; }
public function publish(string $message, array $media, array $credentials, array $params): array
{
$token = $this->resolveCredential($credentials, 'access_token');
$accountId = $credentials['instagram_account_id'] ?? '';
if (empty($token) || empty($accountId)) {
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing access token or Instagram account ID.']];
}
// Step 1: Create media container
$containerUrl = 'https://graph.facebook.com/v19.0/' . urlencode($accountId) . '/media';
$containerData = [
'caption' => mb_substr($message, 0, 2200),
'access_token' => $token,
];
// Attach image if provided
if (!empty($media[0])) {
$containerData['image_url'] = $media[0];
} else {
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Instagram requires at least one image or video.']];
}
$ch = curl_init($containerUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($containerData),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
if ($response === false) {
$curlError = curl_error($ch);
curl_close($ch);
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Connection error: ' . $curlError]];
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true) ?: [];
if ($httpCode < 200 || $httpCode >= 300 || empty($data['id'])) {
return ['success' => false, 'platform_post_id' => '', 'response' => $data];
}
$containerId = $data['id'];
// Step 2: Publish the container
$publishUrl = 'https://graph.facebook.com/v19.0/' . urlencode($accountId) . '/media_publish';
$publishData = [
'creation_id' => $containerId,
'access_token' => $token,
];
$ch = curl_init($publishUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($publishData),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
if ($response === false) {
$curlError = curl_error($ch);
curl_close($ch);
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Connection error: ' . $curlError]];
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true) ?: [];
if ($httpCode >= 200 && $httpCode < 300 && !empty($data['id'])) {
return ['success' => true, 'platform_post_id' => (string) $data['id'], 'response' => $data];
}
return ['success' => false, 'platform_post_id' => '', 'response' => $data];
}
public function validateCredentials(array $credentials): array
{
$token = $this->resolveCredential($credentials, 'access_token');
$accountId = $credentials['instagram_account_id'] ?? '';
if (empty($token) || empty($accountId)) {
return ['valid' => false, 'message' => 'Access token and Instagram account ID are required.', 'account_name' => ''];
}
$ch = curl_init('https://graph.facebook.com/v19.0/me?fields=id,username&access_token=' . urlencode($token));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
if ($response === false) {
$curlError = curl_error($ch);
curl_close($ch);
return ['valid' => false, 'message' => 'Connection error: ' . $curlError, 'account_name' => ''];
}
curl_close($ch);
$data = json_decode($response, true) ?: [];
if (!empty($data['id'])) {
$name = $data['username'] ?? $data['id'];
return ['valid' => true, 'message' => 'Connected', 'account_name' => '@' . $name];
}
return ['valid' => false, 'message' => $data['error']['message'] ?? 'Failed to verify credentials.', 'account_name' => ''];
}
private function resolveCredential(array $credentials, string $key): string
{
$mode = $credentials['mode'] ?? 'default';
if ($mode === 'custom') {
return $credentials[$key] ?? '';
}
return $this->params->get('default_' . $key, '');
}
public function getSupportedMediaTypes(): array
{
return ['image', 'video'];
}
}
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - LinkedIn</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -10,7 +10,7 @@
<license>GPL-3.0-or-later</license>
<description>PLG_MOKOSUITECROSS_LINKEDIN_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\MokoSuiteCross${CLASS_NAME}</namespace>
<namespace path="src">Joomla\Plugin\MokoSuiteCross\Linkedin</namespace>
<files>
<filename plugin="linkedin">linkedin.php</filename>
@@ -14,6 +14,7 @@ namespace Joomla\Plugin\MokoSuiteCross\Linkedin\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossDeleteInterface;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
use Joomla\Event\SubscriberInterface;
@@ -29,7 +30,7 @@ use Joomla\Event\SubscriberInterface;
* "person_id": "..." // LinkedIn Person URN (fallback)
* }
*/
class LinkedinService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface
class LinkedinService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface, MokoSuiteCrossDeleteInterface
{
public static function getSubscribedEvents(): array
{
@@ -147,6 +148,46 @@ class LinkedinService extends CMSPlugin implements SubscriberInterface, MokoSuit
return true;
}
public function deletePost(string $platformPostId, array $credentials): array
{
$token = $credentials['access_token'] ?? '';
if (empty($token)) {
return ['success' => false, 'message' => 'Missing access token.'];
}
$encodedId = urlencode($platformPostId);
$apiUrl = 'https://api.linkedin.com/v2/ugcPosts/' . $encodedId;
$ch = curl_init($apiUrl);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
if ($response === false) {
$curlError = curl_error($ch);
curl_close($ch);
return ['success' => false, 'message' => 'Connection error: ' . $curlError];
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 204) {
return ['success' => true, 'message' => 'Post deleted successfully.'];
}
$data = json_decode($response, true) ?: [];
return ['success' => false, 'message' => $data['message'] ?? 'Delete failed (HTTP ' . $httpCode . ').'];
}
public function getSupportedMediaTypes(): array
{
return ['image', 'video'];
@@ -7,3 +7,8 @@ PLG_MOKOSUITECROSS_MAILCHIMP_DEFAULT_FROM_EMAIL="Default From Email"
PLG_MOKOSUITECROSS_MAILCHIMP_DEFAULT_FROM_EMAIL_DESC="Default sender email address for Mailchimp campaigns."
PLG_MOKOSUITECROSS_MAILCHIMP_AUTO_SEND="Auto Send"
PLG_MOKOSUITECROSS_MAILCHIMP_AUTO_SEND_DESC="Automatically send the campaign on creation instead of saving as draft."
PLG_MOKOSUITECROSS_MAILCHIMP_FIELDSET_TEMPLATE="Email Template"
PLG_MOKOSUITECROSS_MAILCHIMP_TEMPLATE_ID="Mailchimp Template ID"
PLG_MOKOSUITECROSS_MAILCHIMP_TEMPLATE_ID_DESC="Numeric ID of a saved Mailchimp template. Article content is injected into the template section. Leave empty to use the built-in responsive wrapper."
PLG_MOKOSUITECROSS_MAILCHIMP_TEMPLATE_SECTION="Template Section Name"
PLG_MOKOSUITECROSS_MAILCHIMP_TEMPLATE_SECTION_DESC="The editable section name in your Mailchimp template where article content is injected. Default: body_content."
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Mailchimp</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -10,7 +10,7 @@
<license>GPL-3.0-or-later</license>
<description>PLG_MOKOSUITECROSS_MAILCHIMP_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\MokoSuiteCross${CLASS_NAME}</namespace>
<namespace path="src">Joomla\Plugin\MokoSuiteCross\Mailchimp</namespace>
<files>
<filename plugin="mailchimp">mailchimp.php</filename>
@@ -51,6 +51,24 @@
<option value="1">JYES</option>
</field>
</fieldset>
<fieldset name="template" label="PLG_MOKOSUITECROSS_MAILCHIMP_FIELDSET_TEMPLATE">
<field
name="template_id"
type="text"
label="PLG_MOKOSUITECROSS_MAILCHIMP_TEMPLATE_ID"
description="PLG_MOKOSUITECROSS_MAILCHIMP_TEMPLATE_ID_DESC"
hint="Leave empty for built-in responsive template"
filter="integer"
/>
<field
name="template_section"
type="text"
label="PLG_MOKOSUITECROSS_MAILCHIMP_TEMPLATE_SECTION"
description="PLG_MOKOSUITECROSS_MAILCHIMP_TEMPLATE_SECTION_DESC"
default="body_content"
showon="template_id!:"
/>
</fieldset>
</fields>
</config>
</extension>
@@ -95,14 +95,30 @@ class MailchimpService extends CMSPlugin implements SubscriberInterface, MokoSui
$data = json_decode($response, true) ?: [];
if ($httpCode !== 200 || empty($data['id'])) {
if ($httpCode < 200 || $httpCode >= 300 || empty($data['id'])) {
return ['success' => false, 'platform_post_id' => '', 'response' => $data];
}
$campaignId = $data['id'];
// Set campaign content (HTML)
$contentData = json_encode(['html' => $message]);
// Set campaign content — template injection or responsive wrapper
$templateId = (int) $this->params->get('template_id', 0);
$templateSection = $this->params->get('template_section', 'body_content');
if ($templateId > 0) {
// Inject article content into a saved Mailchimp template section
$contentData = json_encode([
'template' => [
'id' => $templateId,
'sections' => [
$templateSection => $message,
],
],
]);
} else {
// Wrap in responsive email skeleton
$contentData = json_encode(['html' => $this->wrapEmailHtml($message)]);
}
$ch = curl_init("https://{$dc}.api.mailchimp.com/3.0/campaigns/{$campaignId}/content");
curl_setopt_array($ch, [
@@ -185,6 +201,27 @@ class MailchimpService extends CMSPlugin implements SubscriberInterface, MokoSui
return end($parts) ?: 'us1';
}
/**
* Wrap content in a responsive email HTML skeleton.
* Used when no Mailchimp template ID is configured.
*/
private function wrapEmailHtml(string $content): string
{
return '<!DOCTYPE html>'
. '<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>'
. '<body style="margin:0;padding:0;background-color:#f4f4f4;font-family:Arial,Helvetica,sans-serif;">'
. '<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background-color:#f4f4f4;">'
. '<tr><td align="center" style="padding:20px 0;">'
. '<table width="600" cellpadding="0" cellspacing="0" role="presentation" style="background-color:#ffffff;border-radius:4px;">'
. '<tr><td style="padding:30px 40px;">'
. $content
. '</td></tr>'
. '</table>'
. '</td></tr>'
. '</table>'
. '</body></html>';
}
public function getSupportedMediaTypes(): array
{
return ['image'];
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Mastodon</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -10,7 +10,7 @@
<license>GPL-3.0-or-later</license>
<description>PLG_MOKOSUITECROSS_MASTODON_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\MokoSuiteCross${CLASS_NAME}</namespace>
<namespace path="src">Joomla\Plugin\MokoSuiteCross\Mastodon</namespace>
<files>
<filename plugin="mastodon">mastodon.php</filename>
@@ -14,6 +14,7 @@ namespace Joomla\Plugin\MokoSuiteCross\Mastodon\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossDeleteInterface;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
use Joomla\Event\SubscriberInterface;
@@ -26,7 +27,7 @@ use Joomla\Event\SubscriberInterface;
* "access_token": "..."
* }
*/
class MastodonService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface
class MastodonService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface, MokoSuiteCrossDeleteInterface
{
public static function getSubscribedEvents(): array
{
@@ -52,10 +53,46 @@ class MastodonService extends CMSPlugin implements SubscriberInterface, MokoSuit
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing credentials']];
}
// Build status payload with optional Mastodon features
$postBody = ['status' => mb_substr($message, 0, 500)];
// Visibility: public (default), unlisted, private, direct
$visibility = $params['visibility'] ?? $this->params->get('default_visibility', 'public');
if (in_array($visibility, ['public', 'unlisted', 'private', 'direct'], true)) {
$postBody['visibility'] = $visibility;
}
// Content warning / spoiler text
$spoiler = $params['spoiler_text'] ?? '';
if (!empty($spoiler)) {
$postBody['spoiler_text'] = $spoiler;
}
// Scheduled posting (must be 5+ minutes in future)
$scheduledAt = $params['scheduled_at'] ?? '';
if (!empty($scheduledAt)) {
$postBody['scheduled_at'] = $scheduledAt;
}
// Poll support (mutually exclusive with media)
if (!empty($params['poll']['options']) && empty($media)) {
$postBody['poll'] = [
'options' => $params['poll']['options'],
'expires_in' => (int) ($params['poll']['expires_in'] ?? 86400),
'multiple' => !empty($params['poll']['multiple']),
];
}
// Language tag
$language = $params['language'] ?? '';
if (!empty($language)) {
$postBody['language'] = $language;
}
$ch = curl_init($instance . '/api/v1/statuses');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['status' => mb_substr($message, 0, 500)]),
CURLOPT_POSTFIELDS => json_encode($postBody),
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
@@ -120,6 +157,46 @@ class MastodonService extends CMSPlugin implements SubscriberInterface, MokoSuit
return ['valid' => false, 'message' => 'Failed', 'account_name' => ''];
}
public function deletePost(string $platformPostId, array $credentials): array
{
$instance = rtrim($credentials['instance_url'] ?? '', '/');
$token = $credentials['access_token'] ?? '';
if (empty($instance) || empty($token)) {
return ['success' => false, 'message' => 'Missing credentials.'];
}
$ch = curl_init($instance . '/api/v1/statuses/' . $platformPostId);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS | CURLPROTO_HTTP,
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS | CURLPROTO_HTTP,
]);
$response = curl_exec($ch);
if ($response === false) {
$curlError = curl_error($ch);
curl_close($ch);
return ['success' => false, 'message' => 'Connection error: ' . $curlError];
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
return ['success' => true, 'message' => 'Status deleted successfully.'];
}
$data = json_decode($response, true) ?: [];
return ['success' => false, 'message' => $data['error'] ?? 'Delete failed with HTTP ' . $httpCode];
}
public function getSupportedMediaTypes(): array
{
return ['image', 'video', 'gif'];
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Matrix / Element</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Medium</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -163,7 +163,7 @@ class MediumService extends CMSPlugin implements SubscriberInterface, MokoSuiteC
curl_close($ch);
return ['valid' => false, 'message' => 'Connection error: ' . $curlError, 'account_name' => ''];
return '';
}
curl_close($ch);
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - MokoSuiteCalendar Events</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - MokoSuiteGallery</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Nostr</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,2 +1,7 @@
PLG_MOKOSUITECROSS_NTFY="MokoSuiteCross - Ntfy Push Notifications"
PLG_MOKOSUITECROSS_NTFY_DESCRIPTION="Cross-post Joomla articles to Ntfy Push Notifications."
PLG_MOKOSUITECROSS_NTFY_DESCRIPTION="Cross-post Joomla articles to Ntfy push notifications. Default server: ntfy.mokoconsulting.tech."
PLG_MOKOSUITECROSS_NTFY_FIELDSET_DEFAULTS="Ntfy Defaults"
PLG_MOKOSUITECROSS_NTFY_DEFAULT_SERVER_URL="Default Server URL"
PLG_MOKOSUITECROSS_NTFY_DEFAULT_SERVER_URL_DESC="Default ntfy server URL. Override per-service in credentials. Default: https://ntfy.mokoconsulting.tech"
PLG_MOKOSUITECROSS_NTFY_DEFAULT_TOPIC="Default Topic"
PLG_MOKOSUITECROSS_NTFY_DEFAULT_TOPIC_DESC="Default ntfy topic name. Each service can override this in its credentials."
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Ntfy Push Notifications</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -23,4 +23,25 @@
<language tag="en-GB">language/en-GB/plg_mokosuitecross_ntfy.ini</language>
<language tag="en-GB">language/en-GB/plg_mokosuitecross_ntfy.sys.ini</language>
</languages>
<config>
<fields name="params">
<fieldset name="basic" label="PLG_MOKOSUITECROSS_NTFY_FIELDSET_DEFAULTS">
<field
name="default_server_url"
type="url"
label="PLG_MOKOSUITECROSS_NTFY_DEFAULT_SERVER_URL"
description="PLG_MOKOSUITECROSS_NTFY_DEFAULT_SERVER_URL_DESC"
default="https://ntfy.mokoconsulting.tech"
/>
<field
name="default_topic"
type="text"
label="PLG_MOKOSUITECROSS_NTFY_DEFAULT_TOPIC"
description="PLG_MOKOSUITECROSS_NTFY_DEFAULT_TOPIC_DESC"
hint="e.g. mokosuite-articles"
/>
</fieldset>
</fields>
</config>
</extension>
@@ -43,7 +43,8 @@ class NtfyService extends CMSPlugin implements SubscriberInterface, MokoSuiteCro
{
$url = $credentials['topic'] ?? $credentials['webhook_url'] ?? '';
$serverUrl = rtrim($credentials['server_url'] ?? 'https://ntfy.sh', '/');
$defaultServer = $this->params->get('default_server_url', 'https://ntfy.mokoconsulting.tech');
$serverUrl = rtrim($credentials['server_url'] ?? $defaultServer, '/');
$topic = $credentials['topic'] ?? '';
$token = $credentials['token'] ?? '';
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Pinterest</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Reddit</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - RSS Feed</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - SendGrid</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Slack</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -10,7 +10,7 @@
<license>GPL-3.0-or-later</license>
<description>PLG_MOKOSUITECROSS_SLACK_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\MokoSuiteCross${CLASS_NAME}</namespace>
<namespace path="src">Joomla\Plugin\MokoSuiteCross\Slack</namespace>
<files>
<filename plugin="slack">slack.php</filename>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Microsoft Teams</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -14,6 +14,7 @@ namespace Joomla\Plugin\MokoSuiteCross\Telegram\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossDeleteInterface;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
use Joomla\Event\SubscriberInterface;
@@ -31,7 +32,7 @@ use Joomla\Event\SubscriberInterface;
* "chat_id": "-100xxxxxxx" // Required — channel/group/user chat ID
* }
*/
class TelegramService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface
class TelegramService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface, MokoSuiteCrossDeleteInterface
{
/**
* Default MokoSuite Bot token — resolved at runtime from component params.
@@ -222,6 +223,52 @@ class TelegramService extends CMSPlugin implements SubscriberInterface, MokoSuit
return $r;
}
public function deletePost(string $platformPostId, array $credentials): array
{
$botToken = $this->resolveBotToken($credentials);
$chatId = $credentials['chat_id'] ?? '';
if (empty($botToken) || empty($chatId)) {
return ['success' => false, 'message' => 'Missing bot token or chat_id.'];
}
$apiUrl = 'https://api.telegram.org/bot' . $botToken . '/deleteMessage';
$postData = json_encode([
'chat_id' => $chatId,
'message_id' => (int) $platformPostId,
]);
$ch = curl_init($apiUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postData,
CURLOPT_HTTPHEADER => ['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, 'message' => 'Connection error: ' . $curlError];
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true) ?: [];
if ($httpCode === 200 && !empty($data['ok'])) {
return ['success' => true, 'message' => 'Message deleted successfully.'];
}
return ['success' => false, 'message' => $data['description'] ?? 'Delete failed (HTTP ' . $httpCode . ').'];
}
public function getSupportedMediaTypes(): array
{
return ['image', 'video', 'document'];
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Telegram</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -10,7 +10,7 @@
<license>GPL-3.0-or-later</license>
<description>PLG_MOKOSUITECROSS_TELEGRAM_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\MokoSuiteCross${CLASS_NAME}</namespace>
<namespace path="src">Joomla\Plugin\MokoSuiteCross\Telegram</namespace>
<files>
<filename plugin="telegram">telegram.php</filename>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Threads (Meta)</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - TikTok</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Tumblr</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -14,6 +14,7 @@ namespace Joomla\Plugin\MokoSuiteCross\Twitter\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossDeleteInterface;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
use Joomla\Event\SubscriberInterface;
@@ -24,7 +25,7 @@ use Joomla\Event\SubscriberInterface;
* Bearer tokens are app-only and cannot create tweets — OAuth 1.0a
* with consumer key/secret + access token/secret is required.
*/
class TwitterService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface
class TwitterService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface, MokoSuiteCrossDeleteInterface
{
public static function getSubscribedEvents(): array
{
@@ -203,6 +204,50 @@ class TwitterService extends CMSPlugin implements SubscriberInterface, MokoSuite
return 'OAuth ' . implode(', ', $parts);
}
public function deletePost(string $platformPostId, array $credentials): array
{
$apiUrl = 'https://api.twitter.com/2/tweets/' . $platformPostId;
$consumerKey = $credentials['api_key'] ?? '';
$consumerSecret = $credentials['api_secret'] ?? '';
$accessToken = $credentials['access_token'] ?? '';
$tokenSecret = $credentials['access_token_secret'] ?? '';
if (!$consumerKey || !$consumerSecret || !$accessToken || !$tokenSecret) {
return ['success' => false, 'message' => 'Missing OAuth 1.0a credentials. All 4 keys are required.'];
}
$authHeader = $this->buildOAuth1Header('DELETE', $apiUrl, $consumerKey, $consumerSecret, $accessToken, $tokenSecret);
$ch = curl_init($apiUrl);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_HTTPHEADER => ['Authorization: ' . $authHeader],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
if ($response === false) {
$curlError = curl_error($ch);
curl_close($ch);
return ['success' => false, 'message' => 'Connection error: ' . $curlError];
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true) ?: [];
if ($httpCode === 200 && !empty($data['data']['deleted']) && $data['data']['deleted'] === true) {
return ['success' => true, 'message' => 'Tweet deleted successfully.'];
}
return ['success' => false, 'message' => $data['detail'] ?? $data['title'] ?? 'Delete failed with HTTP ' . $httpCode];
}
public function getSupportedMediaTypes(): array
{
return ['image', 'video', 'gif'];
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - X / Twitter</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -10,7 +10,7 @@
<license>GPL-3.0-or-later</license>
<description>PLG_MOKOSUITECROSS_TWITTER_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\MokoSuiteCross${CLASS_NAME}</namespace>
<namespace path="src">Joomla\Plugin\MokoSuiteCross\Twitter</namespace>
<files>
<filename plugin="twitter">twitter.php</filename>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Generic Webhook</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - WhatsApp Business</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - WordPress</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1,2 @@
PLG_MOKOSUITECROSS_YOUTUBE="MokoSuiteCross - YouTube"
PLG_MOKOSUITECROSS_YOUTUBE_DESCRIPTION="Cross-post Joomla articles to YouTube community posts."
@@ -0,0 +1,2 @@
PLG_MOKOSUITECROSS_YOUTUBE="MokoSuiteCross - YouTube"
PLG_MOKOSUITECROSS_YOUTUBE_DESCRIPTION="Cross-post Joomla articles to YouTube community posts."
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1,38 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage plg_mokosuitecross_youtube
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Joomla\Plugin\MokoSuiteCross\Youtube\Extension\YoutubeService;
return new class () implements ServiceProviderInterface {
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$plugin = new YoutubeService(
$container->get(DispatcherInterface::class),
(array) PluginHelper::getPlugin('mokosuitecross', 'youtube')
);
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -0,0 +1,137 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage plg_mokosuitecross_youtube
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
namespace Joomla\Plugin\MokoSuiteCross\Youtube\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
use Joomla\Event\SubscriberInterface;
/**
* YouTube service plugin for MokoSuiteCross.
*
* Posts to YouTube via the Data API v3 channel bulletins.
*
* Credentials:
* access_token - OAuth 2.0 token with youtube.force-ssl scope
* channel_id - YouTube channel ID
*/
class YoutubeService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface
{
public static function getSubscribedEvents(): array
{
return ['onMokoSuiteCrossGetServices' => 'onMokoSuiteCrossGetServices'];
}
public function onMokoSuiteCrossGetServices(&$services): void
{
$services[] = $this;
}
public function getServiceType(): string { return 'youtube'; }
public function getServiceName(): string { return 'YouTube'; }
public function getMaxLength(): int { return 5000; }
public function supportsMedia(): bool { return true; }
public function publish(string $message, array $media, array $credentials, array $params): array
{
$token = $credentials['access_token'] ?? '';
$channelId = $credentials['channel_id'] ?? '';
if (empty($token) || empty($channelId)) {
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing access token or channel ID']];
}
$postData = json_encode([
'snippet' => [
'channelId' => $channelId,
'description' => $message,
],
'contentDetails' => [
'bulletin' => [
'resourceId' => [
'kind' => 'youtube#channel',
'channelId' => $channelId,
],
],
],
]);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => 'https://www.googleapis.com/youtube/v3/activities?part=snippet,contentDetails',
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postData,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
if ($response === false) {
$curlError = curl_error($ch);
curl_close($ch);
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Connection error: ' . $curlError]];
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true) ?: [];
if ($httpCode >= 200 && $httpCode < 300) {
return ['success' => true, 'platform_post_id' => $data['id'] ?? '', 'response' => $data];
}
return ['success' => false, 'platform_post_id' => '', 'response' => $data];
}
public function validateCredentials(array $credentials): array
{
$token = $credentials['access_token'] ?? '';
if (empty($token)) {
return ['valid' => false, 'message' => 'Missing access token', 'account_name' => ''];
}
$ch = curl_init('https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true');
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
if ($response === false) {
$curlError = curl_error($ch);
curl_close($ch);
return ['valid' => false, 'message' => 'Connection error: ' . $curlError, 'account_name' => ''];
}
curl_close($ch);
$data = json_decode($response, true) ?: [];
if (!empty($data['items'][0]['snippet']['title'])) {
return ['valid' => true, 'message' => 'Connected', 'account_name' => $data['items'][0]['snippet']['title']];
}
return ['valid' => false, 'message' => 'Invalid token or no channel found', 'account_name' => ''];
}
public function getSupportedMediaTypes(): array
{
return ['image', 'video'];
}
}
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1 @@
<!DOCTYPE html><title></title>
@@ -0,0 +1,11 @@
<?php
/**
* @package MokoSuiteCross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
* SPDX-License-Identifier: GPL-3.0-or-later
*/
defined('_JEXEC') or die;
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Youtube</name>
<version>01.06.00</version>
<creationDate>2026-06-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<description>PLG_MOKOSUITECROSS_YOUTUBE_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\MokoSuiteCross\Youtube</namespace>
<files>
<filename plugin="youtube">youtube.php</filename>
<folder>src</folder>
<folder>services</folder>
<folder>language</folder>
</files>
<languages>
<language tag="en-GB">language/en-GB/plg_mokosuitecross_youtube.ini</language>
<language tag="en-GB">language/en-GB/plg_mokosuitecross_youtube.sys.ini</language>
</languages>
<config>
<fields name="params">
<fieldset name="basic" label="PLG_MOKOSUITECROSS_YOUTUBE_FIELDSET_DEFAULTS">
<field
name="default_webhook_url"
type="url"
label="PLG_MOKOSUITECROSS_YOUTUBE_DEFAULT_WEBHOOK"
description="PLG_MOKOSUITECROSS_YOUTUBE_DEFAULT_WEBHOOK_DESC"
size="60"
/>
</fieldset>
</fields>
</config>
</extension>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteCross</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -34,20 +34,10 @@ class MokoSuiteCross extends CMSPlugin implements SubscriberInterface
public static function getSubscribedEvents(): array
{
return [
'onAfterRoute' => 'onAfterRoute',
'onAfterRender' => 'onAfterRender',
];
}
public function onAfterRoute(): void
{
$app = $this->getApplication();
if ($app->isClient('administrator')) {
$this->warnMissingLicenseKey();
}
}
/**
* Process queued posts on page load (backend and/or frontend).
*
@@ -93,59 +83,6 @@ class MokoSuiteCross extends CMSPlugin implements SubscriberInterface
\Joomla\Component\MokoSuiteCross\Administrator\Helper\QueueProcessor::processQueue(5);
}
/**
* Warn administrators once per session when no license key is configured.
*
* @return void
*/
private function warnMissingLicenseKey(): void
{
$session = Factory::getSession();
if ($session->get('mokosuitecross.license_warned', false)) {
return;
}
$user = Factory::getUser();
if ($user->guest || !$user->authorise('core.manage')) {
return;
}
$session->set('mokosuitecross.license_warned', true);
try {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('extra_query'))
->from($db->quoteName('#__update_sites'))
->where($db->quoteName('name') . ' = ' . $db->quote('MokoSuiteCross Updates'))
->setLimit(1);
$db->setQuery($query);
$extraQuery = (string) $db->loadResult();
if (!empty($extraQuery)) {
parse_str($extraQuery, $parsed);
if (!empty($parsed['dlid']) && preg_match('/^MOKO-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/', $parsed['dlid'])) {
return;
}
}
$this->getApplication()->enqueueMessage(
'<strong>Moko Consulting License Key Required</strong> — '
. 'No download key is configured. Updates will not be available until a valid license key is entered. '
. 'Go to <a href="index.php?option=com_installer&view=updatesites">System → Update Sites</a> '
. 'and enter your license key (<code>MOKO-XXXX-XXXX-XXXX-XXXX</code>) 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.
*/
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteCross Events</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteCross Gallery</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="task" method="upgrade">
<name>Task - MokoSuiteCross Queue Processor</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoSuiteCross</name>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+3 -1
View File
@@ -2,7 +2,7 @@
<extension type="package" method="upgrade">
<name>MokoSuiteCross</name>
<packagename>mokosuitecross</packagename>
<version>01.04.01</version>
<version>01.06.00</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -60,6 +60,8 @@
<file type="plugin" id="tiktok" group="mokosuitecross">plg_mokosuitecross_tiktok.zip</file>
<file type="plugin" id="mokosuitecalendar" group="mokosuitecross">plg_mokosuitecross_mokosuitecalendar.zip</file>
<file type="plugin" id="mokosuitegallery" group="mokosuitecross">plg_mokosuitecross_mokosuitegallery.zip</file>
<file type="plugin" id="instagram" group="mokosuitecross">plg_mokosuitecross_instagram.zip</file>
<file type="plugin" id="youtube" group="mokosuitecross">plg_mokosuitecross_youtube.zip</file>
<!-- Content Source Plugins (system group) -->
<file type="plugin" id="mokosuitecross_events" group="system">plg_system_mokosuitecross_events.zip</file>
-46
View File
@@ -1,46 +0,0 @@
# MokoSuiteCross Wiki
**MokoSuiteCross** — Cross-posting Joomla content to social media, email marketing, and chat platforms.
## Quick Start
1. Install `pkg_mokosuitecross-*.zip` via Joomla Extensions → Install
2. Navigate to **Components → MokoSuiteCross → Services**
3. Add your first service (e.g., Telegram, Discord, Facebook)
4. Publish an article — it's automatically cross-posted to all active services
## Getting Started
- [Installation](getting-started/Installation)
- [Configuration](getting-started/Configuration)
## User Guide
- [Services](user-guide/Services)
- [Telegram](services/Telegram)
- [Message Templates](user-guide/Message-Templates)
- [Troubleshooting](user-guide/Troubleshooting)
## Developer
- [Developer Guide](developer/Developer-Guide)
- [Adding Custom Services](developer/Adding-Custom-Services)
- [REST API](developer/REST-API)
## Architecture
MokoSuiteCross uses a **plugin-based service architecture**. Each social platform is a separate Joomla plugin in the custom `mokosuitecross` plugin group. This means:
- Install only the platforms you need
- Third-party developers can add new platforms as plugins
- Each service plugin implements `MokoSuiteCrossServiceInterface`
- Services support both **default bot/app** mode (pre-configured by Moko) and **custom** mode (bring your own API keys)
## Database Tables
| Table | Purpose |
|-------|---------|
| `#__mokosuitecross_services` | Connected service accounts |
| `#__mokosuitecross_posts` | Cross-post queue and history |
| `#__mokosuitecross_templates` | Per-platform message templates |
| `#__mokosuitecross_logs` | Activity and error logs |
-74
View File
@@ -1,74 +0,0 @@
# Adding Custom Services
MokoSuiteCross uses a plugin-based architecture. Any developer can create a new service plugin.
## Plugin Structure
Create a Joomla plugin in the `mokosuitecross` group:
```
plg_mokosuitecross_myservice/
├── myservice.xml # Plugin manifest (group="mokosuitecross")
├── myservice.php # Legacy stub (empty)
├── src/
│ └── Extension/
│ └── MyserviceService.php # Implements MokoSuiteCrossServiceInterface
├── services/
│ └── provider.php # DI container registration
└── language/
└── en-GB/
├── plg_mokosuitecross_myservice.ini
└── plg_mokosuitecross_myservice.sys.ini
```
## Implement the Interface
Your Extension class must implement `MokoSuiteCrossServiceInterface`:
```php
namespace Joomla\Plugin\MokoSuiteCross\Myservice\Extension;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
use Joomla\Event\SubscriberInterface;
class MyserviceService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface
{
public static function getSubscribedEvents(): array
{
return ['onMokoSuiteCrossGetServices' => 'onMokoSuiteCrossGetServices'];
}
public function onMokoSuiteCrossGetServices(&$services): void
{
$services[] = $this;
}
public function getServiceType(): string { return 'myservice'; }
public function getServiceName(): string { return 'My Service'; }
public function getMaxLength(): int { return 500; }
public function supportsMedia(): bool { return true; }
public function publish(string $message, array $media, array $credentials, array $params): array
{
// Your API integration here
return ['success' => true, 'platform_post_id' => '...', 'response' => [...]];
}
public function validateCredentials(array $credentials): array
{
return ['valid' => true, 'message' => 'OK', 'account_name' => '...'];
}
}
```
## Required Methods
| Method | Returns | Purpose |
|--------|---------|---------|
| `getServiceType()` | string | Unique identifier (lowercase, no spaces) |
| `getServiceName()` | string | Display name in admin UI |
| `publish()` | array | Send content to the platform |
| `validateCredentials()` | array | Test if credentials work |
| `getMaxLength()` | int | Character limit (0 = no limit) |
| `supportsMedia()` | bool | Whether images can be attached |

Some files were not shown because too many files have changed in this diff Show More