Files
mcphub/plugins/wordpress/handlers/ai_media.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

463 lines
18 KiB
Python

"""F.5a.4: AI image generation + upload chain.
Provides ``wordpress_generate_and_upload_image`` which chains:
1. Resolve the caller's per-user provider API key (or env fallback).
2. Call the chosen provider (OpenAI / Stability / Replicate) and get raw bytes.
3. Reuse the F.5a.1 raw-upload path (optimize → POST /wp/v2/media).
4. Optionally apply metadata (alt/caption/title), attach to a post, or set as featured.
5. Emit an audit-log entry with cost/provider/usage.
The tool is registered on the WordPress plugin because the end result is
a WordPress media item — the ``ai_image`` package is a pure provider
library, not an MCP plugin with its own endpoints.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import time
from typing import Any
from plugins.ai_image.providers.base import GenerationRequest, ProviderError
from plugins.ai_image.registry import get_provider, list_providers
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers._media_core import wp_raw_upload
from plugins.wordpress.handlers._media_security import UploadError
from plugins.wordpress.handlers.media import (
_apply_metadata_and_attach,
_format_upload_result,
_maybe_optimize,
)
_logger = logging.getLogger("mcphub.wordpress.ai_media")
def _content_sha(data: bytes) -> str:
"""SHA-256 of the raw generated bytes, truncated for brevity.
Two independent provider calls for the same prompt produce
different bytes (diffusion models are non-deterministic), so this
hash is effectively a per-call fingerprint. Including it in the
idempotency key means a retry with the *same already-generated*
bytes dedupes, while a fresh call with a new image gets a new id.
"""
return hashlib.sha256(data).hexdigest()[:32]
def _idempotency_key_for(
*,
provider: str,
model: str | None,
prompt: str,
size: str,
attach_to_post: int | None,
set_featured: bool,
site_url: str,
user_id: str | None,
content_sha: str,
) -> str:
"""Build the ``Idempotency-Key`` header value for an AI upload.
Matches the ``^[A-Za-z0-9_\\-]{1,128}$`` regex the companion
validates (see airano-mcp-bridge.php handle_upload_and_attach).
"""
raw = "|".join(
[
provider or "",
model or "",
prompt or "",
size or "",
str(attach_to_post or 0),
"1" if set_featured else "0",
site_url or "",
user_id or "",
content_sha or "",
]
)
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
return f"mcphub_ai_{digest[:48]}"
def get_tool_specifications() -> list[dict[str, Any]]:
return [
{
"name": "generate_and_upload_image",
"method_name": "generate_and_upload_image",
"description": (
"Generate an image with an AI provider (OpenAI DALL-E, "
"Stability, or Replicate Flux) and upload it to the WordPress "
"media library in one call. Optionally attach to a post or set "
"as featured image. Uses the caller's stored provider API key; "
"falls back to server env vars if no per-user key is set."
),
"schema": {
"type": "object",
"properties": {
"provider": {
"type": "string",
"enum": list_providers(),
"description": "AI provider to use.",
},
"prompt": {
"type": "string",
"description": "Text prompt describing the image to generate.",
},
"size": {
"type": "string",
"default": "1024x1024",
"description": "Requested image size (WxH). Providers map to supported sizes.",
},
"quality": {
"type": "string",
"default": "standard",
"description": "Provider-specific quality hint (e.g. 'standard'/'hd').",
},
"model": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Optional model override (e.g. 'dall-e-3', 'flux-dev').",
},
"negative_prompt": {
"anyOf": [{"type": "string"}, {"type": "null"}],
},
"filename": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Optional filename hint for the WP library.",
},
"title": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"alt_text": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"caption": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"attach_to_post": {
"anyOf": [{"type": "integer", "minimum": 1}, {"type": "null"}],
},
"set_featured": {"type": "boolean", "default": False},
"skip_optimize": {"type": "boolean", "default": False},
"convert_to": {
"anyOf": [
{"type": "string", "enum": ["webp", "avif"]},
{"type": "null"},
],
"description": (
"F.5a.8.1: re-encode the generated image as WebP or "
"AVIF before upload (falls back to WebP if AVIF is "
"unavailable)."
),
},
},
"required": ["provider", "prompt"],
},
"scope": "write",
}
]
class AIMediaHandler:
"""Handles AI-powered image generation + WP upload chain."""
def __init__(
self,
client: WordPressClient,
user_id: str | None = None,
site_id: str | None = None,
wc_client: WordPressClient | None = None,
):
self.client = client
# ``user_id`` is injected by the user-endpoint plumbing (F.5a.4).
# Admin / system endpoints leave it None.
self.user_id = user_id
# ``site_id`` is injected by the user-endpoint plumbing (F.5a.9.x)
# and is the primary input for resolving per-site provider API
# keys. Admin / env-driven endpoints leave it None, in which case
# the resolver falls back to the legacy env-var path.
self.site_id = site_id
# F.X.fix-pass6 — optional separate client for /wc/v3/* (the
# WC consumer-key/secret pair on a WC site). Used by
# _apply_metadata_and_attach to detect "attach_to_post is a
# WC product" and route the featured-image set through
# /wc/v3/products/{id}/images instead of /wp/v2/posts/{id}
# which 404s for the product CPT. None = same client serves
# both REST roots (WP plugin or legacy single-credential WC).
self.wc_client = wc_client
async def generate_and_upload_image(
self,
provider: str,
prompt: str,
size: str = "1024x1024",
quality: str = "standard",
model: str | None = None,
negative_prompt: str | None = None,
filename: str | None = None,
title: str | None = None,
alt_text: str | None = None,
caption: str | None = None,
attach_to_post: int | None = None,
set_featured: bool = False,
skip_optimize: bool = False,
convert_to: str | None = None,
) -> str:
started = time.time()
try:
from core.tool_rate_limiter import ToolRateLimitError, get_tool_rate_limiter
try:
get_tool_rate_limiter().check("wordpress_generate_and_upload_image", self.user_id)
except ToolRateLimitError as e:
return json.dumps(e.to_dict(), indent=2)
provider_impl = get_provider(provider)
api_key = await self._resolve_api_key(provider)
# F.X.fix-pass3 — when caller omitted ``model``, fall back
# to the per-site default the operator pinned in the
# dashboard. Keeps MCP call sites short and lets the user
# rotate models without code changes.
if model is None and self.site_id:
try:
from core.database import get_database
db = get_database()
model = await db.get_site_provider_default_model(self.site_id, provider)
except Exception as exc: # noqa: BLE001
_logger.debug(
"default_model lookup skipped site=%s provider=%s: %s",
self.site_id,
provider,
exc,
)
if not api_key:
if self.site_id:
msg = (
f"No API key configured for provider '{provider}' on "
f"this site. Open the site in the dashboard and add the "
f"key under 'AI Image Generation' in Connection Settings."
)
dashboard_url = f"/dashboard/sites/{self.site_id}"
else:
msg = (
f"No API key configured for provider '{provider}'. "
f"Set the {provider.upper()}_API_KEY env var on the server."
)
dashboard_url = None
payload = {
"error_code": "NO_PROVIDER_KEY",
"message": msg,
"provider": provider,
}
if dashboard_url:
payload["dashboard_url"] = dashboard_url
return json.dumps(payload, indent=2)
request = GenerationRequest(
prompt=prompt,
size=size,
quality=quality,
model=model,
negative_prompt=negative_prompt,
)
result = await provider_impl.generate(api_key, request)
data, mime_hint = _maybe_optimize(
result.data, result.mime, skip=skip_optimize, convert_to=convert_to
)
# F.X.fix #7: stable idempotency key per logical call. A
# client-side retry (after timeout) produces the same
# digest, so the companion dedupes instead of creating an
# "-2.webp" orphan. Key covers every input that would
# change the intended outcome.
idempotency_key = _idempotency_key_for(
provider=provider,
model=result.meta.get("model") or model,
prompt=prompt,
size=size,
attach_to_post=attach_to_post,
set_featured=set_featured,
site_url=self.client.site_url,
user_id=self.user_id,
content_sha=_content_sha(data),
)
media = await wp_raw_upload(
self.client,
data,
filename=filename or result.filename,
mime_hint=mime_hint or result.mime,
# F.5a.8.5: single-call upload+metadata+attach+featured
# when the companion's upload-and-attach route is
# advertised; _apply_metadata_and_attach becomes a no-op.
attach_to_post=attach_to_post,
set_featured=set_featured,
title=title,
alt_text=alt_text,
caption=caption,
idempotency_key=idempotency_key,
)
attach_status = await _apply_metadata_and_attach(
self.client,
media,
title=title,
alt_text=alt_text,
caption=caption,
attach_to_post=attach_to_post,
set_featured=set_featured,
wc_client=self.wc_client,
)
duration_ms = int((time.time() - started) * 1000)
self._audit(
provider=provider,
model=result.meta.get("model") or model,
size=size,
duration_ms=duration_ms,
bytes_=len(result.data),
cost_usd=result.cost_usd,
media_id=media.get("id"),
error=None,
)
from core.media_audit import log_media_upload
log_media_upload(
site=self.client.site_url,
user_id=self.user_id,
mime=media.get("mime_type") or result.mime,
size_bytes=len(data),
source=f"ai:{provider}",
media_id=media.get("id"),
cost_usd=result.cost_usd,
)
# F.X.fix-pass6 — surface partial-success: media uploaded
# but featured-set / metadata-apply may have warnings. Old
# behaviour bubbled those exceptions up as
# GENERATION_FAILED, hiding the freshly-created media id
# and leaving an orphan in the library.
payload = _format_upload_result(media, source=f"ai:{provider}")
payload["provider"] = provider
payload["provider_meta"] = result.meta
payload["cost_usd"] = result.cost_usd
payload["duration_ms"] = duration_ms
payload["attach"] = {
"metadata_applied": attach_status.get("metadata_applied", False),
"featured_set": attach_status.get("featured_set", False),
"featured_context": attach_status.get("featured_context"),
}
warnings = attach_status.get("warnings") or []
if warnings:
payload["warnings"] = warnings
return json.dumps(payload, indent=2)
except ProviderError as e:
self._audit(
provider=provider,
model=model,
size=size,
duration_ms=int((time.time() - started) * 1000),
bytes_=0,
cost_usd=None,
media_id=None,
error=e.code,
)
return json.dumps(e.to_dict(), indent=2)
except UploadError as e:
self._audit(
provider=provider,
model=model,
size=size,
duration_ms=int((time.time() - started) * 1000),
bytes_=0,
cost_usd=None,
media_id=None,
error=e.code,
)
return json.dumps(e.to_dict(), indent=2)
except Exception as e:
_logger.exception("generate_and_upload_image failed")
return json.dumps(
{
"error_code": "GENERATION_FAILED",
"message": f"generate_and_upload_image failed: {e}",
},
indent=2,
)
async def _resolve_api_key(self, provider: str) -> str | None:
"""Resolve the provider key for this call.
F.5a.9.x policy (single-source, per-site):
1. ``site_id`` set (per-user endpoint, the only UI-reachable path):
read the key from the per-site ``site_provider_keys`` row.
Env fallback is deliberately NOT applied here so a single
``OPENAI_API_KEY=...`` on the server cannot silently paper
over a missing per-site configuration — callers instead get
``NO_PROVIDER_KEY`` pointing at the site's dashboard page.
2. ``site_id is None`` (admin / master-key endpoint, no site
context): fall back to the ``<PROVIDER>_API_KEY`` env var.
This preserves the legacy self-hosted / admin workflow.
The per-user provider-keys store from F.18.8 has been removed
in favour of the per-site model; there is no hybrid fallback.
"""
# 1. Per-site key (the primary path)
if self.site_id:
try:
from core.site_api import get_site_provider_key
return await get_site_provider_key(self.site_id, provider)
except Exception as exc:
_logger.error(
"Site provider-key lookup failed site=%s provider=%s: %s",
self.site_id,
provider,
exc,
)
return None
# 2. Admin / env-var fallback (no site context)
env_var = f"{provider.upper()}_API_KEY"
return os.environ.get(env_var)
def _audit(
self,
*,
provider: str,
model: str | None,
size: str,
duration_ms: int,
bytes_: int,
cost_usd: float | None,
media_id: int | None,
error: str | None,
) -> None:
try:
from core.audit_log import get_audit_logger
logger_ = get_audit_logger()
except Exception:
return
try:
logger_.log_tool_call(
tool_name="wordpress_generate_and_upload_image",
params={
"provider": provider,
"model": model,
"size": size,
"bytes": bytes_,
"cost_usd": cost_usd,
"media_id": media_id,
},
duration_ms=duration_ms,
user_id=self.user_id,
error=error,
result_summary=(
f"{provider}/{model or '-'} {bytes_}B "
f"${cost_usd or 0:.4f} -> media_id={media_id}"
if not error
else f"failed: {error}"
),
)
except Exception:
_logger.debug("audit log emit failed", exc_info=True)