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>
463 lines
18 KiB
Python
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)
|