Files
mcphub/plugins/wordpress/handlers/media_attach.py
airano-ir f203ca88de
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
feat(v3.12.0): media pipeline, AI image generation, capability probe, companion v2.9.0
Three-month batch sync from internal repo (~80 commits) covering Tracks F.5a, F.7e, F.8, F.17, F.18, F.X.

WordPress media pipeline
- Pillow-based optimization, AI image generation (OpenAI / Stability / Replicate / Google Nano Banana / OpenRouter), chunked + resumable uploads, bulk delete/reassign, idempotent retries.

Capability discovery (F.7e)
- Per-site credential probe + adapters for WordPress / WooCommerce / Gitea, tier-fit unions granted ∪ roles, capability badge UI with HTMX partial re-check, install hint in every companion-unreachable error.

Companion plugin overhaul
- Renamed wordpress-plugin/airano-mcp-seo-bridge → wordpress-plugin/airano-mcp-bridge.
- Eight new endpoints: /capabilities, /bulk-meta, /export, /cache-purge, /transient-flush, /site-health, /audit-hook, /upload-and-attach.
- wp.org Plugin Check pass: i18n, WP_Filesystem, scheme allowlist on audit-hook URL.

Other
- Gitea ergonomics (F.17): batch files, tree, search, compare, releases, fork.
- Opportunistic bcrypt upgrade for legacy SHA-256 admin keys (F.8).
- n8n refactor: structured errors, capability probe, missing tools backfilled.
- Idempotency-Key dedup for AI media upload retries; WP client fast-fails on unreachable sites.

Docs
- README + CLAUDE.md drop the fixed "633 tools" claim. The total grows with each release; per-plugin approximations + dashboard-surfaced counts replace it.
- Tools/Tests badges removed in favour of "Plugins: 10".

Deployment
- PyPI mirror chain, optional BUILD_HTTP_PROXY, Alpine→Yandex apk mirror, Debian-slim Plan-B Dockerfile, mirror.gcr.io variant.

CI
- Black + Ruff clean on Python 3.12; pytest tests/ green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:25:58 +02:00

398 lines
16 KiB
Python

"""F.5a.3: WooCommerce product image attachment + WP featured-image tool.
WooCommerce `PUT /products/{id}` with `images: []` REPLACES the whole gallery.
For additive behaviour we GET-merge-PUT. We only ever reference existing
media by `id` (never pass external `src` — WC would re-download it).
"""
from __future__ import annotations
import json
from typing import Any
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers._media_core import (
fetch_url_bytes,
wp_raw_upload,
wp_set_featured_media,
wp_update_media_metadata,
)
from plugins.wordpress.handlers._media_security import (
DEFAULT_MAX_BYTES,
UploadError,
ssrf_check,
)
from plugins.wordpress.handlers.media import _decode_base64, _maybe_optimize
def get_tool_specifications() -> list[dict[str, Any]]:
return [
{
"name": "attach_media_to_product",
"method_name": "attach_media_to_product",
"description": "Attach existing media library items to a WooCommerce product. Use role='main' for the primary image (gallery index 0) or role='gallery' for extra images. mode='append' preserves the existing gallery; mode='replace' wipes and rewrites it.",
"schema": {
"type": "object",
"properties": {
"product_id": {"type": "integer", "minimum": 1},
"media_ids": {
"type": "array",
"items": {"type": "integer", "minimum": 1},
"minItems": 1,
},
"role": {"type": "string", "enum": ["main", "gallery"], "default": "gallery"},
"mode": {
"type": "string",
"enum": ["append", "replace"],
"default": "append",
},
},
"required": ["product_id", "media_ids"],
},
"scope": "write",
},
{
"name": "upload_and_attach_to_product",
"method_name": "upload_and_attach_to_product",
"description": "Upload a single image (from base64 or URL) and attach it to a WooCommerce product in one call.",
"schema": {
"type": "object",
"properties": {
"product_id": {"type": "integer", "minimum": 1},
"source": {"type": "string", "enum": ["base64", "url"]},
"data": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Base64 payload (when source=base64)",
},
"url": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Public URL (when source=url)",
},
"filename": {"type": "string"},
"mime": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"alt_text": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"role": {"type": "string", "enum": ["main", "gallery"], "default": "gallery"},
"mode": {
"type": "string",
"enum": ["append", "replace"],
"default": "append",
},
"skip_optimize": {"type": "boolean", "default": False},
},
"required": ["product_id", "source", "filename"],
},
"scope": "write",
},
{
"name": "set_featured_image",
"method_name": "set_featured_image",
"description": "Set a WordPress post's featured image to an existing media library item.",
"schema": {
"type": "object",
"properties": {
"post_id": {"type": "integer", "minimum": 1},
"media_id": {"type": "integer", "minimum": 1},
},
"required": ["post_id", "media_id"],
},
"scope": "write",
},
]
class MediaAttachHandler:
"""Media-to-product and media-to-post attachment helpers.
F.X.fix-pass4 — Two clients, one purpose:
* ``self.client`` authenticates WC REST (``/wc/v3/*``) — typically
a Consumer Key / Secret pair on a WC site.
* ``self.wp_media_client`` authenticates WP core REST
(``/wp/v2/media``, ``/wp/v2/posts/{id}``) — must be a WP user +
Application Password. WC keys do NOT work for /wp/v2/.
Resolution logic at call time:
* If a dedicated ``wp_media_client`` was passed in, use it for
/wp/v2/* — this is the explicit "user configured WP App Password
on a WC site" path.
* Else, if ``self.client.username`` starts with ``ck_`` we know
the primary is a WC-keys pair that cannot hit /wp/v2/* — surface
the structured ``WP_CREDENTIALS_MISSING`` error so the user
knows where to add credentials.
* Else, fall back to ``self.client`` — for the legacy
single-credential WC mode (Application Password only) and for
tests instantiating the handler with a plain WordPressClient.
"""
def __init__(
self,
client: WordPressClient,
*,
wp_media_client: WordPressClient | None = None,
):
self.client = client
self.wp_media_client = wp_media_client
def _require_wp_media_client(self) -> WordPressClient | None:
"""Pick the right client for /wp/v2/* calls, or None if no
viable credential set is available."""
if self.wp_media_client is not None:
return self.wp_media_client
primary_user = getattr(self.client, "username", "") or ""
if primary_user.startswith("ck_"):
# Primary is a WC consumer key — won't authenticate WP REST.
return None
# Primary is an Application Password (WP plugin mode or
# legacy single-credential WC mode) — same client works.
return self.client
@staticmethod
def _wp_credentials_missing_error() -> str:
"""Standard error when wp_media_client is None on WC sites."""
return json.dumps(
{
"error_code": "WP_CREDENTIALS_MISSING",
"message": (
"This tool needs a WordPress Application Password to upload "
"media via /wp/v2/media. WooCommerce Consumer Key + Secret "
"do NOT authenticate the WP core REST API. Open the site in "
"the dashboard and fill 'WordPress Username' + 'WordPress "
"Application Password' under Connection Settings (advanced)."
),
"remediation": {
"where": "Dashboard → site → Connection Settings",
"fields": ["wp_username", "wp_app_password"],
"wp_admin_path": "Users → Profile → Application Passwords",
},
},
indent=2,
)
async def attach_media_to_product(
self,
product_id: int,
media_ids: list[int],
role: str = "gallery",
mode: str = "append",
) -> str:
try:
if role not in ("main", "gallery"):
raise UploadError("BAD_ROLE", f"role must be 'main' or 'gallery', got '{role}'.")
if mode not in ("append", "replace"):
raise UploadError("BAD_MODE", f"mode must be 'append' or 'replace', got '{mode}'.")
# /wp/v2/media GET to validate media_ids needs WP creds.
if self._require_wp_media_client() is None:
return self._wp_credentials_missing_error()
await self._validate_media_ids(media_ids)
new_images = _merge_product_images(
existing=await self._get_product_images(product_id),
new_ids=media_ids,
role=role,
mode=mode,
)
updated = await self.client.put(
f"products/{product_id}",
json_data={"images": new_images},
use_woocommerce=True,
)
return json.dumps(
{
"product_id": product_id,
"images": [
{"id": i.get("id"), "src": i.get("src")} for i in updated.get("images", [])
],
"message": f"Attached {len(media_ids)} media item(s) to product {product_id}.",
},
indent=2,
)
except UploadError as e:
return json.dumps(e.to_dict(), indent=2)
except Exception as e:
return json.dumps(
{"error_code": "INTERNAL", "message": f"Attach failed: {e}"}, indent=2
)
async def upload_and_attach_to_product(
self,
product_id: int,
source: str,
filename: str,
data: str | None = None,
url: str | None = None,
mime: str | None = None,
alt_text: str | None = None,
role: str = "gallery",
mode: str = "append",
skip_optimize: bool = False,
) -> str:
try:
wp_client = self._require_wp_media_client()
if wp_client is None:
return self._wp_credentials_missing_error()
if source == "base64":
if not data:
raise UploadError("MISSING_FIELD", "source=base64 requires 'data'.")
raw = _decode_base64(data)
mime_hint = mime
elif source == "url":
if not url:
raise UploadError("MISSING_FIELD", "source=url requires 'url'.")
ssrf = ssrf_check(url)
if not ssrf.allowed:
raise UploadError("SSRF", ssrf.reason or "URL rejected.", {"url": url})
raw, declared_ct, _ = await fetch_url_bytes(url, max_bytes=DEFAULT_MAX_BYTES)
mime_hint = mime or declared_ct
else:
raise UploadError(
"BAD_SOURCE", f"source must be 'base64' or 'url', got '{source}'."
)
raw, mime_hint = _maybe_optimize(raw, mime_hint, skip=skip_optimize)
media = await wp_raw_upload(wp_client, raw, filename=filename, mime_hint=mime_hint)
if alt_text is not None:
await wp_update_media_metadata(wp_client, media["id"], alt_text=alt_text)
# Chain into attach
attach_json = await self.attach_media_to_product(
product_id=product_id, media_ids=[media["id"]], role=role, mode=mode
)
attach = json.loads(attach_json)
return json.dumps(
{
"media_id": media["id"],
"media_url": media.get("source_url"),
"product_id": product_id,
"attach_result": attach,
},
indent=2,
)
except UploadError as e:
return json.dumps(e.to_dict(), indent=2)
except Exception as e:
return json.dumps(
{"error_code": "INTERNAL", "message": f"Upload+attach failed: {e}"}, indent=2
)
async def set_featured_image(self, post_id: int, media_id: int) -> str:
"""Set the featured image of a WC product OR a WP post.
F.X.fix-pass5 — auto-detect routing: WC products (CPT) are not
addressable via ``/wp/v2/posts/{id}``; their featured image is
the first entry of the ``images`` array on the WC product
record. We try the WC ``products/{id}`` endpoint first because
this tool is WC-namespaced; if that 404s we fall through to
the legacy WP REST behaviour for regular posts/pages.
"""
try:
wp_client = self._require_wp_media_client()
if wp_client is None:
return self._wp_credentials_missing_error()
await self._validate_media_ids([media_id])
# 1. Try WC product first.
wc_product: dict[str, Any] | None = None
try:
wc_product = await self.client.get(f"products/{post_id}", use_woocommerce=True)
except Exception:
wc_product = None
if isinstance(wc_product, dict) and wc_product.get("id"):
# Featured = images[0]; preserve gallery via the existing
# role="main" mode="replace" merge logic.
new_images = _merge_product_images(
existing=list(wc_product.get("images") or []),
new_ids=[media_id],
role="main",
mode="replace",
)
updated = await self.client.put(
f"products/{post_id}",
json_data={"images": new_images},
use_woocommerce=True,
)
featured_id = (updated.get("images") or [{}])[0].get("id", media_id)
return json.dumps(
{
"post_id": post_id,
"featured_media": featured_id,
"context": "product",
"message": (
f"Set media {media_id} as featured image of WooCommerce "
f"product {post_id}."
),
},
indent=2,
)
# 2. Fall back to WP REST for regular posts/pages.
result = await wp_set_featured_media(wp_client, post_id, media_id)
return json.dumps(
{
"post_id": post_id,
"featured_media": result.get("featured_media", media_id),
"context": "post",
"message": f"Set media {media_id} as featured image of post {post_id}.",
},
indent=2,
)
except UploadError as e:
return json.dumps(e.to_dict(), indent=2)
except Exception as e:
return json.dumps(
{"error_code": "INTERNAL", "message": f"Set featured failed: {e}"}, indent=2
)
# --- internals ----------------------------------------------------------
async def _get_product_images(self, product_id: int) -> list[dict[str, Any]]:
product = await self.client.get(f"products/{product_id}", use_woocommerce=True)
return list(product.get("images", []))
async def _validate_media_ids(self, media_ids: list[int]) -> None:
if not media_ids:
raise UploadError("MISSING_FIELD", "media_ids must not be empty.")
# /wp/v2/media GET — needs WP creds. Caller has already
# validated wp_media_client is present.
wp_client = self.wp_media_client or self.client
for mid in media_ids:
try:
await wp_client.get(f"media/{mid}")
except Exception as e:
raise UploadError(
"MEDIA_NOT_FOUND",
f"Media id {mid} not found in the library.",
{"media_id": mid, "error": str(e)},
) from e
def _merge_product_images(
existing: list[dict[str, Any]],
new_ids: list[int],
*,
role: str,
mode: str,
) -> list[dict[str, Any]]:
"""Pure function — easy to unit-test."""
new_entries = [{"id": mid} for mid in new_ids]
if mode == "replace":
if role == "main":
# First = main, rest = gallery
return new_entries
# role=gallery + mode=replace → keep existing main (index 0), replace the rest
main = existing[:1]
return main + new_entries
# mode == "append"
if role == "main":
# New first image becomes main; existing main demoted into gallery
return new_entries + existing
# role=gallery + mode=append → keep existing, add new to the end (dedupe by id)
seen_ids = {i.get("id") for i in existing if i.get("id") is not None}
tail = [e for e in new_entries if e["id"] not in seen_ids]
return existing + tail