Compare commits

..

8 Commits

Author SHA1 Message Date
gitea-actions[bot] 50412f33ba chore(release): build 01.02.00-rc [skip ci] 2026-06-21 21:41:42 +00:00
Jonathan Miller c54416f06e docs: update CHANGELOG with PR workflow check, fix duplicate header
Generic: Project CI / Lint & Validate (push) Successful in 23s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Access control (pull_request) Successful in 3s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 6s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Failing after 9s
Universal: Security Audit / Dependency Audit (pull_request) Successful in 6s
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Generic: Project CI / Lint & Validate (pull_request) Successful in 18s
Universal: Auto Version Bump / Version Bump (push) Successful in 4s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 38s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-21 16:39:06 -05:00
Jonathan Miller 434505fd0b feat: add README/CHANGELOG diff check to PR workflow
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Successful in 4s
Generic: Project CI / Lint & Validate (push) Successful in 28s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
When source code is modified in a PR, the workflow now:
- BLOCKS if CHANGELOG.md was not updated (error)
- WARNS if README.md was not updated (warning, non-blocking)

This ensures every code change has a corresponding changelog entry.
2026-06-21 15:32:49 -05:00
Jonathan Miller 148e133fc3 feat: Telegram @mokosuite_bot default, wiki folders, README/CHANGELOG update
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Successful in 3s
Generic: Project CI / Lint & Validate (push) Successful in 12s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
- Telegram: updated default bot from @MokoWaaSBot to @mokosuite_bot
- Telegram: embedded obfuscated bot token in plugin PHP (XOR + base64)
- Telegram: added <config> section to plugin XML for parse_mode/preview
- Telegram: removed bot token from admin-visible plugin params
- Branding: replaced all MokoWaaS references with MokoSuite
- Wiki: reorganized into getting-started/, user-guide/, services/, developer/
- README: updated with all 36 service plugins and current features
- CHANGELOG: added entries for recent fixes and changes
2026-06-21 15:27:12 -05:00
gitea-actions[bot] f660899677 chore(version): auto-bump 01.01.02-dev [skip ci] 2026-06-21 16:41:16 +00:00
Jonathan Miller 116896b584 fix: rename all MOKOJOOMCROSS language keys and events to MOKOSUITECROSS (#128, #138)
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Successful in 3s
Generic: Project CI / Lint & Validate (push) Successful in 11s
Update Server / Update Server (push) Successful in 11s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Completes the MokoJoomCross → MokoSuiteCross rebrand across all language
string keys, Joomla event names, documentation, and wiki pages.

- 1,151 language key references renamed (COM_, PLG_, PKG_ prefixes)
- Event names renamed (onMokoJoomCross* → onMokoSuiteCross*)
- CLAUDE.md, CHANGELOG.md, wiki docs updated
- Zero mokojoomcross references remaining in codebase

Closes #128, closes #138
2026-06-21 11:40:35 -05:00
gitea-actions[bot] eaf99d3743 chore(version): auto-bump patch 01.01.01-dev [skip ci] 2026-06-21 16:05:17 +00:00
Jonathan Miller 701b64f5c2 fix: remove duplicate curl_setopt_array calls in 4 service plugins (#139)
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Project CI / Lint & Validate (push) Failing after 8s
Universal: Auto Version Bump / Version Bump (push) Successful in 5s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
SendGrid and Reddit had a second curl_setopt_array that referenced an
undefined $token variable, silently breaking auth. TikTok and Pinterest
had identical duplicates (no variable bug but dead code).

Removes the duplicate block from each plugin's publish() method.
2026-06-21 11:04:12 -05:00
134 changed files with 2339 additions and 2854 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<display-name>Package - MokoSuiteCross</display-name> <display-name>Package - MokoSuiteCross</display-name>
<org>MokoConsulting</org> <org>MokoConsulting</org>
<description>Cross-posting Joomla content to social media, email marketing, and chat platforms</description> <description>Cross-posting Joomla content to social media, email marketing, and chat platforms</description>
<version>01.04.09</version> <version>01.02.00</version>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license> <license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity> </identity>
<governance> <governance>
+9 -9
View File
@@ -4,8 +4,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Release # INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.mokogitea/workflows/auto-bump.yml # PATH: /.mokogitea/workflows/auto-bump.yml
# VERSION: 09.02.00 # VERSION: 09.02.00
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits) # BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
@@ -43,19 +43,19 @@ jobs:
token: ${{ secrets.MOKOGITEA_TOKEN }} token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1 fetch-depth: 1
- name: Setup mokocli tools - name: Setup moko-platform tools
run: | run: |
if ! command -v composer &> /dev/null; then if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi fi
if [ -d "/opt/mokocli/cli" ]; then if [ -d "/opt/moko-platform/cli" ]; then
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV" echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
else else
git clone --depth 1 --branch main --quiet \ git clone --depth 1 --branch main --quiet \
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \ "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
/tmp/mokocli /tmp/moko-platform-api
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV" echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
fi fi
- name: Bump version - name: Bump version
+34 -167
View File
@@ -4,15 +4,15 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Release # INGROUP: mokoplatform.Release
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli # REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokoplatform
# PATH: /templates/workflows/universal/auto-release.yml.template # PATH: /templates/workflows/universal/auto-release.yml.template
# VERSION: 05.00.00 # VERSION: 05.00.00
# BRIEF: Universal build & release detects platform from manifest.xml # BRIEF: Universal build & release detects platform from manifest.xml
# #
# +=======================================================================+ # +========================================================================+
# | UNIVERSAL BUILD & RELEASE PIPELINE | # | UNIVERSAL BUILD & RELEASE PIPELINE |
# +=======================================================================+ # +========================================================================+
# | | # | |
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. | # | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
# | | # | |
@@ -21,7 +21,7 @@
# | dolibarr: mod*.class.php, update.txt, dev version reset | # | dolibarr: mod*.class.php, update.txt, dev version reset |
# | generic: README-only, no update stream | # | generic: README-only, no update stream |
# | | # | |
# +=======================================================================+ # +========================================================================+
name: "Universal: Build & Release" name: "Universal: Build & Release"
@@ -51,7 +51,7 @@ permissions:
contents: write contents: write
jobs: jobs:
# ── PR Opened → Rename branch to RC and build RC release ───────────────────────── # ── PR Opened → Rename branch to RC and build RC release ─────────────────────
promote-rc: promote-rc:
name: Promote to RC name: Promote to RC
runs-on: release runs-on: release
@@ -66,25 +66,25 @@ jobs:
token: ${{ secrets.MOKOGITEA_TOKEN }} token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1 fetch-depth: 1
- name: Setup mokocli tools - name: Setup mokoplatform tools
env: env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: | run: |
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then if [ -f /opt/mokoplatform/cli/version_bump.php ] && [ -f /opt/mokoplatform/vendor/autoload.php ]; then
echo Using pre-installed /opt/mokocli echo Using pre-installed /opt/mokoplatform
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV echo MOKO_CLI=/opt/mokoplatform/cli >> $GITHUB_ENV
else else
echo Falling back to fresh clone echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; then if ! command -v composer > /dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1 sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
fi fi
rm -rf /tmp/mokocli rm -rf /tmp/mokoplatform-api
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokoplatform.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokoplatform-api
cd /tmp/mokocli cd /tmp/mokoplatform-api
composer install --no-dev --no-interaction --quiet composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV echo MOKO_CLI=/tmp/mokoplatform-api/cli >> $GITHUB_ENV
fi fi
- name: Rename branch to rc - name: Rename branch to rc
@@ -109,47 +109,13 @@ jobs:
--path . --stability rc --bump minor --branch rc \ --path . --stability rc --bump minor --branch rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --token "${{ secrets.MOKOGITEA_TOKEN }}"
- name: Update RC release notes from CHANGELOG.md
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Extract [Unreleased] section from changelog
NOTES=""
if [ -f "CHANGELOG.md" ]; then
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
fi
[ -z "$NOTES" ] && NOTES="Release candidate"
# Find the RC release and update its body
RELEASE_ID=$(curl -sf -H "Authorization: token ${TOKEN}" \
"${API_BASE}/releases/tags/release-candidate" \
| python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -n "$RELEASE_ID" ]; then
python3 -c "
import json, urllib.request
body = open('/dev/stdin').read()
payload = json.dumps({'body': body}).encode()
req = urllib.request.Request(
'${API_BASE}/releases/${RELEASE_ID}',
data=payload, method='PATCH',
headers={
'Authorization': 'token ${TOKEN}',
'Content-Type': 'application/json'
})
urllib.request.urlopen(req)
" <<< "$NOTES"
echo "RC release notes updated from CHANGELOG.md"
fi
- name: Summary - name: Summary
if: always() if: always()
run: | run: |
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
# ── Merged PR → Build & Release (or promote RC to stable) ───────────────────────── # ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
release: release:
name: Build & Release Pipeline name: Build & Release Pipeline
runs-on: release runs-on: release
@@ -183,131 +149,50 @@ jobs:
fi fi
echo "No conflict markers found" echo "No conflict markers found"
- name: Setup mokocli tools - name: Setup mokoplatform tools
env: env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}' COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
run: | run: |
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then if [ -f /opt/mokoplatform/cli/version_bump.php ] && [ -f /opt/mokoplatform/vendor/autoload.php ]; then
echo Using pre-installed /opt/mokocli echo Using pre-installed /opt/mokoplatform
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV echo MOKO_CLI=/opt/mokoplatform/cli >> $GITHUB_ENV
else else
echo Falling back to fresh clone echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; then if ! command -v composer > /dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1 sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
fi fi
rm -rf /tmp/mokocli rm -rf /tmp/mokoplatform-api
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokoplatform.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokoplatform-api
cd /tmp/mokocli cd /tmp/mokoplatform-api
composer install --no-dev --no-interaction --quiet composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV echo MOKO_CLI=/tmp/mokoplatform-api/cli >> $GITHUB_ENV
fi fi
- name: "Detect platform"
id: platform
run: |
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
php ${MOKO_CLI}/manifest_read.php --path . --github-output 2>/dev/null || true
- name: "Determine version bump level"
id: bump
run: |
# Fix/patch branches: version was already bumped by pre-release, just strip suffix
# Feature/dev branches: bump minor for the new stable release
HEAD_REF="${{ github.event.pull_request.head.ref || 'dev' }}"
case "$HEAD_REF" in
fix/*|patch/*|hotfix/*|bugfix/*) BUMP="none" ;;
*) BUMP="minor" ;;
esac
echo "level=${BUMP}" >> "$GITHUB_OUTPUT"
echo "Bump level: ${BUMP} (from branch: ${HEAD_REF})"
- name: "Publish stable release" - name: "Publish stable release"
run: | run: |
BUMP_FLAG=""
if [ "${{ steps.bump.outputs.level }}" != "none" ]; then
BUMP_FLAG="--bump ${{ steps.bump.outputs.level }}"
fi
php ${MOKO_CLI}/release_publish.php \ php ${MOKO_CLI}/release_publish.php \
--path . --stability stable ${BUMP_FLAG} --branch main \ --path . --stability stable --bump minor --branch main \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --token "${{ secrets.MOKOGITEA_TOKEN }}"
- name: "Read published version" - name: Update release notes from CHANGELOG.md
id: version
run: |
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "")
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
[ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
PLATFORM="${{ steps.platform.outputs.platform }}"
if [[ "$PLATFORM" == joomla* ]]; then
echo "tag=stable" >> "$GITHUB_OUTPUT"
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
else
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release_tag=v${VERSION}" >> "$GITHUB_OUTPUT"
fi
echo "branch=main" >> "$GITHUB_OUTPUT"
echo "Published version: ${VERSION}"
- name: "Create semver tag for non-Joomla repos"
id: semver
if: |
steps.version.outputs.skip != 'true' &&
!startsWith(steps.platform.outputs.platform, 'joomla')
run: |
VERSION="${{ steps.version.outputs.version }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
SEMVER_TAG="v${VERSION}"
echo "Creating semver tag: ${SEMVER_TAG}"
# Create the git tag via API
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
-X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API_BASE}/tags" \
-d "{\"tag_name\":\"${SEMVER_TAG}\",\"target\":\"main\",\"message\":\"Release ${VERSION}\"}" 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "200" ]; then
echo "Created semver tag: ${SEMVER_TAG}"
elif [ "$HTTP_CODE" = "409" ]; then
echo "Semver tag ${SEMVER_TAG} already exists (skipped)"
else
echo "::warning::Failed to create semver tag ${SEMVER_TAG} (HTTP ${HTTP_CODE})"
fi
echo "semver_tag=${SEMVER_TAG}" >> "$GITHUB_OUTPUT"
- name: Update release notes and promote changelog
run: | run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Get the stable release info (version and ID)
RELEASE_JSON=$(curl -sf -H "Authorization: token ${TOKEN}" \
"${API_BASE}/releases/tags/stable" 2>/dev/null || echo '{}')
RELEASE_ID=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" <<< "$RELEASE_JSON" 2>/dev/null || true)
# Extract version from release name (e.g. "06.17.00" or "v06.17.00")
VERSION=$(python3 -c "
import json, sys, re
r = json.load(sys.stdin)
name = r.get('name', '')
m = re.search(r'(\d+\.\d+\.\d+)', name)
print(m.group(1) if m else '')
" <<< "$RELEASE_JSON" 2>/dev/null || true)
# Extract [Unreleased] section from changelog # Extract [Unreleased] section from changelog
NOTES=""
if [ -f "CHANGELOG.md" ]; then if [ -f "CHANGELOG.md" ]; then
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md) NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
[ -z "$NOTES" ] && NOTES="Stable release"
else
NOTES="Stable release"
fi fi
[ -z "$NOTES" ] && NOTES="Stable release"
# Update release body via API # Update release body via API
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API_BASE}/releases/tags/stable" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -n "$RELEASE_ID" ]; then if [ -n "$RELEASE_ID" ]; then
python3 -c " python3 -c "
import json, urllib.request import json, urllib.request
@@ -317,7 +202,7 @@ jobs:
'${API_BASE}/releases/${RELEASE_ID}', '${API_BASE}/releases/${RELEASE_ID}',
data=payload, method='PATCH', data=payload, method='PATCH',
headers={ headers={
'Authorization': 'token ${TOKEN}', 'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}) })
urllib.request.urlopen(req) urllib.request.urlopen(req)
@@ -325,24 +210,6 @@ jobs:
echo "Release notes updated from CHANGELOG.md" echo "Release notes updated from CHANGELOG.md"
fi fi
# Promote [Unreleased] → [version] in CHANGELOG.md and reset
if [ -n "$VERSION" ] && [ -f "CHANGELOG.md" ]; then
DATE=$(date +%Y-%m-%d)
python3 -c "
import sys
version, date = sys.argv[1], sys.argv[2]
content = open('CHANGELOG.md').read()
old = '## [Unreleased]'
new = f'## [Unreleased]\n\n## [{version}] --- {date}'
content = content.replace(old, new, 1)
open('CHANGELOG.md', 'w').write(content)
" "$VERSION" "$DATE"
git add CHANGELOG.md
git commit -m "chore: promote changelog [Unreleased] → [${VERSION}]" || true
git push origin main || true
echo "Changelog promoted: [Unreleased] → [${VERSION}]"
fi
# -- STEP 9: Mirror to GitHub (stable only) -------------------------------- # -- STEP 9: Mirror to GitHub (stable only) --------------------------------
- name: "Step 9: Mirror release to GitHub" - name: "Step 9: Mirror release to GitHub"
if: >- if: >-
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Universal # INGROUP: MokoStandards.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.mokogitea/workflows/branch-cleanup.yml # PATH: /.mokogitea/workflows/branch-cleanup.yml
# VERSION: 01.00.00 # VERSION: 01.00.00
# BRIEF: Delete feature branches after PR merge # BRIEF: Delete feature branches after PR merge
+13
View File
@@ -13,6 +13,19 @@
name: "Generic: Project CI" name: "Generic: Project CI"
on: on:
push:
branches:
- main
- dev
- dev/**
- rc/**
- version/**
pull_request:
branches:
- main
- dev
- dev/**
- rc/**
workflow_dispatch: workflow_dispatch:
permissions: permissions:
+7 -410
View File
@@ -45,17 +45,17 @@ jobs:
fi fi
php -v && composer --version php -v && composer --version
- name: Setup mokocli tools - name: Setup moko-platform tools
env: env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.GA_TOKEN || github.token }} MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }} MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
run: | run: |
if [ -d "/opt/mokocli" ] || [ -d "/tmp/mokocli" ]; then if [ -d "/tmp/moko-platform" ] || [ -d "/opt/moko-platform" ]; then
echo "mokocli already available on runner — skipping clone" echo "moko-platform already available on runner — skipping clone"
else else
git clone --depth 1 --branch main --quiet \ git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git" \ "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/mokocli 2>/dev/null || echo "mokocli clone skipped — continuing without it" /tmp/moko-platform 2>/dev/null || echo "moko-platform clone skipped — continuing without it"
fi fi
- name: Install dependencies - name: Install dependencies
@@ -245,413 +245,10 @@ jobs:
echo "All ${CHECKED} directories contain index.html." >> $GITHUB_STEP_SUMMARY echo "All ${CHECKED} directories contain index.html." >> $GITHUB_STEP_SUMMARY
fi fi
- name: Check config.xml and access.xml for components
run: |
echo "### Component Config & ACL Check" >> $GITHUB_STEP_SUMMARY
ERRORS=0
# Find all component manifests (XML with type="component")
COMP_MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '<extension[^>]*type="component"' {} ; 2>/dev/null || true)
if [ -z "$COMP_MANIFESTS" ]; then
echo "No component extensions found — skipping." >> $GITHUB_STEP_SUMMARY
else
for MANIFEST in $COMP_MANIFESTS; do
COMP_DIR=$(dirname "$MANIFEST")
COMP_NAME=$(basename "$COMP_DIR")
echo "Component: `${COMP_NAME}` (manifest: `${MANIFEST}`)" >> $GITHUB_STEP_SUMMARY
# Check access.xml exists
ACCESS_FILE=$(find "$COMP_DIR" -name "access.xml" -not -path "./.git/*" 2>/dev/null | head -1)
if [ -z "$ACCESS_FILE" ]; then
echo "- Missing `access.xml` — ACL permissions will not work." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
if command -v php &> /dev/null; then
if ! php -r "@simplexml_load_file('$ACCESS_FILE') ?: exit(1);" 2>/dev/null; then
echo "- `access.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
for ACTION in core.admin core.manage; do
if ! grep -q "name=\"${ACTION}\"" "$ACCESS_FILE" 2>/dev/null; then
echo "- `access.xml` missing required action: `${ACTION}`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
echo "- `access.xml`: valid" >> $GITHUB_STEP_SUMMARY
fi
fi
fi
# Check config.xml exists
CONFIG_FILE=$(find "$COMP_DIR" -name "config.xml" -not -path "./.git/*" 2>/dev/null | head -1)
if [ -z "$CONFIG_FILE" ]; then
echo "- Missing `config.xml` — component Options page will be empty." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
if command -v php &> /dev/null; then
if ! php -r "@simplexml_load_file('$CONFIG_FILE') ?: exit(1);" 2>/dev/null; then
echo "- `config.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "- `config.xml`: valid" >> $GITHUB_STEP_SUMMARY
fi
fi
fi
done
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} config/ACL issue(s) found.**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Component config & ACL check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: SQL schema validation
run: |
echo "### SQL Schema Validation" >> $GITHUB_STEP_SUMMARY
ERRORS=0
# Find SQL files in source/htdocs
SQL_FILES=$(find . -name "*.sql" -path "*/sql/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
if [ -z "$SQL_FILES" ]; then
echo "No SQL files found — skipping." >> $GITHUB_STEP_SUMMARY
else
echo "Found $(echo "$SQL_FILES" | wc -l) SQL file(s)" >> $GITHUB_STEP_SUMMARY
for FILE in $SQL_FILES; do
# Basic syntax check: balanced parentheses, no empty files
SIZE=$(wc -c < "$FILE" | tr -d ' ')
if [ "$SIZE" -eq 0 ]; then
echo "- Empty SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
continue
fi
# Check for common SQL errors
if grep -qP '^\s*$' "$FILE" && [ "$SIZE" -lt 5 ]; then
echo "- Whitespace-only SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
continue
fi
echo "- \`${FILE}\`: ${SIZE} bytes" >> $GITHUB_STEP_SUMMARY
done
# Check update SQL files follow version numbering pattern
UPDATE_DIR=$(find . -path "*/sql/updates/mysql" -type d -not -path "./.git/*" 2>/dev/null | head -1)
if [ -n "$UPDATE_DIR" ]; then
BAD_NAMES=0
for UFILE in "$UPDATE_DIR"/*.sql; do
[ ! -f "$UFILE" ] && continue
BASENAME=$(basename "$UFILE" .sql)
if ! echo "$BASENAME" | grep -qP '^\d+\.\d+\.\d+'; then
echo "- Update file \`${UFILE}\` does not follow version naming (expected X.Y.Z.sql)" >> $GITHUB_STEP_SUMMARY
BAD_NAMES=$((BAD_NAMES + 1))
fi
done
if [ "$BAD_NAMES" -gt 0 ]; then
ERRORS=$((ERRORS + BAD_NAMES))
fi
fi
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} SQL issue(s) found.**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**SQL schema validation passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Manifest file references check
run: |
echo "### Manifest File References" >> $GITHUB_STEP_SUMMARY
ERRORS=0
MANIFEST=""
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
MANIFEST="$XML_FILE"
break
fi
done
if [ -z "$MANIFEST" ]; then
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
else
MANIFEST_DIR=$(dirname "$MANIFEST")
# Check <filename> references
FILENAMES=$(grep -oP '<filename[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
for F in $FILENAMES; do
if [ ! -f "${MANIFEST_DIR}/${F}" ] && [ ! -d "${MANIFEST_DIR}/${F}" ]; then
echo "- Missing: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
# Check <folder> references
FOLDERS=$(grep -oP '<folder[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
for F in $FOLDERS; do
if [ ! -d "${MANIFEST_DIR}/${F}" ]; then
echo "- Missing folder: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
# Check <file> references in package manifests (ZIP files won't exist in source)
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
if [ "$EXT_TYPE" != "package" ]; then
FILES=$(grep -oP '<file[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
for F in $FILES; do
if [ ! -f "${MANIFEST_DIR}/${F}" ]; then
echo "- Missing file: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
fi
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} missing file reference(s).**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Manifest file references check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Form XML validation
run: |
echo "### Form XML Validation" >> $GITHUB_STEP_SUMMARY
ERRORS=0
FORM_FILES=$(find . -name "*.xml" -path "*/forms/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
if [ -z "$FORM_FILES" ]; then
echo "No form XML files found — skipping." >> $GITHUB_STEP_SUMMARY
else
echo "Found $(echo "$FORM_FILES" | wc -l) form file(s)" >> $GITHUB_STEP_SUMMARY
for FILE in $FORM_FILES; do
if command -v php &> /dev/null; then
if ! php -r "@simplexml_load_file('$FILE') ?: exit(1);" 2>/dev/null; then
echo "- \`${FILE}\`: malformed XML" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
# Check for valid Joomla form structure
if ! grep -qE '<form|<field|<fieldset' "$FILE" 2>/dev/null; then
echo "- \`${FILE}\`: no \`<form>\`, \`<field>\`, or \`<fieldset>\` elements found" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "- \`${FILE}\`: valid" >> $GITHUB_STEP_SUMMARY
fi
fi
fi
done
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} form XML issue(s).**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Form XML validation passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Deprecated Joomla API check
continue-on-error: true
run: |
echo "### Deprecated Joomla API Check" >> $GITHUB_STEP_SUMMARY
WARNINGS=0
SRC_DIR=""
for DIR in source/ src/ htdocs/; do
[ -d "$DIR" ] && SRC_DIR="$DIR" && break
done
if [ -z "$SRC_DIR" ]; then
echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY
else
# Joomla 3/4 deprecated patterns that break in Joomla 6
PATTERNS=(
'JFactory::'
'JText::'
'JHtml::'
'JRoute::'
'JUri::'
'JLog::'
'JTable::'
'JInput'
'CMSFactory::\$application'
'JApplicationCms'
)
for PATTERN in "${PATTERNS[@]}"; do
HITS=$(grep -rnl "$PATTERN" "$SRC_DIR" --include="*.php" 2>/dev/null || true)
if [ -n "$HITS" ]; then
COUNT=$(echo "$HITS" | wc -l)
echo "- \`${PATTERN}\` found in ${COUNT} file(s)" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + COUNT))
fi
done
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$WARNINGS" -gt 0 ]; then
echo "**${WARNINGS} deprecated API usage(s) found.** These will break in Joomla 6." >> $GITHUB_STEP_SUMMARY
else
echo "**No deprecated APIs found.**" >> $GITHUB_STEP_SUMMARY
fi
fi
- name: Template output escaping check
continue-on-error: true
run: |
echo "### Template Output Escaping" >> $GITHUB_STEP_SUMMARY
WARNINGS=0
TMPL_FILES=$(find . -name "*.php" -path "*/tmpl/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
if [ -z "$TMPL_FILES" ]; then
echo "No template files found — skipping." >> $GITHUB_STEP_SUMMARY
else
echo "Found $(echo "$TMPL_FILES" | wc -l) template file(s)" >> $GITHUB_STEP_SUMMARY
for FILE in $TMPL_FILES; do
# Check for unescaped output: <?= $var ?> or echo $var without escape()
UNESCAPED=$(grep -nP '<\?=\s*\$(?!this->escape)' "$FILE" 2>/dev/null || true)
if [ -n "$UNESCAPED" ]; then
HITS=$(echo "$UNESCAPED" | wc -l)
echo "- \`${FILE}\`: ${HITS} unescaped \`<?= \$var ?>\` output(s) — use \`<?= \$this->escape(\$var) ?>\`" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + HITS))
fi
# Check for echo without escaping in template context
RAW_ECHO=$(grep -nP '^\s*echo\s+\$(?!this->escape)' "$FILE" 2>/dev/null || true)
if [ -n "$RAW_ECHO" ]; then
HITS=$(echo "$RAW_ECHO" | wc -l)
echo "- \`${FILE}\`: ${HITS} raw \`echo \$var\` — consider \`echo \$this->escape(\$var)\`" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + HITS))
fi
done
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$WARNINGS" -gt 0 ]; then
echo "**${WARNINGS} potential XSS risk(s) in templates.** Review unescaped output." >> $GITHUB_STEP_SUMMARY
else
echo "**All template output appears properly escaped.**" >> $GITHUB_STEP_SUMMARY
fi
fi
- name: Namespace consistency check
run: |
echo "### Namespace Consistency" >> $GITHUB_STEP_SUMMARY
ERRORS=0
# Find component/plugin manifests with <namespace> tags
MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '<namespace' {} \; 2>/dev/null || true)
if [ -z "$MANIFESTS" ]; then
echo "No manifests with \`<namespace>\` found — skipping." >> $GITHUB_STEP_SUMMARY
else
for MANIFEST in $MANIFESTS; do
NS_PATH=$(grep -oP '<namespace[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1)
[ -z "$NS_PATH" ] && continue
MANIFEST_DIR=$(dirname "$MANIFEST")
echo "Manifest: \`${MANIFEST}\` → namespace \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY
# Check PHP files have matching namespace
while IFS= read -r -d '' PHP_FILE; do
FILE_NS=$(grep -oP '^\s*namespace\s+\K[^;]+' "$PHP_FILE" 2>/dev/null | head -1)
[ -z "$FILE_NS" ] && continue
# Namespace should start with the manifest namespace path
if ! echo "$FILE_NS" | grep -qF "${NS_PATH}"; then
echo "- \`${PHP_FILE}\`: namespace \`${FILE_NS}\` doesn't match manifest \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done < <(find "$MANIFEST_DIR" -name "*.php" -path "*/src/*" -not -path "./vendor/*" -print0 2>/dev/null)
done
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} namespace mismatch(es).**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Namespace consistency check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: SPDX license header check
continue-on-error: true
run: |
echo "### SPDX License Headers" >> $GITHUB_STEP_SUMMARY
MISSING=0
SRC_DIR=""
for DIR in source/ src/ htdocs/; do
[ -d "$DIR" ] && SRC_DIR="$DIR" && break
done
if [ -z "$SRC_DIR" ]; then
echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY
else
TOTAL=0
while IFS= read -r -d '' FILE; do
TOTAL=$((TOTAL + 1))
if ! head -10 "$FILE" | grep -qi "SPDX"; then
echo "- Missing SPDX header: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
MISSING=$((MISSING + 1))
fi
done < <(find "$SRC_DIR" -name "*.php" -not -path "./vendor/*" -print0)
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$MISSING" -gt 0 ]; then
echo "**${MISSING}/${TOTAL} PHP file(s) missing SPDX license header.**" >> $GITHUB_STEP_SUMMARY
else
echo "**All ${TOTAL} PHP files have SPDX headers.**" >> $GITHUB_STEP_SUMMARY
fi
fi
- name: Service provider check
run: |
echo "### Service Provider Check" >> $GITHUB_STEP_SUMMARY
ERRORS=0
PROVIDERS=$(find . -name "provider.php" -path "*/services/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
if [ -z "$PROVIDERS" ]; then
echo "No service providers found — skipping." >> $GITHUB_STEP_SUMMARY
else
for FILE in $PROVIDERS; do
# Must return a ServiceProviderInterface
if ! grep -qP 'ServiceProviderInterface|ComponentInterface|MVCFactoryInterface|DispatcherInterface' "$FILE" 2>/dev/null; then
echo "- \`${FILE}\`: does not reference ServiceProviderInterface or component interfaces" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "- \`${FILE}\`: valid service provider" >> $GITHUB_STEP_SUMMARY
fi
# Must have return statement
if ! grep -qP '^\s*return\s+new\s+' "$FILE" 2>/dev/null; then
echo "- \`${FILE}\`: missing \`return new ...\` statement" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} service provider issue(s).**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Service provider check passed.**" >> $GITHUB_STEP_SUMMARY
fi
release-readiness: release-readiness:
name: Release Readiness Check name: Release Readiness Check
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.base_ref == 'main' if: github.event_name == 'pull_request' && github.base_ref == 'main'
continue-on-error: true
steps: steps:
- name: Checkout repository - name: Checkout repository
-76
View File
@@ -1,76 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
name: "Publish to Composer"
on:
push:
tags:
- 'v*'
- '[0-9]*.[0-9]*.[0-9]*'
release:
types: [published]
workflow_dispatch:
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
jobs:
publish:
name: Publish Package
runs-on: ubuntu-latest
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip publish]')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
- name: Install dependencies
run: composer install --no-dev --no-interaction --prefer-dist --quiet
- name: Determine version
id: version
run: |
VERSION=$(php -r "echo json_decode(file_get_contents('composer.json'))->version;")
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Package version: ${VERSION}"
# Gitea Composer Registry — auto-publishes from tags
# The tag push itself registers the package at:
# https://git.mokoconsulting.tech/api/packages/MokoConsulting/composer
- name: Verify Gitea registry
run: |
echo "Gitea Composer registry auto-publishes from tags."
echo "Package available at: ${GITEA_URL}/api/packages/MokoConsulting/composer"
echo "Install: composer require mokoconsulting/mokocli"
# Packagist — notify of new version
- name: Notify Packagist
if: secrets.PACKAGIST_TOKEN != ''
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "Notifying Packagist of version ${VERSION}..."
curl -sf -X POST \
-H "Content-Type: application/json" \
-d '{"repository":{"url":"https://git.mokoconsulting.tech/MokoConsulting/mokocli"}}' \
"https://packagist.org/api/update-package?username=mokoconsulting&apiToken=${{ secrets.PACKAGIST_TOKEN }}" \
&& echo "Packagist notified" \
|| echo "::warning::Packagist notification failed (package may not be registered yet)"
- name: Summary
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "## Composer Package Published" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Registry | Status |" >> $GITHUB_STEP_SUMMARY
echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Gitea | \`composer require mokoconsulting/mokocli:${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Packagist | \`composer require mokoconsulting/mokocli\` |" >> $GITHUB_STEP_SUMMARY
+4
View File
@@ -25,6 +25,10 @@
name: "Universal: Secret Scanning" name: "Universal: Secret Scanning"
on: on:
pull_request:
branches:
- main
- 'dev/**'
schedule: schedule:
- cron: '0 5 * * 1' # Weekly Monday 05:00 UTC - cron: '0 5 * * 1' # Weekly Monday 05:00 UTC
workflow_dispatch: workflow_dispatch:
+3 -3
View File
@@ -4,8 +4,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation # INGROUP: mokoplatform.Automation
# VERSION: 01.04.09 # VERSION: 01.02.00
# BRIEF: Auto-create feature branch when an issue is opened # BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch" name: "Universal: Issue Branch"
@@ -28,7 +28,7 @@ jobs:
steps: steps:
- name: Create branch and comment - name: Create branch and comment
run: | run: |
TOKEN="${{ secrets.GA_TOKEN }}" TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}" API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
ISSUE_NUM="${{ github.event.issue.number }}" ISSUE_NUM="${{ github.event.issue.number }}"
ISSUE_TITLE="${{ github.event.issue.title }}" ISSUE_TITLE="${{ github.event.issue.title }}"
+35 -34
View File
@@ -4,8 +4,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.CI # INGROUP: mokoplatform.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform # REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokoplatform
# PATH: /templates/workflows/universal/pr-check.yml.template # PATH: /templates/workflows/universal/pr-check.yml.template
# VERSION: 09.23.00 # VERSION: 09.23.00
# BRIEF: PR gate — branch policy + code validation before merge # BRIEF: PR gate — branch policy + code validation before merge
@@ -96,32 +96,6 @@ jobs:
echo "Branch policy: OK (${HEAD} → ${BASE})" echo "Branch policy: OK (${HEAD} → ${BASE})"
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
# ── Secret Scanning ──────────────────────────────────────────────────
gitleaks:
name: Secret Scan
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Gitleaks
run: |
GITLEAKS_VERSION="8.21.2"
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar -xz -C /usr/local/bin gitleaks
- name: Scan PR commits for secrets
run: |
if gitleaks detect --source . --verbose \
--log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} 2>&1; then
echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
else
echo "::error::Potential secrets detected in PR commits"
exit 1
fi
# ── Code Validation ──────────────────────────────────────────────────── # ── Code Validation ────────────────────────────────────────────────────
validate: validate:
name: Validate PR name: Validate PR
@@ -185,11 +159,11 @@ jobs:
echo "::error file=${file}::Missing JEXEC guard: ${file}" echo "::error file=${file}::Missing JEXEC guard: ${file}"
ERRORS=$((ERRORS + 1)) ERRORS=$((ERRORS + 1))
fi fi
done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0) done < <(find . -name "*.php" \( -path "*/source/*" -o -path "*/src/*" \) -not -path "./.git/*" -not -path "./vendor/*" -print0)
if [ "$ERRORS" -gt 0 ]; then if [ "$ERRORS" -gt 0 ]; then
echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard" echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard"
echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY
echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY echo "${ERRORS} file(s) are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY
exit 1 exit 1
fi fi
echo "JEXEC guard: OK" echo "JEXEC guard: OK"
@@ -198,7 +172,8 @@ jobs:
if: steps.platform.outputs.platform == 'joomla' if: steps.platform.outputs.platform == 'joomla'
run: | run: |
MISSING=0 MISSING=0
SOURCE_DIR="src" SOURCE_DIR="source"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && exit 0 [ ! -d "$SOURCE_DIR" ] && exit 0
while IFS= read -r dir; do while IFS= read -r dir; do
if [ ! -f "${dir}/index.html" ]; then if [ ! -f "${dir}/index.html" ]; then
@@ -246,7 +221,7 @@ jobs:
echo "joomla.asset.json: valid" echo "joomla.asset.json: valid"
fi fi
# Validate all XML files in src/ are well-formed # Validate all XML files in source/src are well-formed
XML_ERRORS=0 XML_ERRORS=0
if command -v php &> /dev/null; then if command -v php &> /dev/null; then
while IFS= read -r -d '' xmlfile; do while IFS= read -r -d '' xmlfile; do
@@ -475,12 +450,38 @@ jobs:
fi fi
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]" echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
- name: Require README and CHANGELOG in PR diff
run: |
BASE="${{ github.base_ref }}"
HEAD="${{ github.head_ref }}"
CHANGED=$(git diff --name-only "origin/${BASE}...HEAD" 2>/dev/null || git diff --name-only HEAD~1 2>/dev/null || echo "")
SOURCE_CHANGED=$(echo "$CHANGED" | grep -E '^source/|^src/' || true)
if [ -z "$SOURCE_CHANGED" ]; then
echo "No source changes — skipping README/CHANGELOG diff check"
exit 0
fi
ERRORS=0
if ! echo "$CHANGED" | grep -q '^CHANGELOG.md$'; then
echo "::error::Source code was modified but CHANGELOG.md was not updated."
ERRORS=$((ERRORS + 1))
fi
if ! echo "$CHANGED" | grep -q '^README.md$'; then
echo "::warning::Source code was modified but README.md was not updated."
fi
if [ "$ERRORS" -gt 0 ]; then
echo "## Documentation Check: Failed" >> $GITHUB_STEP_SUMMARY
echo "Source code was modified but CHANGELOG.md was not updated." >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Documentation diff check: OK"
- name: Verify package source - name: Verify package source
run: | run: |
SOURCE_DIR="src" SOURCE_DIR="source"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs" [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ ! -d "$SOURCE_DIR" ]; then if [ ! -d "$SOURCE_DIR" ]; then
echo "::warning::No src/ or htdocs/ directory" echo "::warning::No source/, src/, or htdocs/ directory"
exit 0 exit 0
fi fi
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l) FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
@@ -1,71 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Validation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /templates/workflows/joomla/pr-metadata-check.yml.template
# VERSION: 01.00.00
# BRIEF: Validate MokoGitea metadata matches Joomla extension manifest on PRs
name: "Joomla: Metadata Validation"
on:
pull_request:
types: [opened, synchronize, reopened, converted_to_draft, ready_for_review]
permissions:
contents: read
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
jobs:
validate-metadata:
name: "Validate Joomla Metadata"
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup mokocli tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: |
if [ -f /opt/mokocli/cli/joomla_metadata_validate.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
echo Using pre-installed /opt/mokocli
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
else
echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
fi
rm -rf /tmp/mokocli
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
fi
- name: Validate metadata against Joomla manifest
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
php ${MOKO_CLI}/joomla_metadata_validate.php \
--path . \
--token "${GITEA_TOKEN}" \
--org "${GITEA_ORG}" \
--repo "${GITEA_REPO}" \
--api-base "${GITEA_URL}/api/v1" \
--ci
if [ $? -ne 0 ]; then
echo "::error::Joomla metadata mismatch — update delivery will fail. Run 'php cli/joomla_metadata_validate.php' locally to see details."
exit 1
fi
+27 -36
View File
@@ -4,26 +4,23 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Release # INGROUP: mokoplatform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli # REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
# PATH: /templates/workflows/universal/pre-release.yml.template # PATH: /templates/workflows/universal/pre-release.yml.template
# VERSION: 05.01.00 # VERSION: 05.01.00
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches # BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
name: "Universal: Pre-Release" name: "Universal: Pre-Release"
on: on:
push: pull_request:
types: [closed]
branches: branches:
- dev - dev
- 'fix/**' pull_request_target:
- 'patch/**' types: [synchronize, opened, reopened]
- 'hotfix/**' branches:
- 'bugfix/**' - main
- 'chore/**'
- alpha
- beta
- rc
workflow_dispatch: workflow_dispatch:
inputs: inputs:
stability: stability:
@@ -46,11 +43,12 @@ env:
jobs: jobs:
build: build:
name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})" name: "Build Pre-Release (${{ inputs.stability || 'development' }})"
runs-on: release runs-on: release
if: >- if: >-
github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_dispatch' ||
github.event_name == 'push' (github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev') ||
(github.event_name == 'pull_request_target' && github.event.pull_request.base.ref == 'main')
steps: steps:
- name: Checkout - name: Checkout
@@ -58,47 +56,40 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.MOKOGITEA_TOKEN }} token: ${{ secrets.MOKOGITEA_TOKEN }}
ref: ${{ github.ref_name }} ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || '' }}
- name: Setup mokocli tools - name: Setup mokoplatform tools
env: env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: | run: |
# Use pre-installed /opt/mokocli if available (updated by cron every 6h) # Use pre-installed /opt/mokoplatform if available (updated by cron every 6h)
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/cli/manifest_element.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then if [ -f /opt/mokoplatform/cli/version_bump.php ] && [ -f /opt/mokoplatform/cli/manifest_element.php ] && [ -f /opt/mokoplatform/vendor/autoload.php ]; then
echo Using pre-installed /opt/mokocli echo Using pre-installed /opt/mokoplatform
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV echo MOKO_CLI=/opt/mokoplatform/cli >> $GITHUB_ENV
else else
echo Falling back to fresh clone echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; then if ! command -v composer > /dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1 sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
fi fi
rm -rf /tmp/mokocli rm -rf /tmp/mokoplatform-api
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokoplatform.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokoplatform-api
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet cd /tmp/mokoplatform-api && composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV echo MOKO_CLI=/tmp/mokoplatform-api/cli >> $GITHUB_ENV
fi fi
- name: Detect platform - name: Detect platform
id: platform id: platform
run: | run: |
# Auto-detect and update platform if not set in manifest
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
php ${MOKO_CLI}/manifest_read.php --path . --github-output php ${MOKO_CLI}/manifest_read.php --path . --github-output
- name: Resolve metadata and bump version - name: Resolve metadata and bump version
id: meta id: meta
run: | run: |
# Auto-detect stability from branch name on push, or use input on dispatch # Auto-detect stability: RC for PRs targeting main, else use input or default to development
if [ "${{ github.event_name }}" = "push" ]; then if [ "${{ github.event_name }}" = "pull_request_target" ] && [ "${{ github.event.pull_request.base.ref }}" = "main" ]; then
case "${{ github.ref_name }}" in STABILITY="release-candidate"
rc) STABILITY="release-candidate" ;;
alpha) STABILITY="alpha" ;;
beta) STABILITY="beta" ;;
*) STABILITY="development" ;;
esac
else else
STABILITY="${{ inputs.stability || 'development' }}" STABILITY="${{ inputs.stability || 'development' }}"
fi fi
@@ -173,7 +164,7 @@ jobs:
php ${MOKO_CLI}/release_create.php \ php ${MOKO_CLI}/release_create.php \
--path . --version "$VERSION" --tag "$TAG" \ --path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease --repo "${GITEA_REPO}" --branch dev --prerelease
- name: Update release notes from CHANGELOG.md - name: Update release notes from CHANGELOG.md
run: | run: |
-66
View File
@@ -1,66 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/rc-revert.yml
# VERSION: 09.23.00
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
name: "RC Revert"
on:
pull_request:
types: [closed]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
revert:
name: Rename rc/ back to dev/
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == false &&
startsWith(github.event.pull_request.head.ref, 'rc/')
steps:
- name: Rename branch
run: |
BRANCH="${{ github.event.pull_request.head.ref }}"
SUFFIX="${BRANCH#rc/}"
DEV_BRANCH="dev/${SUFFIX}"
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Create dev/ branch from rc/ branch
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \
"${API}" 2>/dev/null || true)
if [ "$STATUS" = "201" ]; then
echo "Created branch: ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
else
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"
exit 1
fi
# Delete rc/ branch
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${API}/${ENCODED}" 2>/dev/null || true)
if [ "$STATUS" = "204" ]; then
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
else
echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})"
fi
echo "### RC Reverted" >> $GITHUB_STEP_SUMMARY
echo "${BRANCH} → ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
+9 -8
View File
@@ -7,8 +7,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Validation # INGROUP: mokoplatform.Validation
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli # REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokoplatform
# PATH: /templates/workflows/joomla/repo_health.yml.template # PATH: /templates/workflows/joomla/repo_health.yml.template
# VERSION: 09.23.00 # VERSION: 09.23.00
# BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts. # BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts.
@@ -33,8 +33,7 @@ on:
- scripts - scripts
- repo - repo
pull_request: pull_request:
branches: push:
- main
permissions: permissions:
contents: read contents: read
@@ -297,17 +296,19 @@ jobs:
missing_required=() missing_required=()
missing_optional=() missing_optional=()
# Source directory: src/ or htdocs/ (either is valid for extension repos) # Source directory: source/, src/, or htdocs/ (any is valid for extension repos)
SOURCE_DIR="" SOURCE_DIR=""
if [ -d "src" ]; then if [ -d "source" ]; then
SOURCE_DIR="source"
elif [ -d "src" ]; then
SOURCE_DIR="src" SOURCE_DIR="src"
elif [ -d "htdocs" ]; then elif [ -d "htdocs" ]; then
SOURCE_DIR="htdocs" SOURCE_DIR="htdocs"
elif [ -d "deploy" ] || [ -d "cli" ] || [ -d "monitoring" ]; then elif [ -d "deploy" ] || [ -d "cli" ] || [ -d "monitoring" ]; then
# Platform/tooling repos don't need src/ # Platform/tooling repos don't need source/
SOURCE_DIR="" SOURCE_DIR=""
else else
missing_required+=("src/ or htdocs/ (source directory required)") missing_required+=("source/ or src/ or htdocs/ (source directory required)")
fi fi
for item in "${required_artifacts[@]}"; do for item in "${required_artifacts[@]}"; do
@@ -1,73 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/workflow-sync-trigger.yml
# VERSION: 01.01.00
# BRIEF: Trigger workflow sync to live repos when a PR is merged to main
name: "Universal: Workflow Sync Trigger"
on:
pull_request:
types: [closed]
branches:
- main
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
sync:
name: Sync workflows to live repos
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == true &&
!contains(github.event.pull_request.title, '[skip sync]')
steps:
- name: Determine platform from repo name
id: platform
run: |
REPO="${{ github.event.repository.name }}"
case "$REPO" in
Template-Joomla) PLATFORM="joomla" ;;
Template-Dolibarr) PLATFORM="dolibarr" ;;
Template-Go) PLATFORM="go" ;;
Template-MCP) PLATFORM="mcp" ;;
Template-Generic) PLATFORM="" ;;
*) PLATFORM="" ;;
esac
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
echo "Platform: ${PLATFORM:-all}"
- name: Clone mokocli
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
GITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
git clone --depth 1 "${GITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
- name: Install dependencies
run: |
cd /tmp/mokocli
composer install --no-dev --no-interaction --quiet 2>/dev/null || true
- name: Run workflow sync
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
ARGS="--token ${MOKOGITEA_TOKEN}"
ARGS="${ARGS} --org ${{ vars.GITEA_ORG || github.repository_owner }}"
ARGS="${ARGS} --phase repos"
PLATFORM="${{ steps.platform.outputs.platform }}"
if [ -n "$PLATFORM" ]; then
ARGS="${ARGS} --platform-filter ${PLATFORM}"
fi
php /tmp/mokocli/cli/workflow_sync.php ${ARGS}
+7 -42
View File
@@ -1,49 +1,9 @@
# Changelog # Changelog
## [Unreleased] ## [Unreleased]
### Added
- **Instagram plugin**: Cross-post to Instagram via Meta Content Publishing API (2-step container flow)
- **YouTube plugin**: Cross-post to YouTube via Data API v3 channel bulletins
- **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
### Changed <!-- VERSION: 01.02.00 -->
- **Default templates**: Updated to use platform-specific placeholders (social/short/chat/email) with graceful fallback
### Fixed
- **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
## [01.04.01] --- 2026-06-21
## [01.04.01] --- 2026-06-21
## [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.09 -->
All notable changes to MokoSuiteCross will be documented in this file. All notable changes to MokoSuiteCross will be documented in this file.
@@ -269,3 +229,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- Perfect Publisher Pro migration tool in installer script - Perfect Publisher Pro migration tool in installer script
- Message template system with per-platform placeholders - Message template system with per-platform placeholders
- Post queue with scheduled posting, retry logic, and delivery tracking - Post queue with scheduled posting, retry logic, and delivery tracking
## [01.00] - 2026-05-28
### Added
- Initial release
+203
View File
@@ -0,0 +1,203 @@
# 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
+3 -10
View File
@@ -1,6 +1,6 @@
# MokoSuiteCross # MokoSuiteCross
<!-- VERSION: 01.04.09 --> <!-- VERSION: 01.02.00 -->
Cross-posting Joomla content to social media, email marketing, and chat platforms for Joomla 5/6. Cross-posting Joomla content to social media, email marketing, and chat platforms for Joomla 5/6.
@@ -14,27 +14,20 @@ MokoSuiteCross automatically publishes your Joomla articles to multiple platform
- **Plugin-based services** — Each platform is a separate plugin; install only what you need - **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 - **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 - **Post queue** — Scheduled posting, retry on failure, detailed delivery logs
- **Message templates** — Customize post format per platform with placeholders ({title}, {url}, {social}, {short}, {chat}, {email_subject}, {email_body}, {field:xxx}) - **Message templates** — Customize post format per platform with placeholders ({title}, {url}, {intro}, {image}, {tags}, {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 - **Post history** — Track what was posted where, with platform response data
- **Evergreen re-sharing** — Automatically re-share articles on a configurable interval - **Evergreen re-sharing** — Automatically re-share articles on a configurable interval
- **Category routing** — Route articles to specific services by Joomla category - **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 - **Migration** — Import settings from Perfect Publisher Pro
- **REST API** — WebServices plugin for headless/external integration - **REST API** — WebServices plugin for headless/external integration
### Supported Platforms (38) ### Supported Platforms (36)
#### Social Media #### Social Media
| Platform | Plugin | Status | | Platform | Plugin | Status |
|----------|--------|--------| |----------|--------|--------|
| Facebook / Meta | `plg_mokosuitecross_facebook` | Implemented | | Facebook / Meta | `plg_mokosuitecross_facebook` | Implemented |
| X / Twitter | `plg_mokosuitecross_twitter` | Implemented | | X / Twitter | `plg_mokosuitecross_twitter` | Implemented |
| Instagram | `plg_mokosuitecross_instagram` | Implemented |
| YouTube | `plg_mokosuitecross_youtube` | Implemented |
| LinkedIn | `plg_mokosuitecross_linkedin` | Implemented | | LinkedIn | `plg_mokosuitecross_linkedin` | Implemented |
| Mastodon | `plg_mokosuitecross_mastodon` | Implemented | | Mastodon | `plg_mokosuitecross_mastodon` | Implemented |
| Bluesky | `plg_mokosuitecross_bluesky` | Implemented | | Bluesky | `plg_mokosuitecross_bluesky` | Implemented |
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env bash
# ============================================================================
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Automation.CI
# INGROUP: moko-platform.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /automation/ci-issue-reporter.sh
# VERSION: 09.23.00
# BRIEF: Creates or updates a Gitea issue when a CI gate fails.
# Deduplicates by searching open issues with the "ci-auto" label
# whose title matches the gate. If a matching issue exists, a comment
# is appended instead of opening a duplicate.
# ============================================================================
set -euo pipefail
# ── Defaults ────────────────────────────────────────────────────────────────
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
GITEA_TOKEN="${GITEA_TOKEN:-}"
REPO="${GITHUB_REPOSITORY:-}"
RUN_URL="${GITHUB_SERVER_URL:-${GITEA_URL}}/${REPO}/actions/runs/${GITHUB_RUN_ID:-0}"
LABEL_NAME="ci-auto"
LABEL_COLOR="#e11d48"
GATE=""
DETAILS=""
SEVERITY="error"
WORKFLOW=""
# ── Parse arguments ─────────────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: ci-issue-reporter.sh --gate NAME --details TEXT [OPTIONS]
Required:
--gate CI gate name (e.g. "Code Quality", "Self-Health")
--details Human-readable failure description
Optional:
--severity "error" (default) or "warning"
--workflow Workflow name for the issue title
--repo owner/repo (default: \$GITHUB_REPOSITORY)
--run-url URL to the CI run (auto-detected from env)
--token Gitea API token (default: \$GITEA_TOKEN)
--url Gitea base URL (default: \$GITEA_URL)
EOF
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--gate) GATE="$2"; shift 2 ;;
--details) DETAILS="$2"; shift 2 ;;
--severity) SEVERITY="$2"; shift 2 ;;
--workflow) WORKFLOW="$2"; shift 2 ;;
--repo) REPO="$2"; shift 2 ;;
--run-url) RUN_URL="$2"; shift 2 ;;
--token) GITEA_TOKEN="$2"; shift 2 ;;
--url) GITEA_URL="$2"; shift 2 ;;
-h|--help) usage ;;
*) echo "Unknown option: $1"; usage ;;
esac
done
[[ -z "$GATE" ]] && { echo "ERROR: --gate is required"; usage; }
[[ -z "$DETAILS" ]] && { echo "ERROR: --details is required"; usage; }
[[ -z "$GITEA_TOKEN" ]] && { echo "ERROR: GITEA_TOKEN not set"; exit 1; }
[[ -z "$REPO" ]] && { echo "ERROR: GITHUB_REPOSITORY not set"; exit 1; }
API="${GITEA_URL}/api/v1/repos/${REPO}"
# ── Build title ─────────────────────────────────────────────────────────────
if [[ -n "$WORKFLOW" ]]; then
TITLE="[CI] ${WORKFLOW}: ${GATE} failed"
else
TITLE="[CI] ${GATE} failed"
fi
# ── Ensure label exists ─────────────────────────────────────────────────────
ensure_label() {
local exists
exists=$(curl -sf -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null || echo "000")
if [[ "$exists" == "200" ]]; then
# Check if label already exists
local found
found=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null \
| grep -o "\"name\":\"${LABEL_NAME}\"" || true)
if [[ -z "$found" ]]; then
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/labels" \
-d "{\"name\":\"${LABEL_NAME}\",\"color\":\"${LABEL_COLOR}\",\"description\":\"Auto-created by CI issue reporter\"}" \
> /dev/null 2>&1 || true
fi
fi
}
# ── Search for existing open issue ──────────────────────────────────────────
find_existing_issue() {
# URL-encode the gate name for the query
local query
query=$(printf '%s' "[CI] ${GATE}" | sed 's/ /%20/g; s/\[/%5B/g; s/\]/%5D/g')
local response
response=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/issues?type=issues&state=open&labels=${LABEL_NAME}&q=${query}&limit=5" \
2>/dev/null || echo "[]")
# Extract the first matching issue number
echo "$response" \
| grep -oP '"number":\s*\K[0-9]+' \
| head -1
}
# ── Build issue body ────────────────────────────────────────────────────────
build_body() {
local severity_badge
if [[ "$SEVERITY" == "error" ]]; then
severity_badge="**Severity:** Error"
else
severity_badge="**Severity:** Warning"
fi
cat <<BODY
## CI Gate Failure: ${GATE}
${severity_badge}
**Workflow:** ${WORKFLOW:-unknown}
**Branch:** ${GITHUB_REF_NAME:-unknown}
**Commit:** \`${GITHUB_SHA:0:8}\`
**Run:** [View CI run](${RUN_URL})
### Details
${DETAILS}
### Resolution
Fix the issue described above and push a new commit. This issue will be closed automatically when the gate passes, or can be closed manually.
---
*Auto-created by [ci-issue-reporter](${GITEA_URL}/${REPO}/src/branch/main/automation/ci-issue-reporter.sh)*
BODY
}
# ── Build comment body (for existing issues) ────────────────────────────────
build_comment() {
cat <<COMMENT
### CI failure recurrence
**Branch:** ${GITHUB_REF_NAME:-unknown}
**Commit:** \`${GITHUB_SHA:0:8}\`
**Run:** [View CI run](${RUN_URL})
${DETAILS}
COMMENT
}
# ── Main ────────────────────────────────────────────────────────────────────
ensure_label
EXISTING=$(find_existing_issue)
if [[ -n "$EXISTING" ]]; then
# Append comment to existing issue
COMMENT_BODY=$(build_comment)
COMMENT_JSON=$(printf '%s' "$COMMENT_BODY" | python3 -c "
import sys, json
print(json.dumps({'body': sys.stdin.read()}))" 2>/dev/null)
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${EXISTING}/comments" \
-d "${COMMENT_JSON}" 2>/dev/null || echo "000")
if [[ "$HTTP" == "201" ]]; then
echo "Commented on existing issue #${EXISTING}"
else
echo "WARNING: Failed to comment on issue #${EXISTING} (HTTP ${HTTP})"
fi
else
# Create new issue
ISSUE_BODY=$(build_body)
ISSUE_JSON=$(python3 -c "
import sys, json
body = sys.stdin.read()
print(json.dumps({
'title': sys.argv[1],
'body': body,
'labels': []
}))" "$TITLE" <<< "$ISSUE_BODY" 2>/dev/null)
# Create the issue
RESPONSE=$(curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues" \
-d "${ISSUE_JSON}" 2>/dev/null || echo "{}")
ISSUE_NUM=$(echo "$RESPONSE" | grep -oP '"number":\s*\K[0-9]+' | head -1)
if [[ -n "$ISSUE_NUM" ]]; then
# Apply label (separate call — more reliable across Gitea versions)
LABEL_ID=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null \
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"${LABEL_NAME}\")" \
| head -1 || true)
if [[ -n "$LABEL_ID" ]]; then
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${ISSUE_NUM}/labels" \
-d "{\"labels\":[${LABEL_ID}]}" \
> /dev/null 2>&1 || true
fi
echo "Created issue #${ISSUE_NUM}: ${TITLE}"
else
echo "WARNING: Failed to create issue"
echo "Response: ${RESPONSE}"
fi
fi
@@ -24,17 +24,6 @@
<option value="0">JNO</option> <option value="0">JNO</option>
</field> </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 <field
name="retry_max" name="retry_max"
type="number" type="number"
@@ -75,51 +64,6 @@
/> />
</fieldset> </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"> <fieldset name="evergreen" label="COM_MOKOSUITECROSS_CONFIG_EVERGREEN">
<field <field
name="evergreen_enabled" name="evergreen_enabled"
@@ -476,19 +476,6 @@ 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." 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 ; 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="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." 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"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="component" method="upgrade"> <extension type="component" method="upgrade">
<name>com_mokosuitecross</name> <name>com_mokosuitecross</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -25,7 +25,7 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitecross_posts` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT, `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`article_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'FK to #__content.id', `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', `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, deleted', `status` varchar(20) NOT NULL DEFAULT 'queued' COMMENT 'queued, posting, posted, failed, scheduled',
`message` text NOT NULL COMMENT 'Rendered message sent to platform', `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_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', `platform_response` text NOT NULL COMMENT 'JSON — full API response from platform',
@@ -74,27 +74,25 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitecross_logs` (
-- Insert default templates -- Insert default templates
INSERT INTO `#__mokosuitecross_templates` (`service_type`, `title`, `template_body`, `published`, `ordering`, `created`) VALUES 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()), ('default', 'Default Template', '{title}\n\n{introtext}\n\n{url}', 1, 1, NOW()),
('twitter', 'Twitter/X Default', '{short}\n\n{url}', 1, 2, NOW()), ('twitter', 'Twitter/X Default', '{title}\n\n{url}', 1, 2, NOW()),
('mastodon', 'Mastodon Default', '{social}\n\n{url}\n\n{hashtags}', 1, 3, NOW()), ('mastodon', 'Mastodon Default', '{title}\n\n{introtext}\n\n{url}\n\n#Joomla', 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()), ('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{chat}\n\n<a href=\"{url}\">Read more</a>', 1, 5, 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{chat}\n\n{url}', 1, 6, NOW()), ('discord', 'Discord Default', '**{title}**\n\n{introtext}\n\n{url}', 1, 6, NOW()),
('slack', 'Slack Default', '*{title}*\n\n{chat}\n\n{url}', 1, 7, NOW()), ('slack', 'Slack Default', '*{title}*\n\n{introtext}\n\n{url}', 1, 7, NOW()),
('facebook', 'Facebook Default', '{social}\n\n{url}', 1, 8, NOW()), ('facebook', 'Facebook Default', '{title}\n\n{introtext}\n\n{url}', 1, 8, NOW()),
('linkedin', 'LinkedIn Default', '{social}\n\n{url}\n\n{hashtags}', 1, 9, NOW()), ('linkedin', 'LinkedIn Default', '{title}\n\n{introtext}\n\n{url}', 1, 9, NOW()),
('bluesky', 'Bluesky Default', '{short}\n\n{url}', 1, 10, NOW()), ('bluesky', 'Bluesky Default', '{title}\n\n{url}', 1, 10, NOW()),
('threads', 'Threads Default', '{social}\n\n{url}', 1, 11, NOW()), ('threads', 'Threads Default', '{title}\n\n{introtext}\n\n{url}', 1, 11, NOW()),
('teams', 'Teams Default', '**{title}**\n\n{chat}\n\n[Read more]({url})', 1, 12, NOW()), ('teams', 'Teams Default', '**{title}**\n\n{introtext}\n\n[Read more]({url})', 1, 12, NOW()),
('medium', 'Medium Default', '{title}\n\n{social}\n\n{url}', 1, 13, NOW()), ('medium', 'Medium Default', '{title}\n\n{introtext}\n\n{url}', 1, 13, NOW()),
('wordpress', 'WordPress Default', '{title}\n\n{introtext}\n\n{url}', 1, 14, NOW()), ('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()), ('webhook', 'Webhook Default', '{title}\n\n{introtext}\n\n{url}', 1, 15, NOW()),
('sendgrid', 'SendGrid Default', '<h1>{email_subject}</h1>\n{email_body}\n<p><a href=\"{url}\">Read more</a></p>', 1, 16, 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>{email_subject}</h1>\n{email_body}\n<p><a href=\"{url}\">Read more</a></p>', 1, 17, 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}: {short}', 1, 18, NOW()), ('ntfy', 'Ntfy Default', '{title}: {introtext}', 1, 18, NOW()),
('reddit', 'Reddit Default', '{title}', 1, 19, NOW()), ('reddit', 'Reddit Default', '{title}', 1, 19, NOW()),
('pinterest', 'Pinterest Default', '{title} - {social}', 1, 20, NOW()), ('pinterest', 'Pinterest Default', '{title} - {introtext}', 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` ( CREATE TABLE IF NOT EXISTS `#__mokosuitecross_category_rules` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT, `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
@@ -96,7 +96,7 @@ class ServiceController extends FormController
$app->mimeType = 'application/json'; $app->mimeType = 'application/json';
$app->setHeader('Content-Type', 'application/json; charset=utf-8'); $app->setHeader('Content-Type', 'application/json; charset=utf-8');
echo new JsonResponse(['error' => $e->getMessage()]); echo new JsonResponse($e);
} }
$app->close(); $app->close();
@@ -17,7 +17,6 @@ use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Factory; use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper; use Joomla\CMS\Plugin\PluginHelper;
use Joomla\CMS\Uri\Uri; use Joomla\CMS\Uri\Uri;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossDeleteInterface;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface; use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
/** /**
@@ -384,33 +383,11 @@ class CrossPostDispatcher
$authorName = $db->loadResult() ?: ''; $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 = ''; $introImage = '';
$images = json_decode($article->images ?? '{}');
switch ($imageMode) { if (!empty($images->image_intro)) {
case 'fulltext': $introImage = Uri::root() . ltrim($images->image_intro, '/');
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 = []; $tagNames = [];
@@ -433,54 +410,17 @@ class CrossPostDispatcher
return '#' . preg_replace('/\s+/', '', $tag); return '#' . preg_replace('/\s+/', '', $tag);
}, $tagNames)); }, $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 [ return [
'{title}' => $titleText, '{title}' => $article->title ?? '',
'{introtext}' => $introStripped, '{introtext}' => strip_tags(mb_substr($article->introtext ?? '', 0, 280)),
'{fulltext}' => strip_tags(mb_substr($article->fulltext ?? '', 0, 500)), '{fulltext}' => strip_tags(mb_substr($article->fulltext ?? '', 0, 500)),
'{url}' => $url, '{url}' => $url,
'{url_raw}' => $urlRaw, '{image}' => $introImage,
'{image}' => $introImage, '{category}' => $categoryName,
'{category}' => $categoryName, '{author}' => $authorName,
'{author}' => $authorName, '{date}' => Factory::getDate($article->publish_up ?? 'now')->format('Y-m-d'),
'{date}' => Factory::getDate($article->publish_up ?? 'now')->format('Y-m-d'), '{tags}' => $tagsComma,
'{tags}' => $tagsComma, '{hashtags}' => $hashtags,
'{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 ?? ''),
]; ];
} }
@@ -519,15 +459,6 @@ class CrossPostDispatcher
$message = str_replace(array_keys($replacements), array_values($replacements), $template); $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} // Resolve custom field placeholders: {field:field_name}
$message = preg_replace_callback('/\{field:([a-zA-Z0-9_-]+)\}/', function ($matches) use ($db, $article) { $message = preg_replace_callback('/\{field:([a-zA-Z0-9_-]+)\}/', function ($matches) use ($db, $article) {
$fieldName = $matches[1]; $fieldName = $matches[1];
@@ -547,82 +478,6 @@ class CrossPostDispatcher
/** /**
* Write an entry to the activity log. * 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 private static function log($db, ?int $postId, ?int $serviceId, string $level, string $message): void
{ {
$log = (object) [ $log = (object) [
@@ -1,35 +0,0 @@
<?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,23 +11,3 @@ 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="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_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_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"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="content" method="upgrade"> <extension type="plugin" group="content" method="upgrade">
<name>Content - MokoSuiteCross</name> <name>Content - MokoSuiteCross</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -140,71 +140,6 @@ class MokoSuiteCrossContent extends CMSPlugin implements SubscriberInterface
showon="mokosuitecross_skip:0[AND]mokosuitecross_evergreen:1" showon="mokosuitecross_skip:0[AND]mokosuitecross_evergreen:1"
/> />
</fieldset> </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> </fields>
</form> </form>
XML; XML;
@@ -390,28 +325,12 @@ XML;
$value = func_get_arg(2); $value = func_get_arg(2);
} }
if ($context !== 'com_content.article') { if ($context !== 'com_content.article' || $value !== 1) {
return; return;
} }
$params = ComponentHelper::getParams('com_mokosuitecross'); $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)) { if (!$params->get('auto_post_on_publish', 1)) {
return; return;
} }
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - ActivityPub (Fediverse)</name> <name>MokoSuiteCross - ActivityPub (Fediverse)</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Google Blogger</name> <name>MokoSuiteCross - Google Blogger</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Bluesky</name> <name>MokoSuiteCross - Bluesky</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -14,7 +14,6 @@ namespace Joomla\Plugin\MokoSuiteCross\Bluesky\Extension;
defined('_JEXEC') or die; defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin; use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossDeleteInterface;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface; use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
use Joomla\Event\SubscriberInterface; use Joomla\Event\SubscriberInterface;
@@ -30,7 +29,7 @@ use Joomla\Event\SubscriberInterface;
* "pds_url": "https://bsky.social" // Optional, defaults to bsky.social * "pds_url": "https://bsky.social" // Optional, defaults to bsky.social
* } * }
*/ */
class BlueskyService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface, MokoSuiteCrossDeleteInterface class BlueskyService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface
{ {
public static function getSubscribedEvents(): array public static function getSubscribedEvents(): array
{ {
@@ -128,7 +127,7 @@ class BlueskyService extends CMSPlugin implements SubscriberInterface, MokoSuite
private function authenticateWithCache(string $pds, string $handle, string $appPwd): array private function authenticateWithCache(string $pds, string $handle, string $appPwd): array
{ {
$cacheKey = hash('sha256', $pds . $handle); $cacheKey = md5($pds . $handle);
if (isset(self::$sessionCache[$cacheKey])) { if (isset(self::$sessionCache[$cacheKey])) {
$cached = self::$sessionCache[$cacheKey]; $cached = self::$sessionCache[$cacheKey];
@@ -176,69 +175,6 @@ class BlueskyService extends CMSPlugin implements SubscriberInterface, MokoSuite
return json_decode($response, true) ?: []; return json_decode($response, true) ?: [];
} }
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 public function getSupportedMediaTypes(): array
{ {
return ['image']; return ['image'];
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Brevo (Sendinblue)</name> <name>MokoSuiteCross - Brevo (Sendinblue)</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -70,6 +70,15 @@ class BrevoService extends CMSPlugin implements SubscriberInterface, MokoSuiteCr
CURLOPT_TIMEOUT => 30, 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); $response = curl_exec($ch);
if ($response === false) { if ($response === false) {
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Constant Contact</name> <name>MokoSuiteCross - Constant Contact</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -73,6 +73,15 @@ class ConstantcontactService extends CMSPlugin implements SubscriberInterface, M
CURLOPT_TIMEOUT => 30, 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); $response = curl_exec($ch);
if ($response === false) { if ($response === false) {
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - ConvertKit</name> <name>MokoSuiteCross - ConvertKit</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -66,6 +66,15 @@ class ConvertkitService extends CMSPlugin implements SubscriberInterface, MokoSu
CURLOPT_TIMEOUT => 30, 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); $response = curl_exec($ch);
if ($response === false) { if ($response === false) {
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Dev.to</name> <name>MokoSuiteCross - Dev.to</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Discord</name> <name>MokoSuiteCross - Discord</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -14,7 +14,6 @@ namespace Joomla\Plugin\MokoSuiteCross\Discord\Extension;
defined('_JEXEC') or die; defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin; use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossDeleteInterface;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface; use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
use Joomla\Event\SubscriberInterface; use Joomla\Event\SubscriberInterface;
@@ -31,7 +30,7 @@ use Joomla\Event\SubscriberInterface;
* "webhook_url": "https://discord.com/api/webhooks/..." // Only for custom mode * "webhook_url": "https://discord.com/api/webhooks/..." // Only for custom mode
* } * }
*/ */
class DiscordService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface, MokoSuiteCrossDeleteInterface class DiscordService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface
{ {
public static function getSubscribedEvents(): array public static function getSubscribedEvents(): array
{ {
@@ -127,44 +126,6 @@ class DiscordService extends CMSPlugin implements SubscriberInterface, MokoSuite
return $this->params->get('default_webhook_url', ''); 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 public function getSupportedMediaTypes(): array
{ {
return ['image', 'video']; return ['image', 'video'];
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Facebook / Meta</name> <name>MokoSuiteCross - Facebook / Meta</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -14,7 +14,6 @@ namespace Joomla\Plugin\MokoSuiteCross\Facebook\Extension;
defined('_JEXEC') or die; defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin; use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossDeleteInterface;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface; use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
use Joomla\Event\SubscriberInterface; use Joomla\Event\SubscriberInterface;
@@ -32,7 +31,7 @@ use Joomla\Event\SubscriberInterface;
* "page_id": "..." // Required — Facebook Page ID * "page_id": "..." // Required — Facebook Page ID
* } * }
*/ */
class FacebookService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface, MokoSuiteCrossDeleteInterface class FacebookService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface
{ {
public static function getSubscribedEvents(): array public static function getSubscribedEvents(): array
{ {
@@ -162,44 +161,6 @@ class FacebookService extends CMSPlugin implements SubscriberInterface, MokoSuit
return $this->params->get('default_page_access_token', ''); 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 public function getSupportedMediaTypes(): array
{ {
return ['image', 'video', 'gif']; return ['image', 'video', 'gif'];
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Ghost</name> <name>MokoSuiteCross - Ghost</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Google Business Profile</name> <name>MokoSuiteCross - Google Business Profile</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Google Chat</name> <name>MokoSuiteCross - Google Chat</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Hashnode</name> <name>MokoSuiteCross - Hashnode</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1 +0,0 @@
<!DOCTYPE html><title></title>
@@ -1,11 +0,0 @@
<?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;
@@ -1,39 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Instagram</name>
<version>01.04.09-dev</version>
<creationDate>2026-06-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<description>PLG_MOKOSUITECROSS_INSTAGRAM_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\MokoSuiteCross\Instagram</namespace>
<files>
<filename plugin="instagram">instagram.php</filename>
<folder>src</folder>
<folder>services</folder>
<folder>language</folder>
</files>
<languages>
<language tag="en-GB">language/en-GB/plg_mokosuitecross_instagram.ini</language>
<language tag="en-GB">language/en-GB/plg_mokosuitecross_instagram.sys.ini</language>
</languages>
<config>
<fields name="params">
<fieldset name="basic" label="PLG_MOKOSUITECROSS_INSTAGRAM_FIELDSET_DEFAULTS">
<field
name="default_webhook_url"
type="url"
label="PLG_MOKOSUITECROSS_INSTAGRAM_DEFAULT_WEBHOOK"
description="PLG_MOKOSUITECROSS_INSTAGRAM_DEFAULT_WEBHOOK_DESC"
size="60"
/>
</fieldset>
</fields>
</config>
</extension>
@@ -1 +0,0 @@
<!DOCTYPE html><title></title>
@@ -1,5 +0,0 @@
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."
@@ -1,2 +0,0 @@
PLG_MOKOSUITECROSS_INSTAGRAM="MokoSuiteCross - Instagram"
PLG_MOKOSUITECROSS_INSTAGRAM_DESCRIPTION="Cross-post Joomla articles to Instagram via Meta Content Publishing API."
@@ -1 +0,0 @@
<!DOCTYPE html><title></title>
@@ -1 +0,0 @@
<!DOCTYPE html><title></title>
@@ -1,38 +0,0 @@
<?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;
}
);
}
};
@@ -1,188 +0,0 @@
<?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'];
}
}
@@ -1 +0,0 @@
<!DOCTYPE html><title></title>
@@ -1 +0,0 @@
<!DOCTYPE html><title></title>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - LinkedIn</name> <name>MokoSuiteCross - LinkedIn</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -14,7 +14,6 @@ namespace Joomla\Plugin\MokoSuiteCross\Linkedin\Extension;
defined('_JEXEC') or die; defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin; use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossDeleteInterface;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface; use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
use Joomla\Event\SubscriberInterface; use Joomla\Event\SubscriberInterface;
@@ -30,7 +29,7 @@ use Joomla\Event\SubscriberInterface;
* "person_id": "..." // LinkedIn Person URN (fallback) * "person_id": "..." // LinkedIn Person URN (fallback)
* } * }
*/ */
class LinkedinService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface, MokoSuiteCrossDeleteInterface class LinkedinService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface
{ {
public static function getSubscribedEvents(): array public static function getSubscribedEvents(): array
{ {
@@ -148,46 +147,6 @@ class LinkedinService extends CMSPlugin implements SubscriberInterface, MokoSuit
return true; 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 public function getSupportedMediaTypes(): array
{ {
return ['image', 'video']; return ['image', 'video'];
@@ -7,8 +7,3 @@ 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_DEFAULT_FROM_EMAIL_DESC="Default sender email address for Mailchimp campaigns."
PLG_MOKOSUITECROSS_MAILCHIMP_AUTO_SEND="Auto Send" 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_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"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Mailchimp</name> <name>MokoSuiteCross - Mailchimp</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -10,7 +10,7 @@
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<description>PLG_MOKOSUITECROSS_MAILCHIMP_DESCRIPTION</description> <description>PLG_MOKOSUITECROSS_MAILCHIMP_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\MokoSuiteCross\Mailchimp</namespace> <namespace path="src">Joomla\Plugin\MokoSuiteCross${CLASS_NAME}</namespace>
<files> <files>
<filename plugin="mailchimp">mailchimp.php</filename> <filename plugin="mailchimp">mailchimp.php</filename>
@@ -51,24 +51,6 @@
<option value="1">JYES</option> <option value="1">JYES</option>
</field> </field>
</fieldset> </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> </fields>
</config> </config>
</extension> </extension>
@@ -95,30 +95,14 @@ class MailchimpService extends CMSPlugin implements SubscriberInterface, MokoSui
$data = json_decode($response, true) ?: []; $data = json_decode($response, true) ?: [];
if ($httpCode < 200 || $httpCode >= 300 || empty($data['id'])) { if ($httpCode !== 200 || empty($data['id'])) {
return ['success' => false, 'platform_post_id' => '', 'response' => $data]; return ['success' => false, 'platform_post_id' => '', 'response' => $data];
} }
$campaignId = $data['id']; $campaignId = $data['id'];
// Set campaign content — template injection or responsive wrapper // Set campaign content (HTML)
$templateId = (int) $this->params->get('template_id', 0); $contentData = json_encode(['html' => $message]);
$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"); $ch = curl_init("https://{$dc}.api.mailchimp.com/3.0/campaigns/{$campaignId}/content");
curl_setopt_array($ch, [ curl_setopt_array($ch, [
@@ -201,27 +185,6 @@ class MailchimpService extends CMSPlugin implements SubscriberInterface, MokoSui
return end($parts) ?: 'us1'; 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 public function getSupportedMediaTypes(): array
{ {
return ['image']; return ['image'];
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Mastodon</name> <name>MokoSuiteCross - Mastodon</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -14,7 +14,6 @@ namespace Joomla\Plugin\MokoSuiteCross\Mastodon\Extension;
defined('_JEXEC') or die; defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin; use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossDeleteInterface;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface; use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
use Joomla\Event\SubscriberInterface; use Joomla\Event\SubscriberInterface;
@@ -27,7 +26,7 @@ use Joomla\Event\SubscriberInterface;
* "access_token": "..." * "access_token": "..."
* } * }
*/ */
class MastodonService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface, MokoSuiteCrossDeleteInterface class MastodonService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface
{ {
public static function getSubscribedEvents(): array public static function getSubscribedEvents(): array
{ {
@@ -121,46 +120,6 @@ class MastodonService extends CMSPlugin implements SubscriberInterface, MokoSuit
return ['valid' => false, 'message' => 'Failed', 'account_name' => '']; 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 public function getSupportedMediaTypes(): array
{ {
return ['image', 'video', 'gif']; return ['image', 'video', 'gif'];
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Matrix / Element</name> <name>MokoSuiteCross - Matrix / Element</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Medium</name> <name>MokoSuiteCross - Medium</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -163,7 +163,7 @@ class MediumService extends CMSPlugin implements SubscriberInterface, MokoSuiteC
curl_close($ch); curl_close($ch);
return ''; return ['valid' => false, 'message' => 'Connection error: ' . $curlError, 'account_name' => ''];
} }
curl_close($ch); curl_close($ch);
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - MokoSuiteCalendar Events</name> <name>MokoSuiteCross - MokoSuiteCalendar Events</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - MokoSuiteGallery</name> <name>MokoSuiteCross - MokoSuiteGallery</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Nostr</name> <name>MokoSuiteCross - Nostr</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Ntfy Push Notifications</name> <name>MokoSuiteCross - Ntfy Push Notifications</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Pinterest</name> <name>MokoSuiteCross - Pinterest</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Reddit</name> <name>MokoSuiteCross - Reddit</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - RSS Feed</name> <name>MokoSuiteCross - RSS Feed</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - SendGrid</name> <name>MokoSuiteCross - SendGrid</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Slack</name> <name>MokoSuiteCross - Slack</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Microsoft Teams</name> <name>MokoSuiteCross - Microsoft Teams</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -14,7 +14,6 @@ namespace Joomla\Plugin\MokoSuiteCross\Telegram\Extension;
defined('_JEXEC') or die; defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin; use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossDeleteInterface;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface; use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
use Joomla\Event\SubscriberInterface; use Joomla\Event\SubscriberInterface;
@@ -32,7 +31,7 @@ use Joomla\Event\SubscriberInterface;
* "chat_id": "-100xxxxxxx" // Required — channel/group/user chat ID * "chat_id": "-100xxxxxxx" // Required — channel/group/user chat ID
* } * }
*/ */
class TelegramService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface, MokoSuiteCrossDeleteInterface class TelegramService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface
{ {
/** /**
* Default MokoSuite Bot token — resolved at runtime from component params. * Default MokoSuite Bot token — resolved at runtime from component params.
@@ -223,52 +222,6 @@ class TelegramService extends CMSPlugin implements SubscriberInterface, MokoSuit
return $r; 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 public function getSupportedMediaTypes(): array
{ {
return ['image', 'video', 'document']; return ['image', 'video', 'document'];
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Telegram</name> <name>MokoSuiteCross - Telegram</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Threads (Meta)</name> <name>MokoSuiteCross - Threads (Meta)</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - TikTok</name> <name>MokoSuiteCross - TikTok</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Tumblr</name> <name>MokoSuiteCross - Tumblr</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -14,7 +14,6 @@ namespace Joomla\Plugin\MokoSuiteCross\Twitter\Extension;
defined('_JEXEC') or die; defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin; use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossDeleteInterface;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface; use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
use Joomla\Event\SubscriberInterface; use Joomla\Event\SubscriberInterface;
@@ -25,7 +24,7 @@ use Joomla\Event\SubscriberInterface;
* Bearer tokens are app-only and cannot create tweets — OAuth 1.0a * Bearer tokens are app-only and cannot create tweets — OAuth 1.0a
* with consumer key/secret + access token/secret is required. * with consumer key/secret + access token/secret is required.
*/ */
class TwitterService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface, MokoSuiteCrossDeleteInterface class TwitterService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface
{ {
public static function getSubscribedEvents(): array public static function getSubscribedEvents(): array
{ {
@@ -204,50 +203,6 @@ class TwitterService extends CMSPlugin implements SubscriberInterface, MokoSuite
return 'OAuth ' . implode(', ', $parts); 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 public function getSupportedMediaTypes(): array
{ {
return ['image', 'video', 'gif']; return ['image', 'video', 'gif'];
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - X / Twitter</name> <name>MokoSuiteCross - X / Twitter</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Generic Webhook</name> <name>MokoSuiteCross - Generic Webhook</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - WhatsApp Business</name> <name>MokoSuiteCross - WhatsApp Business</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade"> <extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - WordPress</name> <name>MokoSuiteCross - WordPress</name>
<version>01.04.09-dev</version> <version>01.02.00-rc</version>
<creationDate>2026-05-28</creationDate> <creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1 +0,0 @@
<!DOCTYPE html><title></title>
@@ -1 +0,0 @@
<!DOCTYPE html><title></title>
@@ -1,2 +0,0 @@
PLG_MOKOSUITECROSS_YOUTUBE="MokoSuiteCross - YouTube"
PLG_MOKOSUITECROSS_YOUTUBE_DESCRIPTION="Cross-post Joomla articles to YouTube community posts."
@@ -1,2 +0,0 @@
PLG_MOKOSUITECROSS_YOUTUBE="MokoSuiteCross - YouTube"
PLG_MOKOSUITECROSS_YOUTUBE_DESCRIPTION="Cross-post Joomla articles to YouTube community posts."
@@ -1 +0,0 @@
<!DOCTYPE html><title></title>
@@ -1 +0,0 @@
<!DOCTYPE html><title></title>
@@ -1,38 +0,0 @@
<?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;
}
);
}
};
@@ -1,137 +0,0 @@
<?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'];
}
}
@@ -1 +0,0 @@
<!DOCTYPE html><title></title>
@@ -1 +0,0 @@
<!DOCTYPE html><title></title>

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