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>
This commit is contained in:
33
plugins/ai_image/__init__.py
Normal file
33
plugins/ai_image/__init__.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""AI image generation provider library (F.5a.4).
|
||||
|
||||
Not a registered MCP plugin — this package exposes provider
|
||||
implementations and a lookup registry consumed by media-upload tools in
|
||||
other plugins (currently ``wordpress_generate_and_upload_image``).
|
||||
|
||||
Typical usage::
|
||||
|
||||
from plugins.ai_image.registry import get_provider
|
||||
|
||||
provider = get_provider("openai")
|
||||
result = await provider.generate(
|
||||
api_key=key, prompt="a red cube", size="1024x1024"
|
||||
)
|
||||
image_bytes, mime, meta = result.bytes, result.mime, result.meta
|
||||
"""
|
||||
|
||||
from plugins.ai_image.providers.base import (
|
||||
BaseImageProvider,
|
||||
GenerationRequest,
|
||||
GenerationResult,
|
||||
ProviderError,
|
||||
)
|
||||
from plugins.ai_image.registry import get_provider, list_providers
|
||||
|
||||
__all__ = [
|
||||
"BaseImageProvider",
|
||||
"GenerationRequest",
|
||||
"GenerationResult",
|
||||
"ProviderError",
|
||||
"get_provider",
|
||||
"list_providers",
|
||||
]
|
||||
1
plugins/ai_image/providers/__init__.py
Normal file
1
plugins/ai_image/providers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""AI image provider implementations (F.5a.4)."""
|
||||
72
plugins/ai_image/providers/base.py
Normal file
72
plugins/ai_image/providers/base.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Base types for AI image providers (F.5a.4).
|
||||
|
||||
Each concrete provider subclasses :class:`BaseImageProvider` and implements
|
||||
``generate()``. Providers return raw image bytes + MIME so the caller (e.g.
|
||||
the WordPress upload pipeline) can reuse the existing raw-upload path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ProviderError(Exception):
|
||||
"""Stable-coded error for provider failures.
|
||||
|
||||
Codes follow the F.5a error taxonomy (``PROVIDER_QUOTA``,
|
||||
``PROVIDER_AUTH``, ``PROVIDER_BAD_REQUEST``, ``PROVIDER_UNAVAILABLE``,
|
||||
``PROVIDER_TIMEOUT``). ``details`` is JSON-serialisable.
|
||||
"""
|
||||
|
||||
def __init__(self, code: str, message: str, details: dict[str, Any] | None = None):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.details = details or {}
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"error_code": self.code, "message": self.message, "details": self.details}
|
||||
|
||||
|
||||
@dataclass
|
||||
class GenerationRequest:
|
||||
"""Normalised generation parameters.
|
||||
|
||||
Not every provider uses every field — unknown fields are ignored.
|
||||
"""
|
||||
|
||||
prompt: str
|
||||
size: str = "1024x1024"
|
||||
quality: str = "standard"
|
||||
model: str | None = None
|
||||
negative_prompt: str | None = None
|
||||
extras: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GenerationResult:
|
||||
"""Raw bytes + metadata returned by a provider."""
|
||||
|
||||
data: bytes
|
||||
mime: str
|
||||
filename: str
|
||||
meta: dict[str, Any] = field(default_factory=dict)
|
||||
cost_usd: float | None = None
|
||||
|
||||
|
||||
class BaseImageProvider(ABC):
|
||||
"""Abstract provider: turns ``(api_key, request)`` into image bytes."""
|
||||
|
||||
name: str = "base"
|
||||
|
||||
@abstractmethod
|
||||
async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
|
||||
"""Call the provider API and return image bytes + metadata.
|
||||
|
||||
Implementations should raise :class:`ProviderError` with a stable
|
||||
code for predictable client handling. Network retries for transient
|
||||
failures (429 / 5xx) should happen inside the implementation.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
184
plugins/ai_image/providers/openai.py
Normal file
184
plugins/ai_image/providers/openai.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""OpenAI image-generation provider (DALL-E 3 + gpt-image-1).
|
||||
|
||||
DALL-E 3 returns a time-limited URL (~1h TTL). gpt-image-1 can return
|
||||
``b64_json`` directly. In both cases we return the raw bytes immediately
|
||||
to the caller so the 1h URL expiry is never a concern downstream.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from plugins.ai_image.providers.base import (
|
||||
BaseImageProvider,
|
||||
GenerationRequest,
|
||||
GenerationResult,
|
||||
ProviderError,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger("mcphub.ai_image.openai")
|
||||
|
||||
_API_URL = "https://api.openai.com/v1/images/generations"
|
||||
_DEFAULT_MODEL = "dall-e-3"
|
||||
_RETRY_STATUS = {429, 500, 502, 503, 504}
|
||||
_MAX_RETRIES = 3
|
||||
_REQUEST_TIMEOUT = 90
|
||||
|
||||
# Rough per-image pricing (USD) for audit logging. These are documented
|
||||
# public list prices at the time of writing and may drift — treat as
|
||||
# ballpark for cost dashboards, not billing.
|
||||
_COST_TABLE: dict[tuple[str, str, str], float] = {
|
||||
("dall-e-3", "1024x1024", "standard"): 0.040,
|
||||
("dall-e-3", "1024x1024", "hd"): 0.080,
|
||||
("dall-e-3", "1792x1024", "standard"): 0.080,
|
||||
("dall-e-3", "1024x1792", "standard"): 0.080,
|
||||
("dall-e-3", "1792x1024", "hd"): 0.120,
|
||||
("dall-e-3", "1024x1792", "hd"): 0.120,
|
||||
("gpt-image-1", "1024x1024", "low"): 0.011,
|
||||
("gpt-image-1", "1024x1024", "medium"): 0.042,
|
||||
("gpt-image-1", "1024x1024", "high"): 0.167,
|
||||
}
|
||||
|
||||
|
||||
def _estimate_cost(model: str, size: str, quality: str) -> float | None:
|
||||
return _COST_TABLE.get((model, size, quality))
|
||||
|
||||
|
||||
class OpenAIProvider(BaseImageProvider):
|
||||
name = "openai"
|
||||
|
||||
async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
|
||||
if not api_key:
|
||||
raise ProviderError("PROVIDER_AUTH", "OpenAI API key is missing.")
|
||||
|
||||
model = request.model or _DEFAULT_MODEL
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"prompt": request.prompt,
|
||||
"size": request.size,
|
||||
"n": 1,
|
||||
}
|
||||
if model == "dall-e-3":
|
||||
payload["quality"] = request.quality or "standard"
|
||||
payload["response_format"] = "url"
|
||||
else:
|
||||
payload["response_format"] = "b64_json"
|
||||
if request.quality:
|
||||
payload["quality"] = request.quality
|
||||
|
||||
data, meta = await self._post_with_retry(api_key, payload)
|
||||
|
||||
items = data.get("data") or []
|
||||
if not items:
|
||||
raise ProviderError("PROVIDER_BAD_RESPONSE", "OpenAI returned no images.")
|
||||
first = items[0]
|
||||
|
||||
if first.get("b64_json"):
|
||||
image_bytes = base64.b64decode(first["b64_json"])
|
||||
elif first.get("url"):
|
||||
image_bytes = await _fetch_url(first["url"])
|
||||
else:
|
||||
raise ProviderError(
|
||||
"PROVIDER_BAD_RESPONSE",
|
||||
"OpenAI response contained no b64_json or url.",
|
||||
)
|
||||
|
||||
meta = dict(meta)
|
||||
if first.get("revised_prompt"):
|
||||
meta["revised_prompt"] = first["revised_prompt"]
|
||||
meta["model"] = model
|
||||
meta["size"] = request.size
|
||||
|
||||
return GenerationResult(
|
||||
data=image_bytes,
|
||||
mime="image/png",
|
||||
filename=f"openai-{model}.png",
|
||||
meta=meta,
|
||||
cost_usd=_estimate_cost(model, request.size, request.quality or "standard"),
|
||||
)
|
||||
|
||||
async def _post_with_retry(
|
||||
self, api_key: str, payload: dict[str, Any]
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
last_error: str = ""
|
||||
delay = 1.0
|
||||
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
for attempt in range(1, _MAX_RETRIES + 1):
|
||||
try:
|
||||
async with session.post(_API_URL, json=payload, headers=headers) as resp:
|
||||
if resp.status == 200:
|
||||
body = await resp.json()
|
||||
return body, {"attempt": attempt, "status": 200}
|
||||
text = await resp.text()
|
||||
last_error = text[:500]
|
||||
if resp.status == 401:
|
||||
raise ProviderError(
|
||||
"PROVIDER_AUTH",
|
||||
"OpenAI rejected the API key (401).",
|
||||
{"body": last_error},
|
||||
)
|
||||
if resp.status == 400:
|
||||
raise ProviderError(
|
||||
"PROVIDER_BAD_REQUEST",
|
||||
f"OpenAI rejected request (400): {last_error}",
|
||||
{"body": last_error},
|
||||
)
|
||||
if resp.status == 429 and attempt < _MAX_RETRIES:
|
||||
_logger.warning("OpenAI 429, retry %d", attempt)
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
continue
|
||||
if resp.status in _RETRY_STATUS and attempt < _MAX_RETRIES:
|
||||
_logger.warning("OpenAI %d, retry %d", resp.status, attempt)
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
continue
|
||||
if resp.status == 429:
|
||||
raise ProviderError(
|
||||
"PROVIDER_QUOTA",
|
||||
"OpenAI quota/rate-limit hit after retries.",
|
||||
{"status": 429, "body": last_error},
|
||||
)
|
||||
raise ProviderError(
|
||||
"PROVIDER_UNAVAILABLE",
|
||||
f"OpenAI upstream error HTTP {resp.status}.",
|
||||
{"status": resp.status, "body": last_error},
|
||||
)
|
||||
except TimeoutError as exc:
|
||||
last_error = f"timeout: {exc}"
|
||||
if attempt < _MAX_RETRIES:
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
continue
|
||||
raise ProviderError(
|
||||
"PROVIDER_TIMEOUT",
|
||||
"OpenAI request timed out after retries.",
|
||||
) from exc
|
||||
raise ProviderError(
|
||||
"PROVIDER_UNAVAILABLE",
|
||||
f"OpenAI call failed after {_MAX_RETRIES} attempts: {last_error}",
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_url(url: str) -> bytes:
|
||||
"""Fetch a DALL-E URL immediately. URLs expire in ~1h, so no caching."""
|
||||
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url) as resp:
|
||||
if resp.status != 200:
|
||||
raise ProviderError(
|
||||
"PROVIDER_BAD_RESPONSE",
|
||||
f"Failed to fetch OpenAI image URL (HTTP {resp.status}).",
|
||||
{"status": resp.status},
|
||||
)
|
||||
return await resp.read()
|
||||
474
plugins/ai_image/providers/openrouter.py
Normal file
474
plugins/ai_image/providers/openrouter.py
Normal file
@@ -0,0 +1,474 @@
|
||||
"""OpenRouter image-generation provider.
|
||||
|
||||
OpenRouter is an aggregator that routes requests to many model vendors.
|
||||
For image generation, the usable path is their chat-completions endpoint
|
||||
with ``modalities=["image","text"]`` — models like
|
||||
``google/gemini-2.5-flash-image`` (a.k.a. "Nano Banana") return the
|
||||
generated image as a ``data:``-prefixed base64 URL inside the message
|
||||
``images`` array; other models may return ``image_url``-style pointers.
|
||||
|
||||
Why this provider is worth adding:
|
||||
|
||||
* Unlocks Gemini image models without needing a Google Cloud project.
|
||||
* Lets users concentrate AI spend on a single OpenRouter key rather
|
||||
than managing separate OpenAI / Stability / Replicate accounts.
|
||||
* Supported models (as of F.X.fix 2026-04-18):
|
||||
- ``google/gemini-2.5-flash-image`` (default, GA)
|
||||
- ``google/gemini-2.5-flash-image-preview`` (DEPRECATED; 404 on
|
||||
fresh OpenRouter accounts — kept as a recognised alias so we can
|
||||
emit ``PROVIDER_MODEL_DEPRECATED`` with a clear hint)
|
||||
- Any other OpenRouter model that returns image parts in
|
||||
``message.images[]`` — the parser is tolerant of newer entries.
|
||||
|
||||
The default model is picked to cover the most common use case (hero
|
||||
images for WordPress / WooCommerce posts); callers can override via
|
||||
``GenerationRequest.model``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from plugins.ai_image.providers.base import (
|
||||
BaseImageProvider,
|
||||
GenerationRequest,
|
||||
GenerationResult,
|
||||
ProviderError,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger("mcphub.ai_image.openrouter")
|
||||
|
||||
_API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||
_MODELS_URL = "https://openrouter.ai/api/v1/models"
|
||||
# GA endpoint. The preview alias (...-image-preview) was deprecated after
|
||||
# Google promoted the model to GA — new OpenRouter accounts get 404 on
|
||||
# the preview ID.
|
||||
_DEFAULT_MODEL = "google/gemini-2.5-flash-image"
|
||||
_DEPRECATED_MODELS: dict[str, str] = {
|
||||
"google/gemini-2.5-flash-image-preview": "google/gemini-2.5-flash-image",
|
||||
}
|
||||
_RETRY_STATUS = {429, 500, 502, 503, 504}
|
||||
_MAX_RETRIES = 3
|
||||
_REQUEST_TIMEOUT = 120
|
||||
|
||||
# USD-per-image price table. Figures are conservative upper bounds from
|
||||
# public OpenRouter pricing (2026-04); operators who negotiate custom
|
||||
# rates can override via ``OPENROUTER_PRICING_OVERRIDE`` (JSON map of
|
||||
# model_id -> price USD).
|
||||
_MODEL_PRICING: dict[str, float] = {
|
||||
"google/gemini-2.5-flash-image": 0.00039,
|
||||
"google/gemini-2.5-flash-image-preview": 0.00039,
|
||||
"google/imagen-3.0-generate": 0.04,
|
||||
"google/imagen-3.0-fast": 0.02,
|
||||
"openai/dall-e-3": 0.04,
|
||||
"openai/dall-e-3-hd": 0.08,
|
||||
"openai/gpt-image-1": 0.017,
|
||||
"black-forest-labs/flux-1.1-pro": 0.04,
|
||||
"black-forest-labs/flux-pro": 0.055,
|
||||
"black-forest-labs/flux-schnell": 0.003,
|
||||
"stability-ai/stable-diffusion-3.5-large": 0.065,
|
||||
}
|
||||
|
||||
|
||||
def _pricing_table() -> dict[str, float]:
|
||||
"""Return the effective price table, merging env override if present."""
|
||||
import json as _json
|
||||
|
||||
override = os.environ.get("OPENROUTER_PRICING_OVERRIDE", "").strip()
|
||||
if not override:
|
||||
return _MODEL_PRICING
|
||||
try:
|
||||
parsed = _json.loads(override)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_logger.warning("OPENROUTER_PRICING_OVERRIDE is not valid JSON: %s", exc)
|
||||
return _MODEL_PRICING
|
||||
if not isinstance(parsed, dict):
|
||||
return _MODEL_PRICING
|
||||
merged = dict(_MODEL_PRICING)
|
||||
for k, v in parsed.items():
|
||||
try:
|
||||
merged[str(k)] = float(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return merged
|
||||
|
||||
|
||||
def _cost_for(model: str) -> float | None:
|
||||
"""Return USD cost for a known model, else None (logs at debug)."""
|
||||
price = _pricing_table().get(model)
|
||||
if price is None:
|
||||
_logger.debug("openrouter: no pricing entry for model %r", model)
|
||||
return price
|
||||
|
||||
|
||||
# Module-level cache for list_image_models() — 1h TTL is plenty; model
|
||||
# catalog drift is measured in days, not minutes.
|
||||
_MODELS_CACHE_TTL_SECONDS = 3600
|
||||
_models_cache: dict[str, Any] = {"fetched_at": 0.0, "payload": None}
|
||||
|
||||
|
||||
class OpenRouterProvider(BaseImageProvider):
|
||||
name = "openrouter"
|
||||
|
||||
async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
|
||||
if not api_key:
|
||||
raise ProviderError("PROVIDER_AUTH", "OpenRouter API key is missing.")
|
||||
|
||||
requested_model = request.model or _DEFAULT_MODEL
|
||||
replacement = _DEPRECATED_MODELS.get(requested_model)
|
||||
if replacement is not None:
|
||||
raise ProviderError(
|
||||
"PROVIDER_MODEL_DEPRECATED",
|
||||
(
|
||||
f"OpenRouter model '{requested_model}' is deprecated. "
|
||||
f"Use '{replacement}' instead."
|
||||
),
|
||||
{"requested_model": requested_model, "replacement_model": replacement},
|
||||
)
|
||||
model = requested_model
|
||||
|
||||
# Chat-completions shape with image modality declared. The
|
||||
# prompt is sent as the single user message; size hints are
|
||||
# advisory — Gemini picks an output size internally.
|
||||
user_content: str = request.prompt
|
||||
if request.negative_prompt:
|
||||
user_content = f"{user_content}\n\nAvoid: {request.negative_prompt}"
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": user_content}],
|
||||
"modalities": ["image", "text"],
|
||||
}
|
||||
|
||||
body = await self._post_with_retry(api_key, payload)
|
||||
|
||||
image_url = _extract_image_url(body)
|
||||
if not image_url:
|
||||
raise ProviderError(
|
||||
"PROVIDER_BAD_RESPONSE",
|
||||
"OpenRouter response contained no image data. Is the "
|
||||
f"model '{model}' configured for image output?",
|
||||
{"model": model},
|
||||
)
|
||||
|
||||
image_bytes, mime = await _materialise_image(image_url)
|
||||
if not image_bytes:
|
||||
raise ProviderError(
|
||||
"PROVIDER_BAD_RESPONSE",
|
||||
"OpenRouter image URL could not be fetched / decoded.",
|
||||
{"model": model},
|
||||
)
|
||||
|
||||
# Derive a useful filename suffix from the mime type.
|
||||
ext = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"}.get(mime, "png")
|
||||
meta: dict[str, Any] = {"model": model, "size": request.size}
|
||||
# Attach OpenRouter usage if present so audit logs can correlate.
|
||||
usage = body.get("usage") or {}
|
||||
if isinstance(usage, dict):
|
||||
for k in ("prompt_tokens", "completion_tokens", "total_tokens"):
|
||||
if k in usage:
|
||||
meta[k] = usage[k]
|
||||
|
||||
return GenerationResult(
|
||||
data=image_bytes,
|
||||
mime=mime,
|
||||
filename=f"openrouter-{model.replace('/', '-')}.{ext}",
|
||||
meta=meta,
|
||||
cost_usd=_cost_for(model),
|
||||
)
|
||||
|
||||
async def _post_with_retry(self, api_key: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
# OpenRouter recommends identifying the calling app for
|
||||
# observability. Using a stable string so admins can spot
|
||||
# traffic from MCPHub in their OpenRouter dashboard.
|
||||
"HTTP-Referer": "https://github.com/airano-ir/mcphub",
|
||||
"X-Title": "MCPHub",
|
||||
}
|
||||
last_error: str = ""
|
||||
delay = 1.0
|
||||
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
for attempt in range(1, _MAX_RETRIES + 1):
|
||||
try:
|
||||
async with session.post(_API_URL, json=payload, headers=headers) as resp:
|
||||
if resp.status == 200:
|
||||
return await resp.json()
|
||||
text = await resp.text()
|
||||
last_error = text[:500]
|
||||
if resp.status == 401:
|
||||
raise ProviderError(
|
||||
"PROVIDER_AUTH",
|
||||
"OpenRouter rejected the API key (401).",
|
||||
{"body": last_error},
|
||||
)
|
||||
if resp.status == 400:
|
||||
raise ProviderError(
|
||||
"PROVIDER_BAD_REQUEST",
|
||||
f"OpenRouter rejected request (400): {last_error}",
|
||||
{"body": last_error},
|
||||
)
|
||||
if resp.status in _RETRY_STATUS and attempt < _MAX_RETRIES:
|
||||
_logger.warning(
|
||||
"OpenRouter %d, retry %d/%d", resp.status, attempt, _MAX_RETRIES
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
continue
|
||||
if resp.status == 429:
|
||||
raise ProviderError(
|
||||
"PROVIDER_QUOTA",
|
||||
"OpenRouter quota/rate-limit hit after retries.",
|
||||
{"status": 429, "body": last_error},
|
||||
)
|
||||
raise ProviderError(
|
||||
"PROVIDER_UNAVAILABLE",
|
||||
f"OpenRouter upstream error HTTP {resp.status}.",
|
||||
{"status": resp.status, "body": last_error},
|
||||
)
|
||||
except TimeoutError as exc:
|
||||
last_error = f"timeout: {exc}"
|
||||
if attempt < _MAX_RETRIES:
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
continue
|
||||
raise ProviderError(
|
||||
"PROVIDER_TIMEOUT",
|
||||
"OpenRouter request timed out after retries.",
|
||||
) from exc
|
||||
raise ProviderError(
|
||||
"PROVIDER_UNAVAILABLE",
|
||||
f"OpenRouter call failed after {_MAX_RETRIES} attempts: {last_error}",
|
||||
)
|
||||
|
||||
async def list_image_models(
|
||||
self, api_key: str | None = None, *, force: bool = False
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return OpenRouter catalog entries whose modality includes image.
|
||||
|
||||
The catalog drifts slowly (days) so we cache the whole filtered
|
||||
list at module scope for 1h. ``api_key`` is optional — the
|
||||
``/v1/models`` endpoint is public, but authenticated callers see
|
||||
per-account availability flags. On any upstream error the
|
||||
previous cache (if any) is returned; otherwise an empty list.
|
||||
"""
|
||||
now = time.time()
|
||||
payload = _models_cache.get("payload")
|
||||
fetched_at = _models_cache.get("fetched_at", 0.0)
|
||||
if not force and payload is not None and (now - fetched_at) < _MODELS_CACHE_TTL_SECONDS:
|
||||
return list(payload)
|
||||
|
||||
headers = {
|
||||
"HTTP-Referer": "https://github.com/airano-ir/mcphub",
|
||||
"X-Title": "MCPHub",
|
||||
}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(_MODELS_URL, headers=headers) as resp:
|
||||
if resp.status != 200:
|
||||
_logger.warning("openrouter /v1/models HTTP %s", resp.status)
|
||||
return list(payload or [])
|
||||
body = await resp.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_logger.warning("openrouter /v1/models fetch failed: %s", exc)
|
||||
return list(payload or [])
|
||||
|
||||
raw_models = body.get("data") or []
|
||||
pricing = _pricing_table()
|
||||
filtered: list[dict[str, Any]] = []
|
||||
for item in raw_models:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if not _model_is_image(item):
|
||||
continue
|
||||
model_id = str(item.get("id") or "")
|
||||
if not model_id or model_id in _DEPRECATED_MODELS:
|
||||
continue
|
||||
filtered.append(
|
||||
{
|
||||
"id": model_id,
|
||||
"name": item.get("name") or model_id,
|
||||
"description": (item.get("description") or "")[:400],
|
||||
"context_length": item.get("context_length"),
|
||||
"input_modalities": _modalities_of(item, "input"),
|
||||
"output_modalities": _modalities_of(item, "output"),
|
||||
"price_per_image_usd": pricing.get(model_id),
|
||||
}
|
||||
)
|
||||
|
||||
filtered.sort(key=lambda m: m["id"])
|
||||
_models_cache["fetched_at"] = now
|
||||
_models_cache["payload"] = filtered
|
||||
return list(filtered)
|
||||
|
||||
|
||||
def _modalities_of(item: dict[str, Any], side: str) -> list[str]:
|
||||
"""Extract input/output modality list from an OpenRouter model entry."""
|
||||
arch = item.get("architecture") or {}
|
||||
if isinstance(arch, dict):
|
||||
key = "input_modalities" if side == "input" else "output_modalities"
|
||||
mods = arch.get(key)
|
||||
if isinstance(mods, list):
|
||||
return [str(m) for m in mods if isinstance(m, str)]
|
||||
modality = arch.get("modality")
|
||||
if isinstance(modality, str):
|
||||
parts = modality.split("->")
|
||||
if len(parts) == 2:
|
||||
idx = 0 if side == "input" else 1
|
||||
return [p.strip() for p in parts[idx].split("+") if p.strip()]
|
||||
return []
|
||||
|
||||
|
||||
def _model_is_image(item: dict[str, Any]) -> bool:
|
||||
"""True when an OpenRouter model entry emits image output."""
|
||||
out = _modalities_of(item, "output")
|
||||
if any("image" in m.lower() for m in out):
|
||||
return True
|
||||
# Legacy catalog rows without structured modalities — fall back to a
|
||||
# name/description heuristic so we don't silently drop valid models.
|
||||
blob = " ".join(str(item.get(k, "") or "") for k in ("id", "name", "description")).lower()
|
||||
return "image" in blob and "gen" in blob
|
||||
|
||||
|
||||
def _extract_image_url(body: dict[str, Any]) -> str | None:
|
||||
"""Pull the first image URL out of an OpenRouter chat response.
|
||||
|
||||
The wire format varies by model vendor. We recognise:
|
||||
|
||||
* ``choices[0].message.images[i].image_url.url`` — a string that
|
||||
may be a ``data:image/...;base64,...`` URL or an https URL.
|
||||
* ``choices[0].message.content`` when it is a list of parts, each
|
||||
of shape ``{type: "image_url", image_url: {url: ...}}`` —
|
||||
OpenAI-compatible multimodal reply shape.
|
||||
|
||||
Returns None if no usable reference was found.
|
||||
"""
|
||||
choices = body.get("choices") or []
|
||||
if not isinstance(choices, list) or not choices:
|
||||
return None
|
||||
message = (choices[0] or {}).get("message") or {}
|
||||
|
||||
# Shape 1: message.images (Gemini via OpenRouter).
|
||||
for entry in message.get("images") or []:
|
||||
url = _image_url_from_entry(entry)
|
||||
if url:
|
||||
return url
|
||||
|
||||
# Shape 2: message.content as list of parts.
|
||||
content = message.get("content")
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
if part.get("type") == "image_url":
|
||||
url = _image_url_from_entry(part)
|
||||
if url:
|
||||
return url
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _image_url_from_entry(entry: Any) -> str | None:
|
||||
"""Handle both ``{image_url: {url: ...}}`` and ``{image_url: "..."}``."""
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
iu = entry.get("image_url")
|
||||
if isinstance(iu, str):
|
||||
return iu
|
||||
if isinstance(iu, dict):
|
||||
u = iu.get("url")
|
||||
return u if isinstance(u, str) else None
|
||||
# Some providers emit a bare "url" key.
|
||||
u = entry.get("url")
|
||||
return u if isinstance(u, str) else None
|
||||
|
||||
|
||||
async def _materialise_image(url: str) -> tuple[bytes, str]:
|
||||
"""Turn an image URL (data: or https) into raw bytes + MIME.
|
||||
|
||||
Returns ``(b"", "")`` on any failure so the caller can emit a
|
||||
uniform ``PROVIDER_BAD_RESPONSE`` error.
|
||||
"""
|
||||
if url.startswith("data:"):
|
||||
try:
|
||||
header, payload = url.split(",", 1)
|
||||
except ValueError:
|
||||
return b"", ""
|
||||
mime = "image/png"
|
||||
rest = header[len("data:") :] if header.startswith("data:") else header
|
||||
if ";" in rest:
|
||||
mime = rest.split(";", 1)[0] or mime
|
||||
elif rest:
|
||||
mime = rest
|
||||
if "base64" in header:
|
||||
try:
|
||||
return base64.b64decode(payload), mime
|
||||
except Exception:
|
||||
return b"", mime
|
||||
return payload.encode("latin-1", errors="ignore"), mime
|
||||
|
||||
if url.startswith("http://") or url.startswith("https://"):
|
||||
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url) as resp:
|
||||
if resp.status != 200:
|
||||
return b"", ""
|
||||
mime = resp.headers.get("Content-Type", "image/png").split(";", 1)[0].strip()
|
||||
return await resp.read(), mime or "image/png"
|
||||
|
||||
return b"", ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Starlette handler — GET /api/providers/openrouter/models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def api_openrouter_models(request: Any) -> Any:
|
||||
"""Return the cached OpenRouter image-model catalog.
|
||||
|
||||
Auth: same OAuth user session guard as the other /api/ endpoints.
|
||||
Query params:
|
||||
* ``force=1`` — bypass the 1h module cache.
|
||||
* ``site_id=<uuid>`` — if present, the user's OpenRouter key for
|
||||
that site is used as bearer so availability flags reflect the
|
||||
operator's account; otherwise the endpoint is fetched unauthed.
|
||||
"""
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from core.dashboard.routes import _require_user_session
|
||||
|
||||
user_session, redirect = _require_user_session(request)
|
||||
if redirect or user_session is None:
|
||||
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
|
||||
|
||||
force = request.query_params.get("force") in {"1", "true", "True"}
|
||||
api_key: str | None = None
|
||||
site_id = (request.query_params.get("site_id") or "").strip()
|
||||
if site_id:
|
||||
try:
|
||||
from core.database import get_database
|
||||
from core.site_api import get_site_provider_key
|
||||
|
||||
db = get_database()
|
||||
site = await db.get_site(site_id, user_session["user_id"])
|
||||
if site is not None:
|
||||
api_key = await get_site_provider_key(site_id, "openrouter")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_logger.debug("openrouter models: site key lookup skipped: %s", exc)
|
||||
|
||||
provider = OpenRouterProvider()
|
||||
models = await provider.list_image_models(api_key=api_key, force=force)
|
||||
return JSONResponse({"ok": True, "provider": "openrouter", "models": models})
|
||||
204
plugins/ai_image/providers/replicate.py
Normal file
204
plugins/ai_image/providers/replicate.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""Replicate provider (Flux family + other image models).
|
||||
|
||||
Replicate runs predictions asynchronously: POST to ``/v1/predictions``
|
||||
returns a job id, and we poll ``/v1/predictions/{id}`` until status is
|
||||
``succeeded`` or ``failed``. The final output is a URL (or list of URLs)
|
||||
that must be fetched to get bytes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from plugins.ai_image.providers.base import (
|
||||
BaseImageProvider,
|
||||
GenerationRequest,
|
||||
GenerationResult,
|
||||
ProviderError,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger("mcphub.ai_image.replicate")
|
||||
|
||||
_PREDICTIONS_URL = "https://api.replicate.com/v1/predictions"
|
||||
_DEFAULT_MODEL = "black-forest-labs/flux-schnell"
|
||||
_MAX_RETRIES = 3
|
||||
_POLL_INTERVAL = 2.0
|
||||
_POLL_TIMEOUT = 180
|
||||
_REQUEST_TIMEOUT = 60
|
||||
_RETRY_STATUS = {429, 500, 502, 503, 504}
|
||||
|
||||
_COST_TABLE: dict[str, float] = {
|
||||
"black-forest-labs/flux-schnell": 0.003,
|
||||
"black-forest-labs/flux-dev": 0.025,
|
||||
"black-forest-labs/flux-pro": 0.055,
|
||||
}
|
||||
|
||||
|
||||
def _aspect_from_size(size: str) -> str:
|
||||
"""Replicate's Flux models accept an aspect_ratio enum, not WxH."""
|
||||
mapping = {
|
||||
"1024x1024": "1:1",
|
||||
"1344x768": "16:9",
|
||||
"768x1344": "9:16",
|
||||
"1152x896": "3:2",
|
||||
"896x1152": "2:3",
|
||||
}
|
||||
return mapping.get(size, "1:1")
|
||||
|
||||
|
||||
class ReplicateProvider(BaseImageProvider):
|
||||
name = "replicate"
|
||||
|
||||
async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
|
||||
if not api_key:
|
||||
raise ProviderError("PROVIDER_AUTH", "Replicate API token is missing.")
|
||||
|
||||
model = request.model or _DEFAULT_MODEL
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"input": {
|
||||
"prompt": request.prompt,
|
||||
"aspect_ratio": _aspect_from_size(request.size),
|
||||
**(request.extras or {}),
|
||||
},
|
||||
}
|
||||
if request.negative_prompt:
|
||||
payload["input"]["negative_prompt"] = request.negative_prompt
|
||||
|
||||
prediction = await self._create_prediction(api_key, payload)
|
||||
final = await self._poll_until_done(api_key, prediction)
|
||||
|
||||
output = final.get("output")
|
||||
url = _first_url(output)
|
||||
if not url:
|
||||
raise ProviderError(
|
||||
"PROVIDER_BAD_RESPONSE",
|
||||
"Replicate prediction finished without an image URL.",
|
||||
{"prediction": final},
|
||||
)
|
||||
image_bytes = await _fetch_url(url)
|
||||
|
||||
meta = {
|
||||
"model": model,
|
||||
"prediction_id": final.get("id"),
|
||||
"status": final.get("status"),
|
||||
"metrics": final.get("metrics", {}),
|
||||
}
|
||||
return GenerationResult(
|
||||
data=image_bytes,
|
||||
mime="image/webp" if url.endswith(".webp") else "image/png",
|
||||
filename=f"replicate-{model.split('/')[-1]}.png",
|
||||
meta=meta,
|
||||
cost_usd=_COST_TABLE.get(model),
|
||||
)
|
||||
|
||||
async def _create_prediction(self, api_key: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Prefer": "wait=0",
|
||||
}
|
||||
delay = 1.0
|
||||
last_error = ""
|
||||
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
for attempt in range(1, _MAX_RETRIES + 1):
|
||||
async with session.post(_PREDICTIONS_URL, json=payload, headers=headers) as resp:
|
||||
if resp.status in (200, 201):
|
||||
return await resp.json()
|
||||
text = await resp.text()
|
||||
last_error = text[:500]
|
||||
if resp.status in (401, 403):
|
||||
raise ProviderError(
|
||||
"PROVIDER_AUTH",
|
||||
f"Replicate rejected auth ({resp.status}).",
|
||||
{"body": last_error},
|
||||
)
|
||||
if resp.status == 422:
|
||||
raise ProviderError(
|
||||
"PROVIDER_BAD_REQUEST",
|
||||
f"Replicate rejected request: {last_error}",
|
||||
{"body": last_error},
|
||||
)
|
||||
if resp.status in _RETRY_STATUS and attempt < _MAX_RETRIES:
|
||||
_logger.warning("Replicate %d, retry %d", resp.status, attempt)
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
continue
|
||||
if resp.status == 429:
|
||||
raise ProviderError(
|
||||
"PROVIDER_QUOTA",
|
||||
"Replicate rate-limit hit.",
|
||||
{"status": 429, "body": last_error},
|
||||
)
|
||||
raise ProviderError(
|
||||
"PROVIDER_UNAVAILABLE",
|
||||
f"Replicate HTTP {resp.status}.",
|
||||
{"status": resp.status, "body": last_error},
|
||||
)
|
||||
raise ProviderError(
|
||||
"PROVIDER_UNAVAILABLE",
|
||||
f"Replicate create_prediction failed after retries: {last_error}",
|
||||
)
|
||||
|
||||
async def _poll_until_done(self, api_key: str, prediction: dict[str, Any]) -> dict[str, Any]:
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
poll_url = (prediction.get("urls") or {}).get("get") or (
|
||||
f"{_PREDICTIONS_URL}/{prediction.get('id')}"
|
||||
)
|
||||
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
|
||||
deadline = asyncio.get_event_loop().time() + _POLL_TIMEOUT
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
while True:
|
||||
if asyncio.get_event_loop().time() > deadline:
|
||||
raise ProviderError(
|
||||
"PROVIDER_TIMEOUT",
|
||||
"Replicate prediction did not finish within timeout.",
|
||||
{"prediction_id": prediction.get("id")},
|
||||
)
|
||||
async with session.get(poll_url, headers=headers) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
raise ProviderError(
|
||||
"PROVIDER_UNAVAILABLE",
|
||||
f"Replicate poll HTTP {resp.status}.",
|
||||
{"body": text[:500]},
|
||||
)
|
||||
body = await resp.json()
|
||||
status = body.get("status")
|
||||
if status in ("succeeded",):
|
||||
return body
|
||||
if status in ("failed", "canceled"):
|
||||
raise ProviderError(
|
||||
"PROVIDER_BAD_RESPONSE",
|
||||
f"Replicate prediction {status}: {body.get('error')}",
|
||||
{"prediction": body},
|
||||
)
|
||||
await asyncio.sleep(_POLL_INTERVAL)
|
||||
|
||||
|
||||
def _first_url(output: Any) -> str | None:
|
||||
if isinstance(output, str):
|
||||
return output
|
||||
if isinstance(output, list) and output:
|
||||
first = output[0]
|
||||
if isinstance(first, str):
|
||||
return first
|
||||
return None
|
||||
|
||||
|
||||
async def _fetch_url(url: str) -> bytes:
|
||||
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url) as resp:
|
||||
if resp.status != 200:
|
||||
raise ProviderError(
|
||||
"PROVIDER_BAD_RESPONSE",
|
||||
f"Failed to fetch Replicate output (HTTP {resp.status}).",
|
||||
{"status": resp.status},
|
||||
)
|
||||
return await resp.read()
|
||||
154
plugins/ai_image/providers/stability.py
Normal file
154
plugins/ai_image/providers/stability.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""Stability AI image generation provider (Stable Image Core / Ultra).
|
||||
|
||||
Uses the v2beta generate endpoint with ``Accept: image/*`` to get raw
|
||||
bytes back directly (no JSON indirection).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from plugins.ai_image.providers.base import (
|
||||
BaseImageProvider,
|
||||
GenerationRequest,
|
||||
GenerationResult,
|
||||
ProviderError,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger("mcphub.ai_image.stability")
|
||||
|
||||
_MODEL_ENDPOINTS = {
|
||||
"core": "https://api.stability.ai/v2beta/stable-image/generate/core",
|
||||
"ultra": "https://api.stability.ai/v2beta/stable-image/generate/ultra",
|
||||
"sd3": "https://api.stability.ai/v2beta/stable-image/generate/sd3",
|
||||
}
|
||||
_DEFAULT_MODEL = "core"
|
||||
_MAX_RETRIES = 3
|
||||
_REQUEST_TIMEOUT = 120
|
||||
_RETRY_STATUS = {429, 500, 502, 503, 504}
|
||||
|
||||
_COST_TABLE: dict[str, float] = {
|
||||
"core": 0.03,
|
||||
"ultra": 0.08,
|
||||
"sd3": 0.065,
|
||||
}
|
||||
|
||||
|
||||
def _size_to_aspect(size: str) -> str:
|
||||
"""Map a WxH size string to the Stability aspect_ratio enum."""
|
||||
mapping = {
|
||||
"1024x1024": "1:1",
|
||||
"1152x896": "21:9",
|
||||
"1216x832": "3:2",
|
||||
"1344x768": "16:9",
|
||||
"768x1344": "9:16",
|
||||
"832x1216": "2:3",
|
||||
"1024x576": "16:9",
|
||||
"512x512": "1:1",
|
||||
}
|
||||
return mapping.get(size, "1:1")
|
||||
|
||||
|
||||
class StabilityProvider(BaseImageProvider):
|
||||
name = "stability"
|
||||
|
||||
async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
|
||||
if not api_key:
|
||||
raise ProviderError("PROVIDER_AUTH", "Stability API key is missing.")
|
||||
|
||||
model = (request.model or _DEFAULT_MODEL).lower()
|
||||
endpoint = _MODEL_ENDPOINTS.get(model)
|
||||
if endpoint is None:
|
||||
raise ProviderError(
|
||||
"PROVIDER_BAD_REQUEST",
|
||||
f"Unknown Stability model '{model}'. " f"Allowed: {', '.join(_MODEL_ENDPOINTS)}.",
|
||||
)
|
||||
|
||||
form = aiohttp.FormData()
|
||||
form.add_field("prompt", request.prompt)
|
||||
form.add_field("output_format", "png")
|
||||
form.add_field("aspect_ratio", _size_to_aspect(request.size))
|
||||
if request.negative_prompt:
|
||||
form.add_field("negative_prompt", request.negative_prompt)
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Accept": "image/*",
|
||||
}
|
||||
data, meta = await self._post_with_retry(endpoint, form, headers)
|
||||
|
||||
meta = dict(meta)
|
||||
meta["model"] = model
|
||||
meta["aspect_ratio"] = _size_to_aspect(request.size)
|
||||
return GenerationResult(
|
||||
data=data,
|
||||
mime="image/png",
|
||||
filename=f"stability-{model}.png",
|
||||
meta=meta,
|
||||
cost_usd=_COST_TABLE.get(model),
|
||||
)
|
||||
|
||||
async def _post_with_retry(
|
||||
self,
|
||||
endpoint: str,
|
||||
form: aiohttp.FormData,
|
||||
headers: dict[str, str],
|
||||
) -> tuple[bytes, dict[str, Any]]:
|
||||
last_error: str = ""
|
||||
delay = 1.0
|
||||
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
for attempt in range(1, _MAX_RETRIES + 1):
|
||||
try:
|
||||
async with session.post(endpoint, data=form, headers=headers) as resp:
|
||||
if resp.status == 200:
|
||||
body = await resp.read()
|
||||
return body, {"attempt": attempt, "status": 200}
|
||||
text = await resp.text()
|
||||
last_error = text[:500]
|
||||
if resp.status == 401 or resp.status == 403:
|
||||
raise ProviderError(
|
||||
"PROVIDER_AUTH",
|
||||
f"Stability rejected auth ({resp.status}).",
|
||||
{"body": last_error},
|
||||
)
|
||||
if resp.status == 400:
|
||||
raise ProviderError(
|
||||
"PROVIDER_BAD_REQUEST",
|
||||
f"Stability rejected request: {last_error}",
|
||||
{"body": last_error},
|
||||
)
|
||||
if resp.status in _RETRY_STATUS and attempt < _MAX_RETRIES:
|
||||
_logger.warning("Stability %d, retry %d", resp.status, attempt)
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
continue
|
||||
if resp.status == 429:
|
||||
raise ProviderError(
|
||||
"PROVIDER_QUOTA",
|
||||
"Stability quota/rate-limit hit.",
|
||||
{"status": 429, "body": last_error},
|
||||
)
|
||||
raise ProviderError(
|
||||
"PROVIDER_UNAVAILABLE",
|
||||
f"Stability upstream error HTTP {resp.status}.",
|
||||
{"status": resp.status, "body": last_error},
|
||||
)
|
||||
except TimeoutError as exc:
|
||||
last_error = f"timeout: {exc}"
|
||||
if attempt < _MAX_RETRIES:
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
continue
|
||||
raise ProviderError(
|
||||
"PROVIDER_TIMEOUT",
|
||||
"Stability request timed out after retries.",
|
||||
) from exc
|
||||
raise ProviderError(
|
||||
"PROVIDER_UNAVAILABLE",
|
||||
f"Stability call failed after {_MAX_RETRIES} attempts: {last_error}",
|
||||
)
|
||||
36
plugins/ai_image/registry.py
Normal file
36
plugins/ai_image/registry.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Provider registry for AI image generation (F.5a.4).
|
||||
|
||||
Lookup by name; raises :class:`ProviderError` with code
|
||||
``PROVIDER_UNKNOWN`` if the caller supplies a non-existent provider.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from plugins.ai_image.providers.base import BaseImageProvider, ProviderError
|
||||
from plugins.ai_image.providers.openai import OpenAIProvider
|
||||
from plugins.ai_image.providers.openrouter import OpenRouterProvider
|
||||
from plugins.ai_image.providers.replicate import ReplicateProvider
|
||||
from plugins.ai_image.providers.stability import StabilityProvider
|
||||
|
||||
_PROVIDERS: dict[str, BaseImageProvider] = {
|
||||
"openai": OpenAIProvider(),
|
||||
"stability": StabilityProvider(),
|
||||
"replicate": ReplicateProvider(),
|
||||
"openrouter": OpenRouterProvider(),
|
||||
}
|
||||
|
||||
|
||||
def get_provider(name: str) -> BaseImageProvider:
|
||||
"""Return the registered provider singleton by name."""
|
||||
try:
|
||||
return _PROVIDERS[name]
|
||||
except KeyError as exc:
|
||||
raise ProviderError(
|
||||
"PROVIDER_UNKNOWN",
|
||||
f"Unknown provider '{name}'. Allowed: {', '.join(_PROVIDERS)}.",
|
||||
) from exc
|
||||
|
||||
|
||||
def list_providers() -> list[str]:
|
||||
"""Return all registered provider names in registration order."""
|
||||
return list(_PROVIDERS.keys())
|
||||
@@ -165,6 +165,40 @@ class BasePlugin(ABC):
|
||||
"""
|
||||
return {"healthy": True, "message": "Health check not implemented for this plugin"}
|
||||
|
||||
async def probe_credential_capabilities(self) -> dict[str, Any]:
|
||||
"""F.7e — report what the saved credential can actually do.
|
||||
|
||||
Plugins should override this to call the upstream service
|
||||
(WordPress companion ``/capabilities``, WooCommerce
|
||||
``system_status``, Gitea ``/user`` header scopes, Coolify
|
||||
``/api/v1/teams/0``, etc.) and return the subset of capability
|
||||
names the caller's token actually grants.
|
||||
|
||||
The base implementation returns a well-shaped "probe not
|
||||
available" payload so callers can handle all plugin types
|
||||
uniformly without special-casing the ones that haven't
|
||||
implemented a probe yet.
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
probe_available: bool — True if the plugin knows how to
|
||||
query its upstream for capabilities.
|
||||
granted: list[str] — the capabilities the credential
|
||||
actually grants (subset of the universe the plugin
|
||||
understands). Empty when ``probe_available=False``.
|
||||
source: str — which endpoint / header the list came from;
|
||||
``"unavailable"`` when no probe is wired.
|
||||
reason: str | None — optional human-readable diagnostic
|
||||
when ``probe_available=False`` or when the call
|
||||
failed in a way that still produced a usable answer.
|
||||
"""
|
||||
return {
|
||||
"probe_available": False,
|
||||
"granted": [],
|
||||
"source": "unavailable",
|
||||
"reason": "probe_not_implemented",
|
||||
}
|
||||
|
||||
def get_project_info(self) -> dict[str, Any]:
|
||||
"""
|
||||
Return basic information about this project instance.
|
||||
|
||||
@@ -206,25 +206,10 @@ class GiteaClient:
|
||||
|
||||
async def create_file(self, owner: str, repo: str, filepath: str, data: dict) -> dict:
|
||||
"""Create a file"""
|
||||
# Encode content to base64 if not already encoded
|
||||
if "content" in data:
|
||||
content = data["content"]
|
||||
|
||||
# Check if content is already base64 encoded
|
||||
is_already_base64 = data.get("content_is_base64", False)
|
||||
|
||||
if not is_already_base64:
|
||||
# Plain text content - encode to base64
|
||||
try:
|
||||
data["content"] = base64.b64encode(content.encode()).decode()
|
||||
except (AttributeError, UnicodeDecodeError):
|
||||
# If content is already bytes or has encoding issues, try direct encoding
|
||||
if isinstance(content, bytes):
|
||||
data["content"] = base64.b64encode(content).decode()
|
||||
else:
|
||||
raise ValueError("Content must be a string or bytes")
|
||||
|
||||
# Remove the flag before sending to API
|
||||
data["content"] = self._normalise_file_content(
|
||||
data["content"], data.get("content_is_base64", False)
|
||||
)
|
||||
data.pop("content_is_base64", None)
|
||||
|
||||
return await self.request(
|
||||
@@ -233,31 +218,63 @@ class GiteaClient:
|
||||
|
||||
async def update_file(self, owner: str, repo: str, filepath: str, data: dict) -> dict:
|
||||
"""Update a file"""
|
||||
# Encode content to base64 if not already encoded
|
||||
if "content" in data:
|
||||
content = data["content"]
|
||||
|
||||
# Check if content is already base64 encoded
|
||||
is_already_base64 = data.get("content_is_base64", False)
|
||||
|
||||
if not is_already_base64:
|
||||
# Plain text content - encode to base64
|
||||
try:
|
||||
data["content"] = base64.b64encode(content.encode()).decode()
|
||||
except (AttributeError, UnicodeDecodeError):
|
||||
# If content is already bytes or has encoding issues, try direct encoding
|
||||
if isinstance(content, bytes):
|
||||
data["content"] = base64.b64encode(content).decode()
|
||||
else:
|
||||
raise ValueError("Content must be a string or bytes")
|
||||
|
||||
# Remove the flag before sending to API
|
||||
data["content"] = self._normalise_file_content(
|
||||
data["content"], data.get("content_is_base64", False)
|
||||
)
|
||||
data.pop("content_is_base64", None)
|
||||
|
||||
return await self.request(
|
||||
"PUT", f"repos/{owner}/{repo}/contents/{filepath}", json_data=data
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalise_file_content(content: Any, is_already_base64: bool) -> str:
|
||||
"""F.17 ergonomics: turn ``content`` into a base64 string suitable
|
||||
for Gitea's contents endpoints, with actionable error messages.
|
||||
|
||||
``is_already_base64=True`` validates that ``content`` decodes
|
||||
cleanly and re-emits it stripped of whitespace; if the decode
|
||||
fails, raise a message that tells the caller exactly how to
|
||||
recover (drop ``data:`` prefix, use ``content_is_base64=False``
|
||||
for raw text). ``is_already_base64=False`` UTF-8 + base64
|
||||
encodes the string.
|
||||
"""
|
||||
if is_already_base64:
|
||||
if not isinstance(content, str):
|
||||
raise ValueError(
|
||||
"content_is_base64=True but content is not a string. "
|
||||
"Pass the raw base64 text; do not wrap it in bytes / dicts."
|
||||
)
|
||||
stripped = content.strip()
|
||||
if stripped.lower().startswith("data:"):
|
||||
raise ValueError(
|
||||
"content looks like a data: URL (starts with 'data:'). "
|
||||
"Strip the 'data:<mime>;base64,' prefix before sending — "
|
||||
"Gitea expects the base64 payload only."
|
||||
)
|
||||
try:
|
||||
# Validate round-trip and normalise whitespace.
|
||||
raw = base64.b64decode(stripped, validate=True)
|
||||
return base64.b64encode(raw).decode()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise ValueError(
|
||||
"content_is_base64=True but content is not valid base64. "
|
||||
"If you meant to send raw text, set content_is_base64=False "
|
||||
"(the client will base64-encode it for you). Original "
|
||||
f"decoder error: {exc}"
|
||||
) from exc
|
||||
|
||||
# Plain text / bytes → base64-encode for the caller.
|
||||
if isinstance(content, bytes):
|
||||
return base64.b64encode(content).decode()
|
||||
if isinstance(content, str):
|
||||
return base64.b64encode(content.encode("utf-8")).decode()
|
||||
raise ValueError(
|
||||
"content must be a string or bytes when content_is_base64=False. "
|
||||
f"Got {type(content).__name__}."
|
||||
)
|
||||
|
||||
async def delete_file(
|
||||
self,
|
||||
owner: str,
|
||||
@@ -275,6 +292,186 @@ class GiteaClient:
|
||||
"DELETE", f"repos/{owner}/{repo}/contents/{filepath}", json_data=data
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# F.17 — ergonomics: batch file write, tree listing, search, compare,
|
||||
# releases, fork.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def change_files(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
data: dict,
|
||||
) -> dict:
|
||||
"""POST /repos/{owner}/{repo}/contents — apply a batch of file
|
||||
create/update/delete operations in a single commit.
|
||||
|
||||
Gitea's ``files`` endpoint takes a list of ``{operation, path,
|
||||
content, sha}`` entries. Operations: ``create`` | ``update`` |
|
||||
``delete``. ``content`` must be base64 for create/update.
|
||||
"""
|
||||
return await self.request("POST", f"repos/{owner}/{repo}/contents", json_data=data)
|
||||
|
||||
async def get_tree(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
sha: str = "HEAD",
|
||||
*,
|
||||
recursive: bool = False,
|
||||
page: int = 1,
|
||||
per_page: int = 100,
|
||||
) -> dict:
|
||||
"""GET /repos/{owner}/{repo}/git/trees/{sha}
|
||||
|
||||
Returns ``{sha, url, tree: [{path, mode, type, size, sha, url}],
|
||||
truncated}``. Set ``recursive=True`` to get the entire tree.
|
||||
"""
|
||||
params: dict[str, Any] = {"page": page, "per_page": per_page}
|
||||
if recursive:
|
||||
params["recursive"] = "true"
|
||||
return await self.request("GET", f"repos/{owner}/{repo}/git/trees/{sha}", params=params)
|
||||
|
||||
async def search_code(
|
||||
self,
|
||||
*,
|
||||
keyword: str,
|
||||
owner: str | None = None,
|
||||
repo: str | None = None,
|
||||
page: int = 1,
|
||||
per_page: int = 30,
|
||||
) -> dict:
|
||||
"""Search code either across all repos (``/repos/search/code``)
|
||||
or within a specific repo (``/repos/{owner}/{repo}/search/code``).
|
||||
|
||||
Returns the raw Gitea payload: ``{ok, data: [...]}``.
|
||||
"""
|
||||
params: dict[str, Any] = {"q": keyword, "page": page, "per_page": per_page}
|
||||
if owner and repo:
|
||||
path = f"repos/{owner}/{repo}/search/code"
|
||||
else:
|
||||
path = "repos/search/code"
|
||||
return await self.request("GET", path, params=params)
|
||||
|
||||
async def compare(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
base: str,
|
||||
head: str,
|
||||
) -> dict:
|
||||
"""GET /repos/{owner}/{repo}/compare/{base}...{head}"""
|
||||
# Gitea's compare endpoint uses ``...`` as the separator. The HTTP
|
||||
# client URL-encodes path params, so we pre-join instead of
|
||||
# passing them as separate path parameters.
|
||||
spec = f"{base}...{head}"
|
||||
return await self.request("GET", f"repos/{owner}/{repo}/compare/{spec}")
|
||||
|
||||
async def list_releases(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
*,
|
||||
page: int = 1,
|
||||
per_page: int = 30,
|
||||
) -> list[dict]:
|
||||
"""GET /repos/{owner}/{repo}/releases"""
|
||||
return await self.request(
|
||||
"GET",
|
||||
f"repos/{owner}/{repo}/releases",
|
||||
params={"page": page, "per_page": per_page},
|
||||
)
|
||||
|
||||
async def create_release(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
data: dict,
|
||||
) -> dict:
|
||||
"""POST /repos/{owner}/{repo}/releases"""
|
||||
return await self.request("POST", f"repos/{owner}/{repo}/releases", json_data=data)
|
||||
|
||||
async def get_release(self, owner: str, repo: str, release_id: int) -> dict:
|
||||
return await self.request("GET", f"repos/{owner}/{repo}/releases/{release_id}")
|
||||
|
||||
async def delete_release(self, owner: str, repo: str, release_id: int) -> dict:
|
||||
return await self.request("DELETE", f"repos/{owner}/{repo}/releases/{release_id}")
|
||||
|
||||
async def upload_release_asset(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
release_id: int,
|
||||
*,
|
||||
filename: str,
|
||||
content_b64: str,
|
||||
) -> dict:
|
||||
"""Upload a release asset.
|
||||
|
||||
Gitea's API accepts multipart/form-data on
|
||||
``POST /repos/{owner}/{repo}/releases/{id}/assets?name=FILE``.
|
||||
We accept base64 content so the tool schema stays JSON-friendly;
|
||||
callers supply ``content_b64``. Decoded bytes go into the
|
||||
multipart body. This bypasses ``self.request`` because that
|
||||
helper only understands JSON bodies.
|
||||
"""
|
||||
import aiohttp
|
||||
|
||||
try:
|
||||
raw = base64.b64decode(content_b64, validate=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise ValueError(
|
||||
"content_b64 must be a valid base64 string. "
|
||||
"For small text files, base64-encode the UTF-8 bytes; "
|
||||
"do not include a 'data:' URL prefix."
|
||||
) from exc
|
||||
|
||||
url = f"{self.api_base}/repos/{owner}/{repo}/releases/{release_id}/assets"
|
||||
headers = self._get_headers()
|
||||
# Requests library-style: multipart/form-data with attachment field.
|
||||
form = aiohttp.FormData()
|
||||
form.add_field(
|
||||
"attachment",
|
||||
raw,
|
||||
filename=filename,
|
||||
content_type="application/octet-stream",
|
||||
)
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
url, params={"name": filename}, data=form, headers=headers
|
||||
) as resp:
|
||||
text = await resp.text()
|
||||
if resp.status >= 400:
|
||||
raise Exception(
|
||||
f"Gitea upload_release_asset failed ({resp.status}): {text[:500]}"
|
||||
)
|
||||
try:
|
||||
import json as _json
|
||||
|
||||
return _json.loads(text) if text else {"success": True}
|
||||
except Exception:
|
||||
return {"success": True, "raw": text[:500]}
|
||||
|
||||
async def fork_repository(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
*,
|
||||
organization: str | None = None,
|
||||
name: str | None = None,
|
||||
) -> dict:
|
||||
"""POST /repos/{owner}/{repo}/forks
|
||||
|
||||
``organization``: target org (omit to fork under the caller).
|
||||
``name``: custom name for the fork.
|
||||
"""
|
||||
payload: dict[str, Any] = {}
|
||||
if organization:
|
||||
payload["organization"] = organization
|
||||
if name:
|
||||
payload["name"] = name
|
||||
return await self.request("POST", f"repos/{owner}/{repo}/forks", json_data=payload)
|
||||
|
||||
# Issue endpoints
|
||||
async def list_issues(self, owner: str, repo: str, params: dict) -> list[dict]:
|
||||
"""List repository issues"""
|
||||
|
||||
@@ -482,6 +482,202 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
# === F.17 ergonomics: batch files, tree, search, compare, releases, fork ===
|
||||
{
|
||||
"name": "create_files",
|
||||
"method_name": "create_files",
|
||||
"description": (
|
||||
"Create or update multiple files in a single commit. Uses Gitea's "
|
||||
"``/repos/{owner}/{repo}/contents`` batch endpoint. Per-file operations: "
|
||||
"``create`` | ``update`` | ``delete``. For create/update, pass raw UTF-8 "
|
||||
"``content`` (default) or a base64 string with ``content_is_base64=true`` "
|
||||
"per file. Delete requires each file's current ``sha``."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "minLength": 1},
|
||||
"repo": {"type": "string", "minLength": 1},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Single commit message for the whole batch.",
|
||||
"minLength": 1,
|
||||
},
|
||||
"branch": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"new_branch": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "If set, commit on a new branch created from ``branch``.",
|
||||
},
|
||||
"files": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 100,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["create", "update", "delete"],
|
||||
},
|
||||
"path": {"type": "string", "minLength": 1},
|
||||
"content": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Required for create/update.",
|
||||
},
|
||||
"sha": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Required for update/delete.",
|
||||
},
|
||||
"content_is_base64": {"type": "boolean", "default": False},
|
||||
},
|
||||
"required": ["operation", "path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "files", "message"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "get_tree",
|
||||
"method_name": "get_tree",
|
||||
"description": (
|
||||
"List the file tree of a Gitea repository. ``sha`` may be a branch "
|
||||
"name, tag, or commit (default: HEAD). ``recursive=true`` returns "
|
||||
"the entire tree in one payload. Use this instead of issuing many "
|
||||
"``get_file`` calls when you don't know the paths ahead of time."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "minLength": 1},
|
||||
"repo": {"type": "string", "minLength": 1},
|
||||
"sha": {"type": "string", "default": "HEAD"},
|
||||
"recursive": {"type": "boolean", "default": False},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"per_page": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"default": 100,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "search_code",
|
||||
"method_name": "search_code",
|
||||
"description": (
|
||||
"Search code inside Gitea. When ``owner`` and ``repo`` are set, the "
|
||||
"search scopes to that repository; otherwise it's an instance-wide "
|
||||
"code search. Returns Gitea's raw ``{ok, data: [...]}`` shape."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"keyword": {"type": "string", "minLength": 1},
|
||||
"owner": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"repo": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"per_page": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 50,
|
||||
"default": 30,
|
||||
},
|
||||
},
|
||||
"required": ["keyword"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "compare",
|
||||
"method_name": "compare",
|
||||
"description": (
|
||||
"Compare two commits / branches / tags in a Gitea repository. Returns "
|
||||
"commits + file diffs between ``base`` and ``head`` (the Gitea "
|
||||
"``compare`` endpoint)."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "minLength": 1},
|
||||
"repo": {"type": "string", "minLength": 1},
|
||||
"base": {"type": "string", "minLength": 1},
|
||||
"head": {"type": "string", "minLength": 1},
|
||||
},
|
||||
"required": ["owner", "repo", "base", "head"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "list_releases",
|
||||
"method_name": "list_releases",
|
||||
"description": "List releases of a Gitea repository (paginated).",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "minLength": 1},
|
||||
"repo": {"type": "string", "minLength": 1},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"per_page": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 50,
|
||||
"default": 30,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_release",
|
||||
"method_name": "create_release",
|
||||
"description": (
|
||||
"Create a release (tag + release metadata) on a Gitea repository. "
|
||||
"Set ``draft`` to hide it from users until published."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "minLength": 1},
|
||||
"repo": {"type": "string", "minLength": 1},
|
||||
"tag_name": {"type": "string", "minLength": 1},
|
||||
"name": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"body": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"target_commitish": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Branch or commit to tag (default: default branch).",
|
||||
},
|
||||
"draft": {"type": "boolean", "default": False},
|
||||
"prerelease": {"type": "boolean", "default": False},
|
||||
},
|
||||
"required": ["owner", "repo", "tag_name"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "fork_repository",
|
||||
"method_name": "fork_repository",
|
||||
"description": (
|
||||
"Fork a Gitea repository. Without ``organization``, forks under the "
|
||||
"calling user. ``name`` optionally renames the fork."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "minLength": 1},
|
||||
"repo": {"type": "string", "minLength": 1},
|
||||
"organization": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"name": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -724,3 +920,180 @@ async def delete_file(
|
||||
await client.delete_file(owner, repo, path, sha, message, branch)
|
||||
result = {"success": True, "message": f"File '{path}' deleted successfully"}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# F.17 ergonomics: batch files, tree, search, compare, releases, fork
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def create_files(
|
||||
client: GiteaClient,
|
||||
owner: str,
|
||||
repo: str,
|
||||
files: list[dict[str, Any]],
|
||||
message: str,
|
||||
branch: str | None = None,
|
||||
new_branch: str | None = None,
|
||||
) -> str:
|
||||
"""Apply a batch of create / update / delete file operations in a single commit.
|
||||
|
||||
Each entry in ``files`` is normalised so Gitea's batch endpoint
|
||||
receives base64-encoded content with the ``content_is_base64`` flag
|
||||
stripped (the server expects base64). Validation:
|
||||
|
||||
* ``operation`` is ``create`` / ``update`` / ``delete``.
|
||||
* ``path`` required on every entry.
|
||||
* ``content`` required on create / update.
|
||||
* ``sha`` required on update / delete (current file SHA).
|
||||
"""
|
||||
prepared: list[dict[str, Any]] = []
|
||||
errors: list[dict[str, Any]] = []
|
||||
|
||||
for idx, f in enumerate(files or []):
|
||||
op = (f.get("operation") or "").strip().lower()
|
||||
path = f.get("path")
|
||||
if op not in {"create", "update", "delete"}:
|
||||
errors.append({"index": idx, "path": path, "error": f"invalid_operation:{op!r}"})
|
||||
continue
|
||||
if not path:
|
||||
errors.append({"index": idx, "error": "missing_path"})
|
||||
continue
|
||||
if op in {"update", "delete"} and not f.get("sha"):
|
||||
errors.append({"index": idx, "path": path, "error": f"missing_sha_for_{op}"})
|
||||
continue
|
||||
|
||||
entry: dict[str, Any] = {"operation": op, "path": path}
|
||||
if op in {"create", "update"}:
|
||||
content = f.get("content")
|
||||
if content is None:
|
||||
errors.append({"index": idx, "path": path, "error": "missing_content"})
|
||||
continue
|
||||
try:
|
||||
entry["content"] = client._normalise_file_content(
|
||||
content, bool(f.get("content_is_base64", False))
|
||||
)
|
||||
except ValueError as exc:
|
||||
errors.append({"index": idx, "path": path, "error": str(exc)})
|
||||
continue
|
||||
if f.get("sha"):
|
||||
entry["sha"] = f["sha"]
|
||||
if f.get("from_path"): # Gitea also supports rename via from_path
|
||||
entry["from_path"] = f["from_path"]
|
||||
prepared.append(entry)
|
||||
|
||||
if errors:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": "validation_failed",
|
||||
"errors": errors,
|
||||
"total": len(files or []),
|
||||
"prepared": len(prepared),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
payload: dict[str, Any] = {"message": message, "files": prepared}
|
||||
if branch:
|
||||
payload["branch"] = branch
|
||||
if new_branch:
|
||||
payload["new_branch"] = new_branch
|
||||
|
||||
result = await client.change_files(owner, repo, payload)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Batched {len(prepared)} file operation(s) into one commit",
|
||||
"result": result,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
|
||||
async def get_tree(
|
||||
client: GiteaClient,
|
||||
owner: str,
|
||||
repo: str,
|
||||
sha: str = "HEAD",
|
||||
recursive: bool = False,
|
||||
page: int = 1,
|
||||
per_page: int = 100,
|
||||
) -> str:
|
||||
tree = await client.get_tree(
|
||||
owner, repo, sha, recursive=recursive, page=page, per_page=per_page
|
||||
)
|
||||
return json.dumps({"success": True, "tree": tree}, indent=2)
|
||||
|
||||
|
||||
async def search_code(
|
||||
client: GiteaClient,
|
||||
keyword: str,
|
||||
owner: str | None = None,
|
||||
repo: str | None = None,
|
||||
page: int = 1,
|
||||
per_page: int = 30,
|
||||
) -> str:
|
||||
result = await client.search_code(
|
||||
keyword=keyword, owner=owner, repo=repo, page=page, per_page=per_page
|
||||
)
|
||||
return json.dumps({"success": True, "result": result}, indent=2)
|
||||
|
||||
|
||||
async def compare(
|
||||
client: GiteaClient,
|
||||
owner: str,
|
||||
repo: str,
|
||||
base: str,
|
||||
head: str,
|
||||
) -> str:
|
||||
diff = await client.compare(owner, repo, base, head)
|
||||
return json.dumps({"success": True, "compare": diff}, indent=2)
|
||||
|
||||
|
||||
async def list_releases(
|
||||
client: GiteaClient,
|
||||
owner: str,
|
||||
repo: str,
|
||||
page: int = 1,
|
||||
per_page: int = 30,
|
||||
) -> str:
|
||||
releases = await client.list_releases(owner, repo, page=page, per_page=per_page)
|
||||
return json.dumps({"success": True, "releases": releases}, indent=2)
|
||||
|
||||
|
||||
async def create_release(
|
||||
client: GiteaClient,
|
||||
owner: str,
|
||||
repo: str,
|
||||
tag_name: str,
|
||||
name: str | None = None,
|
||||
body: str | None = None,
|
||||
target_commitish: str | None = None,
|
||||
draft: bool = False,
|
||||
prerelease: bool = False,
|
||||
) -> str:
|
||||
data: dict[str, Any] = {
|
||||
"tag_name": tag_name,
|
||||
"draft": draft,
|
||||
"prerelease": prerelease,
|
||||
}
|
||||
if name is not None:
|
||||
data["name"] = name
|
||||
if body is not None:
|
||||
data["body"] = body
|
||||
if target_commitish is not None:
|
||||
data["target_commitish"] = target_commitish
|
||||
release = await client.create_release(owner, repo, data)
|
||||
return json.dumps({"success": True, "release": release}, indent=2)
|
||||
|
||||
|
||||
async def fork_repository(
|
||||
client: GiteaClient,
|
||||
owner: str,
|
||||
repo: str,
|
||||
organization: str | None = None,
|
||||
name: str | None = None,
|
||||
) -> str:
|
||||
fork = await client.fork_repository(owner, repo, organization=organization, name=name)
|
||||
return json.dumps({"success": True, "fork": fork}, indent=2)
|
||||
|
||||
@@ -7,6 +7,8 @@ Modular handlers for better organization and maintainability.
|
||||
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from plugins.base import BasePlugin
|
||||
from plugins.gitea import handlers
|
||||
from plugins.gitea.client import GiteaClient
|
||||
@@ -160,3 +162,61 @@ class GiteaPlugin(BasePlugin):
|
||||
return {"healthy": True, "message": "Gitea instance is accessible"}
|
||||
except Exception as e:
|
||||
return {"healthy": False, "message": f"Gitea health check failed: {str(e)}"}
|
||||
|
||||
async def probe_credential_capabilities(self) -> dict[str, Any]:
|
||||
"""F.7e — report what the Gitea access token grants.
|
||||
|
||||
Gitea tokens carry OAuth-style scope labels. Scopes are surfaced
|
||||
two ways (different Gitea versions disagree) and we accept both:
|
||||
|
||||
* ``X-OAuth-Scopes`` header on the response of any authenticated
|
||||
endpoint — the format used by GitHub-compatible clients.
|
||||
* ``capabilities`` key in ``meta`` (rare; ignored here — the
|
||||
header is the universal path).
|
||||
|
||||
We hit ``GET /api/v1/user`` which every token that can do
|
||||
anything useful on Gitea is allowed to call. Failures return
|
||||
``probe_available=False`` with a reason rather than raising,
|
||||
so the badge falls back to "probe unavailable" cleanly.
|
||||
"""
|
||||
url = f"{self.client.api_base}/user"
|
||||
headers = self.client._get_headers()
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=15)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url, headers=headers) as resp:
|
||||
if resp.status >= 400:
|
||||
body = (await resp.text())[:200]
|
||||
return {
|
||||
"probe_available": False,
|
||||
"granted": [],
|
||||
"source": "gitea_oauth_scopes",
|
||||
"reason": f"user_endpoint_http_{resp.status}: {body}",
|
||||
}
|
||||
scopes_header = resp.headers.get("X-OAuth-Scopes", "")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"probe_available": False,
|
||||
"granted": [],
|
||||
"source": "gitea_oauth_scopes",
|
||||
"reason": f"probe_failed: {exc}",
|
||||
}
|
||||
|
||||
granted = [s.strip() for s in scopes_header.split(",") if s.strip()]
|
||||
|
||||
if not granted:
|
||||
# Gitea instances without per-token scopes (unset header)
|
||||
# grant full access to whatever the user account itself
|
||||
# can do. Report that honestly rather than claiming nothing.
|
||||
return {
|
||||
"probe_available": False,
|
||||
"granted": [],
|
||||
"source": "gitea_oauth_scopes",
|
||||
"reason": "scopes_header_absent_or_empty",
|
||||
}
|
||||
|
||||
return {
|
||||
"probe_available": True,
|
||||
"granted": sorted(granted),
|
||||
"source": "gitea_oauth_scopes",
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""
|
||||
n8n REST API Client
|
||||
"""n8n REST API Client.
|
||||
|
||||
Handles all HTTP communication with n8n REST API.
|
||||
Separates API communication from business logic.
|
||||
@@ -11,6 +10,64 @@ from typing import Any
|
||||
import aiohttp
|
||||
|
||||
|
||||
class N8nApiError(Exception):
|
||||
"""Base exception for n8n API errors with structured error info."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
error_code: str = "API_ERROR",
|
||||
status_code: int = 0,
|
||||
hint: str = "",
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.error_code = error_code
|
||||
self.status_code = status_code
|
||||
self.hint = hint
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
d: dict[str, Any] = {"error_code": self.error_code, "message": str(self)}
|
||||
if self.status_code:
|
||||
d["status_code"] = self.status_code
|
||||
if self.hint:
|
||||
d["hint"] = self.hint
|
||||
return d
|
||||
|
||||
|
||||
class N8nAuthError(N8nApiError):
|
||||
"""Raised on 401/403 — invalid or under-scoped API key."""
|
||||
|
||||
def __init__(self, message: str, *, status_code: int = 401, hint: str = "") -> None:
|
||||
super().__init__(
|
||||
message,
|
||||
error_code="AUTH_FAILED",
|
||||
status_code=status_code,
|
||||
hint=hint or "Check the API key in n8n → Settings → API → API Keys.",
|
||||
)
|
||||
|
||||
|
||||
class N8nNotFoundError(N8nApiError):
|
||||
"""Raised on 404 — resource doesn't exist."""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message, error_code="NOT_FOUND", status_code=404)
|
||||
|
||||
|
||||
class N8nValidationError(N8nApiError):
|
||||
"""Raised on 400/422 — bad payload."""
|
||||
|
||||
def __init__(self, message: str, *, status_code: int = 400) -> None:
|
||||
super().__init__(message, error_code="VALIDATION_ERROR", status_code=status_code)
|
||||
|
||||
|
||||
class N8nConnectionError(N8nApiError):
|
||||
"""Raised on network-level failures."""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message, error_code="CONNECTION_ERROR")
|
||||
|
||||
|
||||
class N8nClient:
|
||||
"""
|
||||
n8n REST API client for HTTP communication.
|
||||
@@ -66,69 +123,63 @@ class N8nClient:
|
||||
json_data: dict | None = None,
|
||||
headers_override: dict | None = None,
|
||||
) -> Any:
|
||||
"""
|
||||
Make authenticated request to n8n REST API.
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, PUT, DELETE, PATCH)
|
||||
endpoint: API endpoint (without base URL)
|
||||
params: Query parameters
|
||||
json_data: JSON body data
|
||||
headers_override: Override default headers
|
||||
|
||||
Returns:
|
||||
API response (dict, list, or None)
|
||||
"""Make authenticated request to n8n REST API.
|
||||
|
||||
Raises:
|
||||
Exception: On API errors with status code and message
|
||||
N8nAuthError: On 401/403.
|
||||
N8nNotFoundError: On 404.
|
||||
N8nValidationError: On 400/422.
|
||||
N8nApiError: On other 4xx/5xx.
|
||||
N8nConnectionError: On network-level failure.
|
||||
"""
|
||||
# Build full URL
|
||||
url = f"{self.api_base}/{endpoint.lstrip('/')}"
|
||||
|
||||
# Setup headers
|
||||
headers = self._get_headers(headers_override)
|
||||
|
||||
# Filter out None values from params
|
||||
if params:
|
||||
params = {k: v for k, v in params.items() if v is not None}
|
||||
|
||||
# Filter None values from JSON data
|
||||
if json_data:
|
||||
json_data = {k: v for k, v in json_data.items() if v is not None}
|
||||
|
||||
# Make request
|
||||
self.logger.debug(f"{method} {url}")
|
||||
self.logger.debug(f"Params: {params}")
|
||||
self.logger.debug(f"Data: {json_data}")
|
||||
self.logger.debug("%s %s", method, url)
|
||||
|
||||
async with (
|
||||
aiohttp.ClientSession() as session,
|
||||
session.request(
|
||||
method=method, url=url, params=params, json=json_data, headers=headers
|
||||
) as response,
|
||||
):
|
||||
# Log response
|
||||
self.logger.debug(f"Response status: {response.status}")
|
||||
try:
|
||||
async with (
|
||||
aiohttp.ClientSession() as session,
|
||||
session.request(
|
||||
method=method, url=url, params=params, json=json_data, headers=headers
|
||||
) as response,
|
||||
):
|
||||
self.logger.debug("Response status: %d", response.status)
|
||||
|
||||
# Handle empty responses (e.g., 204 No Content)
|
||||
if response.status == 204:
|
||||
return {"success": True, "message": "Operation completed successfully"}
|
||||
if response.status == 204:
|
||||
return {"success": True, "message": "Operation completed successfully"}
|
||||
|
||||
try:
|
||||
response_data = await response.json()
|
||||
except Exception:
|
||||
response_text = await response.text()
|
||||
if response.status >= 400:
|
||||
self._raise_for_status(response.status, response_text)
|
||||
return {"success": True, "message": response_text}
|
||||
|
||||
# Try to parse JSON response
|
||||
try:
|
||||
response_data = await response.json()
|
||||
except Exception:
|
||||
response_text = await response.text()
|
||||
if response.status >= 400:
|
||||
raise Exception(f"n8n API error (status {response.status}): {response_text}")
|
||||
return {"success": True, "message": response_text}
|
||||
error_msg = response_data.get("message", str(response_data))
|
||||
self._raise_for_status(response.status, error_msg)
|
||||
|
||||
# Check for errors
|
||||
if response.status >= 400:
|
||||
error_msg = response_data.get("message", str(response_data))
|
||||
raise Exception(f"n8n API error (status {response.status}): {error_msg}")
|
||||
return response_data
|
||||
|
||||
return response_data
|
||||
except (aiohttp.ClientError, OSError) as exc:
|
||||
raise N8nConnectionError(f"Cannot reach n8n at {self.site_url}: {exc}") from exc
|
||||
|
||||
@staticmethod
|
||||
def _raise_for_status(status: int, message: str) -> None:
|
||||
if status in (401, 403):
|
||||
raise N8nAuthError(message, status_code=status)
|
||||
if status == 404:
|
||||
raise N8nNotFoundError(message)
|
||||
if status in (400, 422):
|
||||
raise N8nValidationError(message, status_code=status)
|
||||
raise N8nApiError(message, error_code="API_ERROR", status_code=status)
|
||||
|
||||
# =====================
|
||||
# WORKFLOW ENDPOINTS
|
||||
@@ -176,8 +227,18 @@ class N8nClient:
|
||||
"""Execute a workflow manually"""
|
||||
return await self.request("POST", f"workflows/{workflow_id}/run", json_data=data or {})
|
||||
|
||||
async def transfer_workflow(
|
||||
self, workflow_id: str, destination_project_id: str
|
||||
) -> dict[str, Any]:
|
||||
"""Transfer workflow to another project (Enterprise)."""
|
||||
return await self.request(
|
||||
"PUT",
|
||||
f"workflows/{workflow_id}/transfer",
|
||||
json_data={"destinationProjectId": destination_project_id},
|
||||
)
|
||||
|
||||
async def get_workflow_tags(self, workflow_id: str) -> list[dict[str, Any]]:
|
||||
"""Get tags assigned to a workflow"""
|
||||
"""Get tags assigned to a workflow."""
|
||||
workflow = await self.get_workflow(workflow_id)
|
||||
return workflow.get("tags", [])
|
||||
|
||||
@@ -189,14 +250,16 @@ class N8nClient:
|
||||
self,
|
||||
workflow_id: str | None = None,
|
||||
status: str | None = None,
|
||||
project_id: str | None = None,
|
||||
include_data: bool = False,
|
||||
limit: int = 20,
|
||||
cursor: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""List workflow executions with filters"""
|
||||
"""List workflow executions with filters."""
|
||||
params = {
|
||||
"workflowId": workflow_id,
|
||||
"status": status,
|
||||
"projectId": project_id,
|
||||
"includeData": str(include_data).lower(),
|
||||
"limit": limit,
|
||||
"cursor": cursor,
|
||||
@@ -391,12 +454,17 @@ class N8nClient:
|
||||
data["variables"] = variables
|
||||
return await self.request("POST", "source-control/pull", json_data=data)
|
||||
|
||||
async def get_current_user(self) -> dict[str, Any]:
|
||||
"""Get current authenticated user info (for capability probe)."""
|
||||
return await self.request("GET", "user")
|
||||
|
||||
async def health_check(self) -> dict[str, Any]:
|
||||
"""Check n8n instance health"""
|
||||
# Use direct URL, not API base
|
||||
"""Check n8n instance health."""
|
||||
url = f"{self.site_url}/healthz"
|
||||
async with aiohttp.ClientSession() as session, session.get(url) as response:
|
||||
if response.status == 200:
|
||||
return {"healthy": True, "status": "ok"}
|
||||
else:
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session, session.get(url) as response:
|
||||
if response.status == 200:
|
||||
return {"healthy": True, "status": "ok"}
|
||||
return {"healthy": False, "status": f"unhealthy (status {response.status})"}
|
||||
except (aiohttp.ClientError, OSError) as exc:
|
||||
raise N8nConnectionError(f"Cannot reach n8n health endpoint at {url}: {exc}") from exc
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"""Credentials Handler - manages n8n credentials"""
|
||||
"""Credentials Handler - manages n8n credentials."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.n8n.client import N8nClient
|
||||
from plugins.n8n.client import N8nApiError, N8nClient
|
||||
|
||||
|
||||
def _error_json(exc: Exception) -> str:
|
||||
if isinstance(exc, N8nApiError):
|
||||
return json.dumps({"success": False, **exc.to_dict()}, indent=2)
|
||||
return json.dumps({"success": False, "error": str(exc)}, indent=2)
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
@@ -107,7 +113,7 @@ async def get_credential(client: N8nClient, credential_id: str) -> str:
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def create_credential(client: N8nClient, name: str, type: str, data: dict[str, Any]) -> str:
|
||||
@@ -126,7 +132,7 @@ async def create_credential(client: N8nClient, name: str, type: str, data: dict[
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def delete_credential(client: N8nClient, credential_id: str) -> str:
|
||||
@@ -137,7 +143,7 @@ async def delete_credential(client: N8nClient, credential_id: str) -> str:
|
||||
{"success": True, "message": f"Credential {credential_id} deleted"}, indent=2
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def get_credential_schema(client: N8nClient, credential_type: str) -> str:
|
||||
@@ -148,7 +154,7 @@ async def get_credential_schema(client: N8nClient, credential_type: str) -> str:
|
||||
{"success": True, "credential_type": credential_type, "schema": schema}, indent=2
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def transfer_credential(
|
||||
@@ -165,4 +171,4 @@ async def transfer_credential(
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
"""Execution Handler - manages n8n workflow executions"""
|
||||
"""Execution Handler - manages n8n workflow executions."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.n8n.client import N8nClient
|
||||
from plugins.n8n.client import N8nApiError, N8nClient
|
||||
|
||||
|
||||
def _error_json(exc: Exception) -> str:
|
||||
if isinstance(exc, N8nApiError):
|
||||
return json.dumps({"success": False, **exc.to_dict()}, indent=2)
|
||||
return json.dumps({"success": False, "error": str(exc)}, indent=2)
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
@@ -27,6 +33,10 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"enum": ["success", "error", "waiting", "running", "new"],
|
||||
"description": "OPTIONAL: Filter by execution status. Omit for all statuses.",
|
||||
},
|
||||
"project_id": {
|
||||
"type": "string",
|
||||
"description": "OPTIONAL: Filter by project ID (Enterprise). Omit for all projects.",
|
||||
},
|
||||
"include_data": {
|
||||
"type": "boolean",
|
||||
"description": "Include full execution data",
|
||||
@@ -200,15 +210,17 @@ async def list_executions(
|
||||
client: N8nClient,
|
||||
workflow_id: str | None = None,
|
||||
status: str | None = None,
|
||||
project_id: str | None = None,
|
||||
include_data: bool = False,
|
||||
limit: int = 20,
|
||||
cursor: str | None = None,
|
||||
) -> str:
|
||||
"""List workflow executions"""
|
||||
"""List workflow executions."""
|
||||
try:
|
||||
response = await client.list_executions(
|
||||
workflow_id=workflow_id,
|
||||
status=status,
|
||||
project_id=project_id,
|
||||
include_data=include_data,
|
||||
limit=limit,
|
||||
cursor=cursor,
|
||||
@@ -239,7 +251,7 @@ async def list_executions(
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def get_execution(client: N8nClient, execution_id: str, include_data: bool = True) -> str:
|
||||
@@ -270,7 +282,7 @@ async def get_execution(client: N8nClient, execution_id: str, include_data: bool
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def delete_execution(client: N8nClient, execution_id: str) -> str:
|
||||
@@ -283,7 +295,7 @@ async def delete_execution(client: N8nClient, execution_id: str) -> str:
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def delete_executions(client: N8nClient, execution_ids: list[str]) -> str:
|
||||
@@ -309,7 +321,7 @@ async def delete_executions(client: N8nClient, execution_ids: list[str]) -> str:
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def stop_execution(client: N8nClient, execution_id: str) -> str:
|
||||
@@ -322,7 +334,7 @@ async def stop_execution(client: N8nClient, execution_id: str) -> str:
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def retry_execution(client: N8nClient, execution_id: str) -> str:
|
||||
@@ -366,7 +378,7 @@ async def retry_execution(client: N8nClient, execution_id: str) -> str:
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def get_execution_data(client: N8nClient, execution_id: str) -> str:
|
||||
@@ -390,7 +402,7 @@ async def get_execution_data(client: N8nClient, execution_id: str) -> str:
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def wait_for_execution(
|
||||
@@ -444,4 +456,4 @@ async def wait_for_execution(
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"""Projects Handler - manages n8n projects (Enterprise/Pro)"""
|
||||
"""Projects Handler - manages n8n projects (Enterprise/Pro)."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.n8n.client import N8nClient
|
||||
from plugins.n8n.client import N8nApiError, N8nClient
|
||||
|
||||
|
||||
def _error_json(exc: Exception) -> str:
|
||||
if isinstance(exc, N8nApiError):
|
||||
return json.dumps({"success": False, **exc.to_dict()}, indent=2)
|
||||
return json.dumps({"success": False, "error": str(exc)}, indent=2)
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
@@ -146,7 +152,7 @@ async def list_projects(client: N8nClient, limit: int = 100, cursor: str | None
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def get_project(client: N8nClient, project_id: str) -> str:
|
||||
@@ -157,7 +163,7 @@ async def get_project(client: N8nClient, project_id: str) -> str:
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def create_project(client: N8nClient, name: str) -> str:
|
||||
@@ -172,7 +178,7 @@ async def create_project(client: N8nClient, name: str) -> str:
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def update_project(client: N8nClient, project_id: str, name: str) -> str:
|
||||
@@ -187,7 +193,7 @@ async def update_project(client: N8nClient, project_id: str, name: str) -> str:
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def delete_project(client: N8nClient, project_id: str) -> str:
|
||||
@@ -195,7 +201,7 @@ async def delete_project(client: N8nClient, project_id: str) -> str:
|
||||
await client.delete_project(project_id)
|
||||
return json.dumps({"success": True, "message": f"Project {project_id} deleted"}, indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def add_project_users(client: N8nClient, project_id: str, users: list[dict[str, str]]) -> str:
|
||||
@@ -207,7 +213,7 @@ async def add_project_users(client: N8nClient, project_id: str, users: list[dict
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def change_project_user_role(
|
||||
@@ -223,7 +229,7 @@ async def change_project_user_role(
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def remove_project_user(client: N8nClient, project_id: str, user_id: str) -> str:
|
||||
@@ -234,4 +240,4 @@ async def remove_project_user(client: N8nClient, project_id: str, user_id: str)
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"""System Handler - manages n8n system operations (audit, source control, health)"""
|
||||
"""System Handler - manages n8n system operations (audit, source control, health)."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.n8n.client import N8nClient
|
||||
from plugins.n8n.client import N8nApiError, N8nClient
|
||||
|
||||
|
||||
def _error_json(exc: Exception) -> str:
|
||||
if isinstance(exc, N8nApiError):
|
||||
return json.dumps({"success": False, **exc.to_dict()}, indent=2)
|
||||
return json.dumps({"success": False, "error": str(exc)}, indent=2)
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
@@ -84,7 +90,7 @@ async def run_security_audit(client: N8nClient, categories: list[str] | None = N
|
||||
|
||||
return json.dumps(audit_data, indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def source_control_pull(
|
||||
@@ -99,7 +105,7 @@ async def source_control_pull(
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def get_instance_info(client: N8nClient) -> str:
|
||||
@@ -115,13 +121,12 @@ async def get_instance_info(client: N8nClient) -> str:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get current user to verify connectivity
|
||||
try:
|
||||
user = await client.request("GET", "user")
|
||||
user = await client.get_current_user()
|
||||
info["current_user"] = {
|
||||
"id": user.get("id"),
|
||||
"email": user.get("email"),
|
||||
"role": user.get("role"),
|
||||
"role": user.get("role") or user.get("globalRole"),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
@@ -131,7 +136,7 @@ async def get_instance_info(client: N8nClient) -> str:
|
||||
|
||||
return json.dumps({"success": True, "instance_info": info}, indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def health_check(client: N8nClient) -> str:
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"""Tags Handler - manages n8n tags"""
|
||||
"""Tags Handler - manages n8n tags."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.n8n.client import N8nClient
|
||||
from plugins.n8n.client import N8nApiError, N8nClient
|
||||
|
||||
|
||||
def _error_json(exc: Exception) -> str:
|
||||
if isinstance(exc, N8nApiError):
|
||||
return json.dumps({"success": False, **exc.to_dict()}, indent=2)
|
||||
return json.dumps({"success": False, "error": str(exc)}, indent=2)
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
@@ -99,7 +105,7 @@ async def list_tags(client: N8nClient, limit: int = 100, cursor: str | None = No
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def get_tag(client: N8nClient, tag_id: str) -> str:
|
||||
@@ -109,7 +115,7 @@ async def get_tag(client: N8nClient, tag_id: str) -> str:
|
||||
{"success": True, "tag": {"id": tag.get("id"), "name": tag.get("name")}}, indent=2
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def create_tag(client: N8nClient, name: str) -> str:
|
||||
@@ -124,7 +130,7 @@ async def create_tag(client: N8nClient, name: str) -> str:
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def update_tag(client: N8nClient, tag_id: str, name: str) -> str:
|
||||
@@ -139,7 +145,7 @@ async def update_tag(client: N8nClient, tag_id: str, name: str) -> str:
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def delete_tag(client: N8nClient, tag_id: str) -> str:
|
||||
@@ -147,7 +153,7 @@ async def delete_tag(client: N8nClient, tag_id: str) -> str:
|
||||
await client.delete_tag(tag_id)
|
||||
return json.dumps({"success": True, "message": f"Tag {tag_id} deleted"}, indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def delete_tags(client: N8nClient, tag_ids: list[str]) -> str:
|
||||
@@ -164,4 +170,4 @@ async def delete_tags(client: N8nClient, tag_ids: list[str]) -> str:
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"""Users Handler - manages n8n users"""
|
||||
"""Users Handler - manages n8n users."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.n8n.client import N8nClient
|
||||
from plugins.n8n.client import N8nApiError, N8nClient
|
||||
|
||||
|
||||
def _error_json(exc: Exception) -> str:
|
||||
if isinstance(exc, N8nApiError):
|
||||
return json.dumps({"success": False, **exc.to_dict()}, indent=2)
|
||||
return json.dumps({"success": False, "error": str(exc)}, indent=2)
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
@@ -115,7 +121,7 @@ async def list_users(
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def get_user(client: N8nClient, user_id: str, include_role: bool = True) -> str:
|
||||
@@ -136,7 +142,7 @@ async def get_user(client: N8nClient, user_id: str, include_role: bool = True) -
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def create_user(client: N8nClient, email: str, role: str = "global:member") -> str:
|
||||
@@ -152,7 +158,7 @@ async def create_user(client: N8nClient, email: str, role: str = "global:member"
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def delete_user(client: N8nClient, user_id: str) -> str:
|
||||
@@ -160,7 +166,7 @@ async def delete_user(client: N8nClient, user_id: str) -> str:
|
||||
await client.delete_user(user_id)
|
||||
return json.dumps({"success": True, "message": f"User {user_id} deleted"}, indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def change_user_role(client: N8nClient, user_id: str, new_role: str) -> str:
|
||||
@@ -170,4 +176,4 @@ async def change_user_role(client: N8nClient, user_id: str, new_role: str) -> st
|
||||
{"success": True, "message": f"User {user_id} role changed to {new_role}"}, indent=2
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"""Variables Handler - manages n8n environment variables"""
|
||||
"""Variables Handler - manages n8n environment variables."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.n8n.client import N8nClient
|
||||
from plugins.n8n.client import N8nApiError, N8nClient
|
||||
|
||||
|
||||
def _error_json(exc: Exception) -> str:
|
||||
if isinstance(exc, N8nApiError):
|
||||
return json.dumps({"success": False, **exc.to_dict()}, indent=2)
|
||||
return json.dumps({"success": False, "error": str(exc)}, indent=2)
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
@@ -106,7 +112,7 @@ async def list_variables(client: N8nClient, limit: int = 100, cursor: str | None
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def get_variable(client: N8nClient, key: str) -> str:
|
||||
@@ -120,7 +126,7 @@ async def get_variable(client: N8nClient, key: str) -> str:
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def create_variable(client: N8nClient, key: str, value: str) -> str:
|
||||
@@ -135,7 +141,7 @@ async def create_variable(client: N8nClient, key: str, value: str) -> str:
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def update_variable(client: N8nClient, key: str, value: str) -> str:
|
||||
@@ -150,7 +156,7 @@ async def update_variable(client: N8nClient, key: str, value: str) -> str:
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def delete_variable(client: N8nClient, key: str) -> str:
|
||||
@@ -158,7 +164,7 @@ async def delete_variable(client: N8nClient, key: str) -> str:
|
||||
await client.delete_variable(key)
|
||||
return json.dumps({"success": True, "message": f"Variable '{key}' deleted"}, indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def set_variables(client: N8nClient, variables: dict[str, str]) -> str:
|
||||
@@ -190,4 +196,4 @@ async def set_variables(client: N8nClient, variables: dict[str, str]) -> str:
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Workflow Handler - manages n8n workflows"""
|
||||
"""Workflow Handler - manages n8n workflows."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.n8n.client import N8nClient
|
||||
from plugins.n8n.client import N8nApiError, N8nClient
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
@@ -318,12 +318,45 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
# === TRANSFER WORKFLOW TO PROJECT (Enterprise) ===
|
||||
{
|
||||
"name": "transfer_workflow_to_project",
|
||||
"method_name": "transfer_workflow_to_project",
|
||||
"description": (
|
||||
"Transfer a workflow to a different project (Enterprise/Pro). "
|
||||
"The destination project must exist and the API key must have "
|
||||
"access to both the source and destination projects."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workflow_id": {
|
||||
"type": "string",
|
||||
"description": "Workflow ID to transfer",
|
||||
"minLength": 1,
|
||||
},
|
||||
"destination_project_id": {
|
||||
"type": "string",
|
||||
"description": "Target project ID",
|
||||
"minLength": 1,
|
||||
},
|
||||
},
|
||||
"required": ["workflow_id", "destination_project_id"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# === HANDLER FUNCTIONS ===
|
||||
|
||||
|
||||
def _error_json(exc: Exception) -> str:
|
||||
if isinstance(exc, N8nApiError):
|
||||
return json.dumps({"success": False, **exc.to_dict()}, indent=2)
|
||||
return json.dumps({"success": False, "error": str(exc)}, indent=2)
|
||||
|
||||
|
||||
async def list_workflows(
|
||||
client: N8nClient,
|
||||
active: bool | None = None,
|
||||
@@ -361,7 +394,7 @@ async def list_workflows(
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def get_workflow(client: N8nClient, workflow_id: str) -> str:
|
||||
@@ -388,7 +421,7 @@ async def get_workflow(client: N8nClient, workflow_id: str) -> str:
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def create_workflow(
|
||||
@@ -429,7 +462,7 @@ async def create_workflow(
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def update_workflow(
|
||||
@@ -470,7 +503,7 @@ async def update_workflow(
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def delete_workflow(client: N8nClient, workflow_id: str) -> str:
|
||||
@@ -483,7 +516,7 @@ async def delete_workflow(client: N8nClient, workflow_id: str) -> str:
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def activate_workflow(client: N8nClient, workflow_id: str) -> str:
|
||||
@@ -504,7 +537,7 @@ async def activate_workflow(client: N8nClient, workflow_id: str) -> str:
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def deactivate_workflow(client: N8nClient, workflow_id: str) -> str:
|
||||
@@ -525,7 +558,7 @@ async def deactivate_workflow(client: N8nClient, workflow_id: str) -> str:
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def execute_workflow(client: N8nClient, workflow_id: str) -> str:
|
||||
@@ -546,7 +579,7 @@ async def execute_workflow(client: N8nClient, workflow_id: str) -> str:
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def execute_workflow_with_data(
|
||||
@@ -570,7 +603,7 @@ async def execute_workflow_with_data(
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def duplicate_workflow(client: N8nClient, workflow_id: str, new_name: str) -> str:
|
||||
@@ -599,7 +632,7 @@ async def duplicate_workflow(client: N8nClient, workflow_id: str, new_name: str)
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def export_workflow(client: N8nClient, workflow_id: str) -> str:
|
||||
@@ -613,7 +646,7 @@ async def export_workflow(client: N8nClient, workflow_id: str) -> str:
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def import_workflow(
|
||||
@@ -646,7 +679,7 @@ async def import_workflow(
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def get_workflow_tags(client: N8nClient, workflow_id: str) -> str:
|
||||
@@ -659,7 +692,7 @@ async def get_workflow_tags(client: N8nClient, workflow_id: str) -> str:
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def set_workflow_tags(
|
||||
@@ -693,4 +726,23 @@ async def set_workflow_tags(
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
return _error_json(e)
|
||||
|
||||
|
||||
async def transfer_workflow_to_project(
|
||||
client: N8nClient, workflow_id: str, destination_project_id: str
|
||||
) -> str:
|
||||
"""Transfer a workflow to another project (Enterprise)."""
|
||||
try:
|
||||
await client.transfer_workflow(workflow_id, destination_project_id)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Workflow {workflow_id} transferred to project {destination_project_id}",
|
||||
"workflow_id": workflow_id,
|
||||
"destination_project_id": destination_project_id,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
return _error_json(e)
|
||||
|
||||
@@ -18,8 +18,8 @@ class N8nPlugin(BasePlugin):
|
||||
n8n Automation Plugin - Comprehensive workflow management.
|
||||
|
||||
Provides complete n8n management capabilities including:
|
||||
- Workflow management (CRUD, activate, deactivate, execute)
|
||||
- Execution monitoring (list, get, delete, retry, wait)
|
||||
- Workflow management (CRUD, activate, deactivate, execute, transfer)
|
||||
- Execution monitoring (list, get, delete, retry, wait, project filter)
|
||||
- Credential management (get, create, delete, schema, transfer)
|
||||
- Tag management (CRUD, bulk delete)
|
||||
- User management (CRUD, roles)
|
||||
@@ -27,7 +27,7 @@ class N8nPlugin(BasePlugin):
|
||||
- Variable management (CRUD, bulk set) - Enterprise/Pro
|
||||
- System operations (audit, source control, health)
|
||||
|
||||
Total: 56 tools
|
||||
Total: 57 tools
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@@ -69,7 +69,7 @@ class N8nPlugin(BasePlugin):
|
||||
specs = []
|
||||
|
||||
# Collect specifications from all handlers
|
||||
specs.extend(handlers.workflows.get_tool_specifications()) # 14 tools
|
||||
specs.extend(handlers.workflows.get_tool_specifications()) # 15 tools
|
||||
specs.extend(handlers.executions.get_tool_specifications()) # 8 tools
|
||||
specs.extend(handlers.credentials.get_tool_specifications()) # 5 tools
|
||||
specs.extend(handlers.tags.get_tool_specifications()) # 6 tools
|
||||
@@ -118,24 +118,62 @@ class N8nPlugin(BasePlugin):
|
||||
# Method not found in any handler
|
||||
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
|
||||
|
||||
async def probe_credential_capabilities(self) -> dict[str, Any]:
|
||||
"""F.7e — probe the n8n API key's effective permissions.
|
||||
|
||||
Calls ``GET /api/v1/user`` which returns the current user's
|
||||
``globalRole`` (and ``globalScopes`` on enterprise). If the key
|
||||
is invalid or the endpoint is unreachable we return a graceful
|
||||
fallback so the dashboard can still render a status badge.
|
||||
"""
|
||||
from plugins.n8n.client import N8nAuthError, N8nConnectionError
|
||||
|
||||
try:
|
||||
user = await self.client.get_current_user()
|
||||
except N8nAuthError as exc:
|
||||
return {
|
||||
"probe_available": False,
|
||||
"granted": [],
|
||||
"source": "n8n_api",
|
||||
"reason": f"auth_failed: {exc}",
|
||||
}
|
||||
except (N8nConnectionError, Exception) as exc: # noqa: BLE001
|
||||
return {
|
||||
"probe_available": False,
|
||||
"granted": [],
|
||||
"source": "n8n_api",
|
||||
"reason": f"probe_failed: {exc}",
|
||||
}
|
||||
|
||||
role_name = ""
|
||||
role_obj = user.get("role") or user.get("globalRole") or ""
|
||||
if isinstance(role_obj, dict):
|
||||
role_name = role_obj.get("name", "")
|
||||
elif isinstance(role_obj, str):
|
||||
role_name = role_obj
|
||||
scopes = user.get("globalScopes") or []
|
||||
|
||||
return {
|
||||
"probe_available": True,
|
||||
"granted": sorted(scopes) if scopes else [role_name],
|
||||
"source": "n8n_api",
|
||||
"role": role_name,
|
||||
"email": user.get("email", ""),
|
||||
}
|
||||
|
||||
async def check_health(self) -> dict[str, Any]:
|
||||
"""
|
||||
Check if n8n instance is accessible (internal use).
|
||||
|
||||
Note: This is named check_health to avoid shadowing the handler's
|
||||
health_check function which is exposed as an MCP tool.
|
||||
|
||||
Returns:
|
||||
Dict containing health check result
|
||||
"""
|
||||
"""Check if n8n instance is accessible (internal use)."""
|
||||
try:
|
||||
result = await self.client.health_check()
|
||||
return {
|
||||
"healthy": result.get("healthy", False),
|
||||
"message": f"n8n instance at {self.client.site_url} is {'accessible' if result.get('healthy') else 'not accessible'}",
|
||||
"message": (
|
||||
f"n8n instance at {self.client.site_url} is "
|
||||
f"{'accessible' if result.get('healthy') else 'not accessible'}"
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"healthy": False, "message": f"n8n health check failed: {str(e)}"}
|
||||
return {"healthy": False, "message": f"n8n health check failed: {e}"}
|
||||
|
||||
async def health_check(self, **kwargs) -> str:
|
||||
"""
|
||||
|
||||
@@ -7,23 +7,30 @@ Provides 28 tools for WooCommerce store management.
|
||||
Uses shared WordPress handlers for implementation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from plugins.base import BasePlugin
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress.handlers import (
|
||||
AIMediaHandler,
|
||||
CouponsHandler,
|
||||
CustomersHandler,
|
||||
MediaAttachHandler,
|
||||
OrdersHandler,
|
||||
ProductsHandler,
|
||||
ReportsHandler,
|
||||
get_ai_media_specs,
|
||||
get_coupons_specs,
|
||||
get_customers_specs,
|
||||
get_media_attach_specs,
|
||||
get_orders_specs,
|
||||
get_products_specs,
|
||||
get_reports_specs,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WooCommercePlugin(BasePlugin):
|
||||
"""
|
||||
@@ -58,6 +65,8 @@ class WooCommercePlugin(BasePlugin):
|
||||
- url: WordPress/WooCommerce site URL
|
||||
- consumer_key/consumer_secret: WooCommerce REST API keys (preferred)
|
||||
- username/app_password: WordPress application password (fallback)
|
||||
- wp_username/wp_app_password: optional WP Application Password
|
||||
for /wp/v2/media calls (used by media tools — F.X.fix-pass4).
|
||||
project_id: Optional project ID (auto-generated if not provided)
|
||||
"""
|
||||
super().__init__(config, project_id=project_id)
|
||||
@@ -76,17 +85,68 @@ class WooCommercePlugin(BasePlugin):
|
||||
"Please set either CONSUMER_KEY/CONSUMER_SECRET or USERNAME/APP_PASSWORD."
|
||||
)
|
||||
|
||||
# Create WordPress API client
|
||||
# Create WordPress API client (used for /wc/v3/* WooCommerce REST).
|
||||
self.client = WordPressClient(
|
||||
site_url=config["url"], username=username, app_password=password
|
||||
)
|
||||
|
||||
# F.X.fix-pass4 — derive a SECONDARY client for /wp/v2/* media
|
||||
# uploads. WC's Consumer Key + Secret pair does NOT authenticate
|
||||
# WP core REST endpoints; uploads to /wp/v2/media require an
|
||||
# Application Password from the WP admin user. Three resolution
|
||||
# paths, first that wins:
|
||||
# 1. explicit wp_username + wp_app_password fields (recommended)
|
||||
# 2. legacy username + app_password (single-credential mode)
|
||||
# 3. None — media tools will surface a clear error at call time
|
||||
wp_user = (config.get("wp_username") or "").strip()
|
||||
wp_pw = (config.get("wp_app_password") or "").strip()
|
||||
legacy_user = (config.get("username") or "").strip()
|
||||
legacy_pw = (config.get("app_password") or "").strip()
|
||||
# If consumer_key/consumer_secret are present, we're in WC-keys
|
||||
# mode and the primary client cannot hit /wp/v2/*. We need
|
||||
# explicit WP creds OR legacy app_password to enable media tools.
|
||||
if wp_user and wp_pw:
|
||||
self.wp_media_client: WordPressClient | None = WordPressClient(
|
||||
site_url=config["url"], username=wp_user, app_password=wp_pw
|
||||
)
|
||||
elif (
|
||||
legacy_user
|
||||
and legacy_pw
|
||||
and not (config.get("consumer_key") or config.get("consumer_secret"))
|
||||
):
|
||||
# Legacy app_password mode (no ck/cs at all) — same client
|
||||
# works for both /wp/v2/* and /wc/v3/* because it uses an
|
||||
# Application Password.
|
||||
self.wp_media_client = self.client
|
||||
else:
|
||||
self.wp_media_client = None
|
||||
|
||||
# Initialize WooCommerce handlers
|
||||
self.products = ProductsHandler(self.client)
|
||||
self.orders = OrdersHandler(self.client)
|
||||
self.customers = CustomersHandler(self.client)
|
||||
self.coupons = CouponsHandler(self.client)
|
||||
self.reports = ReportsHandler(self.client)
|
||||
self.media_attach = MediaAttachHandler(self.client, wp_media_client=self.wp_media_client)
|
||||
# F.X.fix-pass5 — expose generate_and_upload_image on WC sites
|
||||
# too (was WP-only). The handler reads the per-site provider
|
||||
# key resolver and uploads via wp_media_client (so WC sites
|
||||
# with consumer_key/consumer_secret as primary still work as
|
||||
# long as wp_username + wp_app_password are configured).
|
||||
# When wp_media_client is None we still register the tool so
|
||||
# the user sees a clear NO_PROVIDER_KEY / WP_CREDENTIALS_MISSING
|
||||
# error rather than a missing-tool 404.
|
||||
self.ai_media = AIMediaHandler(
|
||||
self.wp_media_client or self.client,
|
||||
user_id=config.get("user_id"),
|
||||
site_id=config.get("site_id"),
|
||||
# F.X.fix-pass6 — pass the primary WC client so
|
||||
# _apply_metadata_and_attach can detect "attach_to_post
|
||||
# is a WC product" and route featured-image set through
|
||||
# /wc/v3/products/{id} instead of the WP /posts endpoint
|
||||
# which 404s for products.
|
||||
wc_client=self.client,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
@@ -116,6 +176,16 @@ class WooCommercePlugin(BasePlugin):
|
||||
# Reports (3 tools)
|
||||
specs.extend(get_reports_specs())
|
||||
|
||||
# F.5a.3: Media attachment (3 tools — attach_media_to_product,
|
||||
# upload_and_attach_to_product, set_featured_image)
|
||||
specs.extend(get_media_attach_specs())
|
||||
|
||||
# F.X.fix-pass5: AI image generation (1 tool — also surfaced on
|
||||
# the WP plugin; same handler, same per-site provider key
|
||||
# resolver). On a WC site this lets the operator chain
|
||||
# generate → attach without two endpoints.
|
||||
specs.extend(get_ai_media_specs())
|
||||
|
||||
return specs
|
||||
|
||||
async def health_check(self) -> dict[str, Any]:
|
||||
@@ -147,6 +217,118 @@ class WooCommercePlugin(BasePlugin):
|
||||
"plugin_type": "woocommerce",
|
||||
}
|
||||
|
||||
async def probe_credential_capabilities(self) -> dict[str, Any]:
|
||||
"""F.7e — report what the WooCommerce consumer key grants.
|
||||
|
||||
Uses ``GET /wp-json/wc/v3/system_status`` which is readable by any
|
||||
key with at least ``read`` permission and includes a
|
||||
``security.rest_api_keys[]`` array where each entry carries a
|
||||
``permissions`` field (``read`` / ``write`` / ``read_write``). We
|
||||
match the caller's consumer_key against the ``truncated_key`` the
|
||||
endpoint reports (last 7 chars) and derive the granted cap list.
|
||||
|
||||
Falls back gracefully on any network / shape failure — callers
|
||||
still get the tools, they just don't get the early-warning badge.
|
||||
"""
|
||||
try:
|
||||
status = await self.client.get("system_status", use_woocommerce=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"probe_available": False,
|
||||
"granted": [],
|
||||
"source": "woocommerce_system_status",
|
||||
"reason": f"system_status_unreachable: {exc}",
|
||||
}
|
||||
|
||||
if not isinstance(status, dict):
|
||||
return {
|
||||
"probe_available": False,
|
||||
"granted": [],
|
||||
"source": "woocommerce_system_status",
|
||||
"reason": "non_dict_response",
|
||||
}
|
||||
|
||||
keys = ((status.get("security") or {}).get("rest_api_keys")) or []
|
||||
consumer_key = self.config.get("consumer_key") or self.config.get("username") or ""
|
||||
# WC's system_status truncates to last 7 chars by default.
|
||||
my_tail = consumer_key[-7:] if consumer_key else ""
|
||||
match = None
|
||||
for entry in keys:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
tail = str(entry.get("truncated_key") or "")
|
||||
if my_tail and tail == my_tail:
|
||||
match = entry
|
||||
break
|
||||
|
||||
if match is None:
|
||||
# Fallback: if there's exactly one key listed OR the endpoint
|
||||
# doesn't expose truncated_key on this WC version, fall back
|
||||
# to the top entry so we still show *something* useful.
|
||||
if len(keys) == 1 and isinstance(keys[0], dict):
|
||||
match = keys[0]
|
||||
|
||||
# F.X.fix-pass5 — flag whether the site has WP App Password
|
||||
# credentials configured. Media tools (and AI image with
|
||||
# attach) need these on a WC site whose primary credential is
|
||||
# consumer_key + secret. The prerequisites resolver in
|
||||
# core/tool_access reads this to gate the WC media tool list.
|
||||
wp_credentials_present = bool(
|
||||
(self.config.get("wp_username") and self.config.get("wp_app_password"))
|
||||
or (
|
||||
self.config.get("username")
|
||||
and self.config.get("app_password")
|
||||
and not (self.config.get("consumer_key") or self.config.get("consumer_secret"))
|
||||
)
|
||||
)
|
||||
|
||||
if match is not None:
|
||||
permission = str(match.get("permissions") or "").lower()
|
||||
granted: list[str] = []
|
||||
if permission in {"read", "read_write", "write"}:
|
||||
granted.append("read_products")
|
||||
granted.append("read_orders")
|
||||
if permission in {"write", "read_write"}:
|
||||
granted.append("write_products")
|
||||
granted.append("write_orders")
|
||||
|
||||
return {
|
||||
"probe_available": True,
|
||||
"granted": sorted(granted),
|
||||
"source": "woocommerce_system_status",
|
||||
"permissions": permission,
|
||||
"wp_credentials_present": wp_credentials_present,
|
||||
}
|
||||
|
||||
# F.X.fix-pass3 — match is None (consumer key not listed). This
|
||||
# happens when:
|
||||
# * system_status omits truncated_key on the running WC build
|
||||
# * the key was created via WP-CLI / a custom path
|
||||
# * the key is shadowed by another key with the same tail
|
||||
# Don't surface a false-negative "probe unavailable" (which the
|
||||
# badge labelled with a misleading "install companion plugin"
|
||||
# hint, since WC has no companion).
|
||||
#
|
||||
# F.X.fix-pass5 — STAY CONSERVATIVE. The previous pass probed
|
||||
# ``GET /wc/v3/settings`` and upgraded to write+admin on 200,
|
||||
# but that signal mixes two unrelated facts (WP user has
|
||||
# ``manage_woocommerce`` capability AND key has any read perm)
|
||||
# and was over-granting on read-only keys whose backing user
|
||||
# was an admin. Result: tier-fit "Read + Write" stayed green
|
||||
# for read-only keys. Now we report read-only and let the
|
||||
# tier-fit warning fire correctly. Operators with read+write
|
||||
# keys still see their tools work; the badge just says
|
||||
# "Currently granted: read_products, read_orders" — a soft
|
||||
# signal, not a block.
|
||||
return {
|
||||
"probe_available": True,
|
||||
"granted": sorted({"read_products", "read_orders"}),
|
||||
"source": "woocommerce_system_status_inferred",
|
||||
"permissions": "inferred",
|
||||
"probe_inferred": True,
|
||||
"wp_credentials_present": wp_credentials_present,
|
||||
}
|
||||
|
||||
# ========================================
|
||||
# Method Delegation to Handlers
|
||||
# ========================================
|
||||
@@ -239,3 +421,17 @@ class WooCommercePlugin(BasePlugin):
|
||||
|
||||
async def get_customer_report(self, **kwargs):
|
||||
return await self.reports.get_customer_report(**kwargs)
|
||||
|
||||
# === F.5a.3: Media attach ===
|
||||
async def attach_media_to_product(self, **kwargs):
|
||||
return await self.media_attach.attach_media_to_product(**kwargs)
|
||||
|
||||
async def upload_and_attach_to_product(self, **kwargs):
|
||||
return await self.media_attach.upload_and_attach_to_product(**kwargs)
|
||||
|
||||
async def set_featured_image(self, **kwargs):
|
||||
return await self.media_attach.set_featured_image(**kwargs)
|
||||
|
||||
# === F.X.fix-pass5: AI image (re-exported from WP handler) ===
|
||||
async def generate_and_upload_image(self, **kwargs):
|
||||
return await self.ai_media.generate_and_upload_image(**kwargs)
|
||||
|
||||
@@ -33,11 +33,42 @@ class ConnectionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SiteUnreachableError(ConnectionError):
|
||||
"""Raised when the WordPress site is not reachable at the TCP/DNS layer.
|
||||
|
||||
Subclass of :class:`ConnectionError` so existing ``except ConnectionError``
|
||||
sites keep working. Carries a structured ``error_code='SITE_UNREACHABLE'``
|
||||
plus optional ``install_hint`` so companion-backed handlers and the
|
||||
capability probe can emit a uniform error payload that the dashboard
|
||||
can render as a "check your URL / install companion" prompt in <10s
|
||||
instead of the previous 35-85s hang.
|
||||
"""
|
||||
|
||||
error_code = "SITE_UNREACHABLE"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
install_hint: dict[str, Any] | None = None,
|
||||
reason: str = "site_unreachable",
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.install_hint = install_hint
|
||||
self.reason = reason
|
||||
|
||||
|
||||
# Transient HTTP status codes that are worth retrying
|
||||
_RETRYABLE_STATUS_CODES = {502, 503, 504, 429}
|
||||
|
||||
# Default request timeout in seconds
|
||||
# Default request timeout in seconds (wall clock).
|
||||
_REQUEST_TIMEOUT = 30
|
||||
# Connect timeout: how long to wait for the TCP handshake before giving
|
||||
# up. Five seconds is enough to rule out DNS/TCP failure on any real
|
||||
# site; beyond that the site is down. Short connect + long total lets us
|
||||
# fail fast on unreachable hosts while still allowing slow responses
|
||||
# from reachable ones to complete.
|
||||
_CONNECT_TIMEOUT = 5
|
||||
|
||||
# Retry configuration
|
||||
_MAX_RETRIES = 2
|
||||
@@ -126,7 +157,7 @@ class WordPressClient:
|
||||
"""
|
||||
# Build URL based on endpoint type
|
||||
if use_custom_namespace:
|
||||
# For custom namespaces like airano-mcp-seo-bridge/v1
|
||||
# For custom namespaces like airano-mcp-bridge/v1
|
||||
url = f"{self.site_url}/wp-json/{endpoint}"
|
||||
elif use_woocommerce:
|
||||
# For WooCommerce endpoints
|
||||
@@ -158,8 +189,10 @@ class WordPressClient:
|
||||
if json_data:
|
||||
json_data = {k: v for k, v in json_data.items() if should_include(v)}
|
||||
|
||||
# Make request with retry for transient errors
|
||||
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
|
||||
# Make request with retry for transient errors. connect=5
|
||||
# short-circuits TCP/DNS failures in <10s (2 retries × 5s each
|
||||
# worst case) instead of the previous 35-85s hang.
|
||||
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT, connect=_CONNECT_TIMEOUT)
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(_MAX_RETRIES + 1):
|
||||
@@ -209,11 +242,15 @@ class WordPressClient:
|
||||
raise # Never retry auth/config errors
|
||||
|
||||
except TimeoutError:
|
||||
last_exception = ConnectionError(
|
||||
f"Request timed out after {_REQUEST_TIMEOUT}s. "
|
||||
f"The site at {self.site_url} is not responding. "
|
||||
"Possible causes: site is overloaded, network is slow, "
|
||||
"or the server is down."
|
||||
last_exception = SiteUnreachableError(
|
||||
(
|
||||
f"Request timed out after {_REQUEST_TIMEOUT}s. "
|
||||
f"The site at {self.site_url} is not responding. "
|
||||
"Possible causes: site is overloaded, network is "
|
||||
"slow, or the server is down."
|
||||
),
|
||||
install_hint=self._site_unreachable_install_hint(),
|
||||
reason="site_timeout",
|
||||
)
|
||||
if attempt < _MAX_RETRIES:
|
||||
wait = _RETRY_BACKOFF_BASE * (2**attempt)
|
||||
@@ -225,40 +262,61 @@ class WordPressClient:
|
||||
continue
|
||||
|
||||
except aiohttp.ClientConnectorCertificateError as e:
|
||||
raise ConnectionError(
|
||||
f"SSL certificate error for {self.site_url}. "
|
||||
"The site's SSL certificate is invalid or expired. "
|
||||
f"Details: {e}"
|
||||
raise SiteUnreachableError(
|
||||
(
|
||||
f"SSL certificate error for {self.site_url}. "
|
||||
"The site's SSL certificate is invalid or expired. "
|
||||
f"Details: {e}"
|
||||
),
|
||||
install_hint=self._site_unreachable_install_hint(),
|
||||
reason="site_ssl_error",
|
||||
) from e
|
||||
|
||||
except aiohttp.ClientConnectorDNSError as e:
|
||||
host = self.site_url.split("://")[-1].split("/")[0]
|
||||
raise ConnectionError(
|
||||
f"DNS resolution failed for '{host}'. "
|
||||
"The domain name could not be found. "
|
||||
"Please check that the URL is correct."
|
||||
raise SiteUnreachableError(
|
||||
(
|
||||
f"DNS resolution failed for '{host}'. "
|
||||
"The domain name could not be found. "
|
||||
"Please check that the URL is correct."
|
||||
),
|
||||
install_hint=self._site_unreachable_install_hint(),
|
||||
reason="site_dns_error",
|
||||
) from e
|
||||
|
||||
except aiohttp.ClientConnectorError as e:
|
||||
os_error = getattr(e, "os_error", None)
|
||||
if isinstance(os_error, socket.gaierror):
|
||||
host = self.site_url.split("://")[-1].split("/")[0]
|
||||
raise ConnectionError(
|
||||
f"DNS resolution failed for '{host}'. "
|
||||
"The domain name could not be found. "
|
||||
"Please check that the URL is correct."
|
||||
raise SiteUnreachableError(
|
||||
(
|
||||
f"DNS resolution failed for '{host}'. "
|
||||
"The domain name could not be found. "
|
||||
"Please check that the URL is correct."
|
||||
),
|
||||
install_hint=self._site_unreachable_install_hint(),
|
||||
reason="site_dns_error",
|
||||
) from e
|
||||
|
||||
raise ConnectionError(
|
||||
f"Cannot connect to {self.site_url}. "
|
||||
"The server is unreachable. Possible causes: "
|
||||
"wrong URL, server is down, firewall blocking, or wrong port."
|
||||
raise SiteUnreachableError(
|
||||
(
|
||||
f"Cannot connect to {self.site_url}. "
|
||||
"The server is unreachable. Possible causes: "
|
||||
"wrong URL, server is down, firewall blocking, "
|
||||
"or wrong port."
|
||||
),
|
||||
install_hint=self._site_unreachable_install_hint(),
|
||||
reason="site_connection_refused",
|
||||
) from e
|
||||
|
||||
except aiohttp.InvalidURL:
|
||||
raise ConnectionError(
|
||||
f"Invalid URL: {self.site_url}. "
|
||||
"Please provide a valid URL starting with https:// or http://."
|
||||
raise SiteUnreachableError(
|
||||
(
|
||||
f"Invalid URL: {self.site_url}. "
|
||||
"Please provide a valid URL starting with "
|
||||
"https:// or http://."
|
||||
),
|
||||
reason="site_invalid_url",
|
||||
)
|
||||
|
||||
except (aiohttp.ClientError, OSError) as e:
|
||||
@@ -277,6 +335,25 @@ class WordPressClient:
|
||||
# All retries exhausted
|
||||
raise last_exception # type: ignore[misc]
|
||||
|
||||
@staticmethod
|
||||
def _site_unreachable_install_hint() -> dict[str, Any]:
|
||||
"""Structured install hint for SITE_UNREACHABLE errors.
|
||||
|
||||
Mirrors the shape produced by
|
||||
``plugins.wordpress.handlers._companion_hint.companion_install_hint``
|
||||
so dashboard code can render one uniform "fix your connection"
|
||||
prompt regardless of whether the error came from the low-level
|
||||
client or a companion-backed handler.
|
||||
"""
|
||||
from plugins.wordpress.handlers._companion_hint import (
|
||||
companion_install_hint,
|
||||
)
|
||||
|
||||
return companion_install_hint(
|
||||
min_version="2.9.0",
|
||||
required_capability="manage_options",
|
||||
)
|
||||
|
||||
def _parse_error_response(
|
||||
self, status_code: int, error_text: str, use_woocommerce: bool = False
|
||||
) -> dict[str, Any]:
|
||||
|
||||
@@ -7,14 +7,48 @@ Each handler is responsible for a specific domain of WordPress operations.
|
||||
Part of Option B clean architecture refactoring.
|
||||
"""
|
||||
|
||||
from plugins.wordpress.handlers.ai_media import AIMediaHandler
|
||||
from plugins.wordpress.handlers.ai_media import get_tool_specifications as get_ai_media_specs
|
||||
from plugins.wordpress.handlers.audit_hook import AuditHookHandler
|
||||
from plugins.wordpress.handlers.audit_hook import (
|
||||
get_tool_specifications as get_audit_hook_specs,
|
||||
)
|
||||
from plugins.wordpress.handlers.bulk_meta import BulkMetaHandler
|
||||
from plugins.wordpress.handlers.bulk_meta import get_tool_specifications as get_bulk_meta_specs
|
||||
from plugins.wordpress.handlers.cache_purge import CachePurgeHandler
|
||||
from plugins.wordpress.handlers.cache_purge import (
|
||||
get_tool_specifications as get_cache_purge_specs,
|
||||
)
|
||||
from plugins.wordpress.handlers.capabilities import CapabilitiesHandler
|
||||
from plugins.wordpress.handlers.capabilities import (
|
||||
get_tool_specifications as get_capabilities_specs,
|
||||
)
|
||||
from plugins.wordpress.handlers.comments import CommentsHandler
|
||||
from plugins.wordpress.handlers.comments import get_tool_specifications as get_comments_specs
|
||||
from plugins.wordpress.handlers.coupons import CouponsHandler
|
||||
from plugins.wordpress.handlers.coupons import get_tool_specifications as get_coupons_specs
|
||||
from plugins.wordpress.handlers.customers import CustomersHandler
|
||||
from plugins.wordpress.handlers.customers import get_tool_specifications as get_customers_specs
|
||||
from plugins.wordpress.handlers.export import ExportHandler
|
||||
from plugins.wordpress.handlers.export import get_tool_specifications as get_export_specs
|
||||
from plugins.wordpress.handlers.media import MediaHandler
|
||||
from plugins.wordpress.handlers.media import get_tool_specifications as get_media_specs
|
||||
from plugins.wordpress.handlers.media_attach import MediaAttachHandler
|
||||
from plugins.wordpress.handlers.media_attach import (
|
||||
get_tool_specifications as get_media_attach_specs,
|
||||
)
|
||||
from plugins.wordpress.handlers.media_bulk import MediaBulkHandler
|
||||
from plugins.wordpress.handlers.media_bulk import (
|
||||
get_tool_specifications as get_media_bulk_specs,
|
||||
)
|
||||
from plugins.wordpress.handlers.media_chunked import MediaChunkedHandler
|
||||
from plugins.wordpress.handlers.media_chunked import (
|
||||
get_tool_specifications as get_media_chunked_specs,
|
||||
)
|
||||
from plugins.wordpress.handlers.media_probe import ProbeHandler
|
||||
from plugins.wordpress.handlers.media_probe import (
|
||||
get_tool_specifications as get_media_probe_specs,
|
||||
)
|
||||
from plugins.wordpress.handlers.menus import MenusHandler
|
||||
from plugins.wordpress.handlers.menus import get_tool_specifications as get_menus_specs
|
||||
from plugins.wordpress.handlers.orders import OrdersHandler
|
||||
@@ -23,14 +57,26 @@ from plugins.wordpress.handlers.posts import PostsHandler
|
||||
from plugins.wordpress.handlers.posts import get_tool_specifications as get_posts_specs
|
||||
from plugins.wordpress.handlers.products import ProductsHandler
|
||||
from plugins.wordpress.handlers.products import get_tool_specifications as get_products_specs
|
||||
from plugins.wordpress.handlers.regenerate_thumbnails import RegenerateThumbnailsHandler
|
||||
from plugins.wordpress.handlers.regenerate_thumbnails import (
|
||||
get_tool_specifications as get_regenerate_thumbnails_specs,
|
||||
)
|
||||
from plugins.wordpress.handlers.reports import ReportsHandler
|
||||
from plugins.wordpress.handlers.reports import get_tool_specifications as get_reports_specs
|
||||
from plugins.wordpress.handlers.seo import SEOHandler
|
||||
from plugins.wordpress.handlers.seo import get_tool_specifications as get_seo_specs
|
||||
from plugins.wordpress.handlers.site import SiteHandler
|
||||
from plugins.wordpress.handlers.site import get_tool_specifications as get_site_specs
|
||||
from plugins.wordpress.handlers.site_health import SiteHealthHandler
|
||||
from plugins.wordpress.handlers.site_health import (
|
||||
get_tool_specifications as get_site_health_specs,
|
||||
)
|
||||
from plugins.wordpress.handlers.taxonomy import TaxonomyHandler
|
||||
from plugins.wordpress.handlers.taxonomy import get_tool_specifications as get_taxonomy_specs
|
||||
from plugins.wordpress.handlers.transient_flush import TransientFlushHandler
|
||||
from plugins.wordpress.handlers.transient_flush import (
|
||||
get_tool_specifications as get_transient_flush_specs,
|
||||
)
|
||||
from plugins.wordpress.handlers.users import UsersHandler
|
||||
from plugins.wordpress.handlers.users import get_tool_specifications as get_users_specs
|
||||
from plugins.wordpress.handlers.wp_cli import WPCLIHandler
|
||||
@@ -40,6 +86,19 @@ __all__ = [
|
||||
# Core Handlers
|
||||
"PostsHandler",
|
||||
"MediaHandler",
|
||||
"MediaAttachHandler",
|
||||
"MediaBulkHandler",
|
||||
"MediaChunkedHandler",
|
||||
"ProbeHandler",
|
||||
"AIMediaHandler",
|
||||
"AuditHookHandler",
|
||||
"BulkMetaHandler",
|
||||
"CachePurgeHandler",
|
||||
"CapabilitiesHandler",
|
||||
"RegenerateThumbnailsHandler",
|
||||
"ExportHandler",
|
||||
"SiteHealthHandler",
|
||||
"TransientFlushHandler",
|
||||
"TaxonomyHandler",
|
||||
"CommentsHandler",
|
||||
"UsersHandler",
|
||||
@@ -57,6 +116,19 @@ __all__ = [
|
||||
# Tool specifications
|
||||
"get_posts_specs",
|
||||
"get_media_specs",
|
||||
"get_media_attach_specs",
|
||||
"get_media_bulk_specs",
|
||||
"get_media_chunked_specs",
|
||||
"get_media_probe_specs",
|
||||
"get_ai_media_specs",
|
||||
"get_audit_hook_specs",
|
||||
"get_bulk_meta_specs",
|
||||
"get_cache_purge_specs",
|
||||
"get_capabilities_specs",
|
||||
"get_regenerate_thumbnails_specs",
|
||||
"get_export_specs",
|
||||
"get_site_health_specs",
|
||||
"get_transient_flush_specs",
|
||||
"get_taxonomy_specs",
|
||||
"get_comments_specs",
|
||||
"get_users_specs",
|
||||
|
||||
65
plugins/wordpress/handlers/_companion_hint.py
Normal file
65
plugins/wordpress/handlers/_companion_hint.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Shared ``companion_unreachable`` error-hint helper.
|
||||
|
||||
Every companion-backed handler (cache_purge, bulk_meta, export,
|
||||
site_health, transient_flush, audit_hook, regenerate_thumbnails,
|
||||
capabilities) returns a structured JSON error when the companion
|
||||
plugin isn't installed / reachable. This module provides a single
|
||||
helper so the hint message, download URL, and install instructions
|
||||
stay in sync.
|
||||
|
||||
F.20 will swap the download URL constant from the GitHub raw path to
|
||||
``wordpress.org/plugins/airano-mcp-bridge/`` once the wp.org listing
|
||||
is live. At that point a single edit here + the dashboard template
|
||||
(``core/templates/dashboard/sites/manage.html``) covers everything.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Single source of truth for the companion download URL. Mirror of the
|
||||
# constant used in ``core/dashboard/routes.py``. Kept as a plain string
|
||||
# rather than imported to avoid a core→plugin import cycle.
|
||||
COMPANION_DOWNLOAD_URL = (
|
||||
"https://github.com/airano-ir/mcphub/raw/main/" "wordpress-plugin/airano-mcp-bridge.zip"
|
||||
)
|
||||
|
||||
|
||||
def companion_install_hint(
|
||||
*,
|
||||
min_version: str,
|
||||
required_capability: str = "manage_options",
|
||||
route: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Return a ``hint`` dict that every companion-backed handler can
|
||||
merge into its ``companion_unreachable`` payload.
|
||||
|
||||
Args:
|
||||
min_version: Earliest companion plugin version that exposes the
|
||||
route this handler uses (e.g. ``"2.4.0"``). Surfaced in the
|
||||
install hint so the user knows what they need.
|
||||
required_capability: WordPress capability the calling user needs
|
||||
for the companion route. Default ``manage_options``.
|
||||
route: Optional route path — included in the hint when set so
|
||||
the user can sanity-check by hitting the endpoint directly.
|
||||
|
||||
Returns:
|
||||
Dict with ``install_url``, ``install_instructions``, and
|
||||
``required_capability``. Callers merge into their existing
|
||||
structured error response alongside ``error`` / ``message``.
|
||||
"""
|
||||
instructions = (
|
||||
f"Install the Airano MCP Bridge companion plugin (v{min_version}+) "
|
||||
"on the WordPress site. Download the zip, then in the WP admin: "
|
||||
"Plugins → Add New → Upload Plugin → select the file → Activate. "
|
||||
f"Ensure the Application Password user has the ``{required_capability}`` "
|
||||
"capability. Run ``wordpress_probe_capabilities`` to verify the "
|
||||
"route is advertised."
|
||||
)
|
||||
out = {
|
||||
"install_url": COMPANION_DOWNLOAD_URL,
|
||||
"install_instructions": instructions,
|
||||
"required_capability": required_capability,
|
||||
"companion_min_version": min_version,
|
||||
}
|
||||
if route:
|
||||
out["route"] = route
|
||||
return out
|
||||
407
plugins/wordpress/handlers/_media_core.py
Normal file
407
plugins/wordpress/handlers/_media_core.py
Normal file
@@ -0,0 +1,407 @@
|
||||
"""Shared raw-binary upload primitive for WordPress media library.
|
||||
|
||||
WP REST `/wp/v2/media` expects the raw binary file body with a `Content-Disposition`
|
||||
header (NOT multipart/form-data). Metadata fields like alt_text/caption/title must
|
||||
be set via a follow-up `POST /media/{id}` JSON call.
|
||||
|
||||
F.5a.7: when the airano-mcp companion plugin is present AND the file size
|
||||
exceeds the site's advertised ``upload_max_filesize``, prefer the companion
|
||||
``POST /airano-mcp/v1/upload-chunk`` route which reads the body via
|
||||
``php://input`` (bypasses ``upload_max_filesize``). On any failure from the
|
||||
companion route we fall back to the standard ``/wp/v2/media`` path so we
|
||||
never regress the default upload behaviour.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress.handlers._media_security import (
|
||||
ALLOWED_MIMES,
|
||||
DEFAULT_MAX_BYTES,
|
||||
UploadError,
|
||||
content_disposition,
|
||||
safe_filename,
|
||||
sniff_mime,
|
||||
validate_mime,
|
||||
validate_size,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger("mcphub.wordpress.media")
|
||||
|
||||
_UPLOAD_TIMEOUT = 120 # seconds
|
||||
|
||||
# Endpoint suffix (under /wp-json/) for the companion upload-chunk route.
|
||||
_COMPANION_UPLOAD_ENDPOINT = "airano-mcp/v1/upload-chunk"
|
||||
# F.5a.8.5: single-call upload + attach + featured via companion v2.9.0+.
|
||||
_COMPANION_UPLOAD_AND_ATTACH_ENDPOINT = "airano-mcp/v1/upload-and-attach"
|
||||
|
||||
|
||||
async def _should_use_companion(client: WordPressClient, size: int) -> bool:
|
||||
"""F.5a.7: decide whether to prefer the companion upload-chunk route.
|
||||
|
||||
True when the cached probe result marks the companion helper as available
|
||||
AND the payload size exceeds the site's advertised ``upload_max_filesize``
|
||||
(falling back to the effective ceiling when that specific key is unset).
|
||||
|
||||
We only consult the *cached* probe — never trigger a fresh probe from the
|
||||
upload path — so a cold cache simply degrades to the standard route.
|
||||
"""
|
||||
try:
|
||||
from plugins.wordpress.handlers.media_probe import get_cached_limits
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return False
|
||||
|
||||
try:
|
||||
cached = await get_cached_limits(client)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_logger.debug("Companion probe lookup failed: %s", exc)
|
||||
return False
|
||||
if not cached or not cached.get("companion_available"):
|
||||
return False
|
||||
|
||||
limits_bytes = cached.get("limits_bytes") or {}
|
||||
ceiling = (
|
||||
limits_bytes.get("upload_max_filesize")
|
||||
or limits_bytes.get("wp_max_upload_size")
|
||||
or limits_bytes.get("effective_ceiling")
|
||||
)
|
||||
if ceiling is None or ceiling <= 0:
|
||||
return False
|
||||
return size > ceiling
|
||||
|
||||
|
||||
async def _companion_has_upload_and_attach(client: WordPressClient) -> bool:
|
||||
"""F.5a.8.5: does the cached capability probe advertise the
|
||||
``upload_and_attach`` route?
|
||||
|
||||
We only check the cached result — never force a fresh probe from
|
||||
the upload path. If the cache is cold, we conservatively return
|
||||
False and the caller falls back to the 3-step path; the probe
|
||||
will warm up on the next dashboard visit.
|
||||
"""
|
||||
try:
|
||||
from plugins.wordpress.handlers.capabilities import get_cached_capabilities
|
||||
except Exception: # pragma: no cover
|
||||
return False
|
||||
try:
|
||||
cached = await get_cached_capabilities(client)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_logger.debug("Companion capability cache lookup failed: %s", exc)
|
||||
return False
|
||||
if not cached or not cached.get("companion_available"):
|
||||
return False
|
||||
routes = cached.get("routes") or {}
|
||||
return bool(routes.get("upload_and_attach"))
|
||||
|
||||
|
||||
async def _companion_upload_and_attach(
|
||||
client: WordPressClient,
|
||||
data: bytes,
|
||||
*,
|
||||
sniffed: str,
|
||||
disposition: str,
|
||||
attach_to_post: int | None,
|
||||
set_featured: bool,
|
||||
title: str | None,
|
||||
alt_text: str | None,
|
||||
caption: str | None,
|
||||
description: str | None,
|
||||
idempotency_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Upload + metadata + attach + featured in a single companion call.
|
||||
|
||||
Raises ``UploadError`` on any non-2xx response so the caller can
|
||||
fall back to ``wp/v2/media`` + separate metadata calls.
|
||||
"""
|
||||
params: dict[str, str] = {}
|
||||
if attach_to_post and attach_to_post > 0:
|
||||
params["attach_to_post"] = str(int(attach_to_post))
|
||||
if set_featured and attach_to_post and attach_to_post > 0:
|
||||
params["set_featured"] = "true"
|
||||
if title:
|
||||
params["title"] = str(title)
|
||||
if alt_text:
|
||||
params["alt_text"] = str(alt_text)
|
||||
if caption:
|
||||
params["caption"] = str(caption)
|
||||
if description:
|
||||
params["description"] = str(description)
|
||||
|
||||
url = f"{client.site_url}/wp-json/{_COMPANION_UPLOAD_AND_ATTACH_ENDPOINT}"
|
||||
headers = {
|
||||
"Authorization": client.auth_header,
|
||||
"Content-Type": sniffed,
|
||||
"Content-Disposition": disposition,
|
||||
}
|
||||
# F.X.fix #7: pass the caller's idempotency key through to the
|
||||
# companion so a retry after client timeout returns the original
|
||||
# attachment instead of creating an "-2.webp" orphan.
|
||||
if idempotency_key:
|
||||
headers["Idempotency-Key"] = str(idempotency_key)
|
||||
timeout = aiohttp.ClientTimeout(total=_UPLOAD_TIMEOUT)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(url, data=data, headers=headers, params=params) as response:
|
||||
text = await response.text()
|
||||
if response.status >= 400:
|
||||
raise UploadError(
|
||||
f"COMPANION_{response.status}",
|
||||
f"Companion upload-and-attach failed: HTTP {response.status}",
|
||||
{"status": response.status, "body": text[:500]},
|
||||
)
|
||||
try:
|
||||
return await response.json(content_type=None)
|
||||
except Exception as e:
|
||||
raise UploadError(
|
||||
"COMPANION_BAD_RESPONSE",
|
||||
f"Companion upload-and-attach returned non-JSON response: {e}",
|
||||
{"body": text[:500]},
|
||||
) from e
|
||||
|
||||
|
||||
async def _companion_raw_upload(
|
||||
client: WordPressClient,
|
||||
data: bytes,
|
||||
*,
|
||||
sniffed: str,
|
||||
disposition: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Upload via the companion ``POST /airano-mcp/v1/upload-chunk`` route.
|
||||
|
||||
Raises ``UploadError`` on any non-2xx response so the caller can fall
|
||||
back to the standard /wp/v2/media path.
|
||||
"""
|
||||
url = f"{client.site_url}/wp-json/{_COMPANION_UPLOAD_ENDPOINT}"
|
||||
headers = {
|
||||
"Authorization": client.auth_header,
|
||||
"Content-Type": sniffed,
|
||||
"Content-Disposition": disposition,
|
||||
}
|
||||
timeout = aiohttp.ClientTimeout(total=_UPLOAD_TIMEOUT)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(url, data=data, headers=headers) as response:
|
||||
text = await response.text()
|
||||
if response.status >= 400:
|
||||
raise UploadError(
|
||||
f"COMPANION_{response.status}",
|
||||
f"Companion upload-chunk failed: HTTP {response.status}",
|
||||
{"status": response.status, "body": text[:500]},
|
||||
)
|
||||
try:
|
||||
return await response.json(content_type=None)
|
||||
except Exception as e:
|
||||
raise UploadError(
|
||||
"COMPANION_BAD_RESPONSE",
|
||||
f"Companion upload returned non-JSON response: {e}",
|
||||
{"body": text[:500]},
|
||||
) from e
|
||||
|
||||
|
||||
async def wp_raw_upload(
|
||||
client: WordPressClient,
|
||||
data: bytes,
|
||||
*,
|
||||
filename: str | None,
|
||||
mime_hint: str | None = None,
|
||||
max_bytes: int = DEFAULT_MAX_BYTES,
|
||||
allowed_mimes: set[str] = ALLOWED_MIMES,
|
||||
# F.5a.8.5: optional attach + featured + metadata params. When any of
|
||||
# these is provided AND the companion's upload-and-attach route is
|
||||
# advertised in the cached capability probe, we POST to the single-
|
||||
# call endpoint instead of doing the 3-step REST dance. Returned
|
||||
# dict is marked ``_upload_route="companion_unified"`` so the caller
|
||||
# (``_apply_metadata_and_attach``) can skip the separate metadata /
|
||||
# featured-image calls.
|
||||
attach_to_post: int | None = None,
|
||||
set_featured: bool = False,
|
||||
title: str | None = None,
|
||||
alt_text: str | None = None,
|
||||
caption: str | None = None,
|
||||
description: str | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Upload raw bytes to WP media library. Returns the attachment dict from WP.
|
||||
|
||||
When the companion plugin advertises limits smaller than the payload, we
|
||||
POST to ``/airano-mcp/v1/upload-chunk`` first and only fall back to
|
||||
``/wp/v2/media`` if the companion route errors.
|
||||
|
||||
F.5a.8.5: when ``attach_to_post`` or any metadata field is set AND the
|
||||
companion advertises the ``upload_and_attach`` route, we prefer the
|
||||
single-call path which bundles upload + metadata + attach + featured
|
||||
into one PHP request.
|
||||
"""
|
||||
validate_size(data, max_bytes=max_bytes)
|
||||
sniffed = sniff_mime(data, hint=mime_hint)
|
||||
validate_mime(sniffed, allowed=allowed_mimes)
|
||||
|
||||
ascii_name, encoded = safe_filename(filename, mime=sniffed)
|
||||
disposition = content_disposition(ascii_name, encoded)
|
||||
|
||||
# F.5a.8.5 unified route: tried first when the caller wants metadata
|
||||
# applied AND the companion supports it. Falls back to the legacy
|
||||
# path on any error (companion 4xx/5xx, route not advertised, etc.).
|
||||
has_metadata_intent = (
|
||||
any(v is not None for v in (attach_to_post, title, alt_text, caption, description))
|
||||
or set_featured
|
||||
)
|
||||
if has_metadata_intent and await _companion_has_upload_and_attach(client):
|
||||
try:
|
||||
result = await _companion_upload_and_attach(
|
||||
client,
|
||||
data,
|
||||
sniffed=sniffed,
|
||||
disposition=disposition,
|
||||
attach_to_post=attach_to_post,
|
||||
set_featured=set_featured,
|
||||
title=title,
|
||||
alt_text=alt_text,
|
||||
caption=caption,
|
||||
description=description,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
result["_upload_route"] = "companion_unified"
|
||||
return result
|
||||
except UploadError as exc:
|
||||
_logger.warning(
|
||||
"Companion upload-and-attach failed (%s); falling back to "
|
||||
"/upload-chunk + separate metadata calls",
|
||||
exc.code,
|
||||
)
|
||||
|
||||
# F.5a.7 route selection (non-fatal; falls back to /wp/v2/media on error).
|
||||
if await _should_use_companion(client, len(data)):
|
||||
try:
|
||||
result = await _companion_raw_upload(
|
||||
client,
|
||||
data,
|
||||
sniffed=sniffed,
|
||||
disposition=disposition,
|
||||
)
|
||||
result["_upload_route"] = "companion"
|
||||
return result
|
||||
except UploadError as exc:
|
||||
_logger.warning(
|
||||
"Companion upload-chunk failed (%s); falling back to /wp/v2/media",
|
||||
exc.code,
|
||||
)
|
||||
|
||||
url = f"{client.api_base}/media"
|
||||
headers = {
|
||||
"Authorization": client.auth_header,
|
||||
"Content-Type": sniffed,
|
||||
"Content-Disposition": disposition,
|
||||
}
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=_UPLOAD_TIMEOUT)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(url, data=data, headers=headers) as response:
|
||||
text = await response.text()
|
||||
if response.status == 413:
|
||||
raise UploadError(
|
||||
"WP_413",
|
||||
"WordPress rejected upload (413 Payload Too Large). "
|
||||
"Site's upload_max_filesize / post_max_size is below the file size.",
|
||||
{"status": 413, "body": text[:500]},
|
||||
)
|
||||
if response.status in (401, 403):
|
||||
raise UploadError(
|
||||
"WP_AUTH",
|
||||
f"WordPress rejected auth ({response.status}). Verify Application Password.",
|
||||
{"status": response.status, "body": text[:500]},
|
||||
)
|
||||
if response.status >= 400:
|
||||
raise UploadError(
|
||||
f"WP_{response.status}",
|
||||
f"WordPress upload failed: HTTP {response.status}",
|
||||
{"status": response.status, "body": text[:500]},
|
||||
)
|
||||
try:
|
||||
result = await response.json(content_type=None)
|
||||
if isinstance(result, dict):
|
||||
result["_upload_route"] = "rest"
|
||||
return result
|
||||
except Exception as e:
|
||||
raise UploadError(
|
||||
"WP_BAD_RESPONSE",
|
||||
f"WP upload returned non-JSON response: {e}",
|
||||
{"body": text[:500]},
|
||||
) from e
|
||||
|
||||
|
||||
async def wp_update_media_metadata(
|
||||
client: WordPressClient,
|
||||
media_id: int,
|
||||
*,
|
||||
title: str | None = None,
|
||||
alt_text: str | None = None,
|
||||
caption: str | None = None,
|
||||
description: str | None = None,
|
||||
post: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply metadata fields via POST /media/{id}. Only sends non-None fields."""
|
||||
payload: dict[str, Any] = {}
|
||||
if title is not None:
|
||||
payload["title"] = title
|
||||
if alt_text is not None:
|
||||
payload["alt_text"] = alt_text
|
||||
if caption is not None:
|
||||
payload["caption"] = caption
|
||||
if description is not None:
|
||||
payload["description"] = description
|
||||
if post is not None:
|
||||
payload["post"] = post
|
||||
if not payload:
|
||||
return {}
|
||||
return await client.post(f"media/{media_id}", json_data=payload)
|
||||
|
||||
|
||||
async def wp_set_featured_media(
|
||||
client: WordPressClient, post_id: int, media_id: int
|
||||
) -> dict[str, Any]:
|
||||
"""Set a post's featured image."""
|
||||
return await client.post(f"posts/{post_id}", json_data={"featured_media": media_id})
|
||||
|
||||
|
||||
async def fetch_url_bytes(
|
||||
url: str,
|
||||
*,
|
||||
max_bytes: int = DEFAULT_MAX_BYTES,
|
||||
timeout_sec: int = 60,
|
||||
user_agent: str = "MCPHub-MediaUploader/1.0",
|
||||
resolved_ip: str | None = None,
|
||||
) -> tuple[bytes, str | None, str]:
|
||||
"""Download up to `max_bytes` from URL. Streams and enforces the limit.
|
||||
|
||||
Returns (data, content_type, filename_guess).
|
||||
"""
|
||||
filename_guess = url.rsplit("/", 1)[-1].split("?")[0] or "download"
|
||||
headers = {"User-Agent": user_agent}
|
||||
timeout = aiohttp.ClientTimeout(total=timeout_sec)
|
||||
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url, headers=headers, allow_redirects=True) as resp:
|
||||
if resp.status >= 400:
|
||||
raise UploadError(
|
||||
"URL_FETCH_FAILED",
|
||||
f"Download failed: HTTP {resp.status}",
|
||||
{"status": resp.status, "url": url},
|
||||
)
|
||||
declared_ct = resp.headers.get("Content-Type")
|
||||
# Stream read with byte cap
|
||||
buf = bytearray()
|
||||
async for chunk in resp.content.iter_chunked(64 * 1024):
|
||||
buf.extend(chunk)
|
||||
if len(buf) > max_bytes:
|
||||
raise UploadError(
|
||||
"TOO_LARGE",
|
||||
f"Remote file exceeds limit of {max_bytes} bytes while streaming.",
|
||||
{"max": max_bytes, "url": url},
|
||||
)
|
||||
_ = asyncio # keep import for callers if needed
|
||||
return bytes(buf), declared_ct, filename_guess
|
||||
196
plugins/wordpress/handlers/_media_optimize.py
Normal file
196
plugins/wordpress/handlers/_media_optimize.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""F.5a.2 / F.5a.8.1: Image optimization pipeline using Pillow.
|
||||
|
||||
Defaults for web publishing: JPEG quality 85, PNG→JPEG threshold via opacity check,
|
||||
max long edge 2560 px, EXIF strip, animated GIF preserved untouched.
|
||||
|
||||
F.5a.8.1 adds an optional format conversion stage: when ``convert_to`` is
|
||||
``"webp"`` or ``"avif"`` (or the env var ``WP_MEDIA_CONVERT_TO`` is set),
|
||||
raster inputs are re-encoded in that modern format regardless of source
|
||||
type. Transparency is preserved; animated GIFs are still left untouched.
|
||||
|
||||
Returns the (possibly reduced) bytes and the (possibly updated) MIME type.
|
||||
Non-raster types (pdf, svg, video, audio) pass through unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
|
||||
_logger = logging.getLogger("mcphub.wordpress.media.optimize")
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageOps # type: ignore
|
||||
except Exception: # pragma: no cover
|
||||
Image = None # type: ignore
|
||||
ImageOps = None # type: ignore
|
||||
|
||||
_DEFAULT_MAX_EDGE = int(os.environ.get("WP_MEDIA_MAX_EDGE", "2560"))
|
||||
_DEFAULT_JPEG_QUALITY = int(os.environ.get("WP_MEDIA_JPEG_QUALITY", "85"))
|
||||
# F.5a.8.1: optional output format override.
|
||||
# "" → no conversion (current default, keeps source format)
|
||||
# "webp" → convert raster images to image/webp
|
||||
# "avif" → convert raster images to image/avif (requires Pillow≥9.2 with AVIF)
|
||||
_DEFAULT_CONVERT_TO = os.environ.get("WP_MEDIA_CONVERT_TO", "").strip().lower()
|
||||
|
||||
_RASTER_MIMES = {"image/jpeg", "image/png", "image/webp", "image/bmp", "image/tiff"}
|
||||
|
||||
# Format map for the convert_to override. Values are (Pillow format, output MIME).
|
||||
_CONVERT_TO_FORMATS: dict[str, tuple[str, str]] = {
|
||||
"webp": ("WEBP", "image/webp"),
|
||||
"avif": ("AVIF", "image/avif"),
|
||||
}
|
||||
|
||||
|
||||
def _avif_supported() -> bool:
|
||||
"""Return True if the running Pillow actually decodes/encodes AVIF.
|
||||
|
||||
We check at call time rather than import time so environments that
|
||||
upgrade Pillow without restarting still see support as it becomes
|
||||
available.
|
||||
"""
|
||||
if Image is None:
|
||||
return False
|
||||
# Pillow registers the extension lazily; probing for a writer is more
|
||||
# reliable than checking the features module (which doesn't list AVIF
|
||||
# on every version).
|
||||
try:
|
||||
return "AVIF" in Image.registered_extensions().values()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return False
|
||||
|
||||
|
||||
def optimize(
|
||||
data: bytes,
|
||||
mime_hint: str | None,
|
||||
*,
|
||||
max_edge: int = _DEFAULT_MAX_EDGE,
|
||||
jpeg_quality: int = _DEFAULT_JPEG_QUALITY,
|
||||
strip_exif: bool = True,
|
||||
convert_to: str | None = None,
|
||||
) -> tuple[bytes, str | None]:
|
||||
"""Resize/recompress raster images.
|
||||
|
||||
Args:
|
||||
data: raw image bytes.
|
||||
mime_hint: best-guess MIME from sniff/client; non-raster types are
|
||||
passed through unchanged.
|
||||
max_edge: long-edge pixel limit (default ``WP_MEDIA_MAX_EDGE`` or 2560).
|
||||
jpeg_quality: q parameter for JPEG / WebP / AVIF encoders.
|
||||
strip_exif: when True, apply ``ImageOps.exif_transpose`` to bake in
|
||||
rotation then drop the metadata.
|
||||
convert_to: force output format regardless of source. ``"webp"`` or
|
||||
``"avif"``; ``None`` or ``""`` falls back to
|
||||
``WP_MEDIA_CONVERT_TO`` then to source-format heuristics. When
|
||||
AVIF is requested but Pillow lacks AVIF support, falls back to
|
||||
WebP rather than silently writing the source bytes.
|
||||
|
||||
Returns:
|
||||
``(new_bytes, new_mime_or_original)``. If optimization produced
|
||||
larger output without resizing, the original bytes are returned.
|
||||
"""
|
||||
if Image is None:
|
||||
return data, mime_hint
|
||||
|
||||
if mime_hint and mime_hint not in _RASTER_MIMES:
|
||||
return data, mime_hint
|
||||
|
||||
try:
|
||||
img = Image.open(io.BytesIO(data))
|
||||
except Exception:
|
||||
return data, mime_hint
|
||||
|
||||
# Preserve animated GIFs untouched (single-frame re-encode would break them)
|
||||
if getattr(img, "is_animated", False):
|
||||
return data, mime_hint
|
||||
|
||||
fmt = (img.format or "").upper() # capture BEFORE exif_transpose strips .format
|
||||
|
||||
try:
|
||||
img = ImageOps.exif_transpose(img) if strip_exif else img
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
w, h = img.size
|
||||
long_edge = max(w, h)
|
||||
|
||||
resized = False
|
||||
if long_edge > max_edge:
|
||||
scale = max_edge / long_edge
|
||||
new_size = (max(1, int(w * scale)), max(1, int(h * scale)))
|
||||
img = img.resize(new_size, Image.LANCZOS)
|
||||
resized = True
|
||||
|
||||
# F.5a.8.1: explicit convert_to wins over the env default.
|
||||
requested_convert = (convert_to or _DEFAULT_CONVERT_TO or "").strip().lower()
|
||||
if requested_convert == "avif" and not _avif_supported():
|
||||
_logger.info("AVIF requested but Pillow lacks AVIF support; falling back to WebP.")
|
||||
requested_convert = "webp"
|
||||
|
||||
has_alpha = img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info)
|
||||
|
||||
if requested_convert in _CONVERT_TO_FORMATS:
|
||||
out_fmt, out_mime = _CONVERT_TO_FORMATS[requested_convert]
|
||||
# Both WebP and AVIF support alpha; convert palette images to RGBA/RGB
|
||||
# so the encoder has a consistent colour model.
|
||||
if img.mode == "P":
|
||||
img = img.convert("RGBA" if has_alpha else "RGB")
|
||||
elif img.mode == "LA":
|
||||
img = img.convert("RGBA")
|
||||
elif img.mode == "L":
|
||||
img = img.convert("RGB")
|
||||
elif fmt == "PNG" and not has_alpha:
|
||||
# Opaque PNG → JPEG is typically smaller
|
||||
out_fmt = "JPEG"
|
||||
out_mime = "image/jpeg"
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
elif fmt == "JPEG":
|
||||
out_fmt = "JPEG"
|
||||
out_mime = "image/jpeg"
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
elif fmt == "WEBP":
|
||||
out_fmt = "WEBP"
|
||||
out_mime = "image/webp"
|
||||
elif fmt == "PNG":
|
||||
out_fmt = "PNG"
|
||||
out_mime = "image/png"
|
||||
else:
|
||||
# BMP/TIFF etc — convert to JPEG for web delivery
|
||||
out_fmt = "JPEG"
|
||||
out_mime = "image/jpeg"
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
|
||||
buf = io.BytesIO()
|
||||
save_kwargs: dict = {"optimize": True}
|
||||
if out_fmt == "JPEG":
|
||||
save_kwargs.update({"quality": jpeg_quality, "progressive": True})
|
||||
elif out_fmt == "WEBP":
|
||||
save_kwargs.update({"quality": jpeg_quality, "method": 6})
|
||||
elif out_fmt == "AVIF":
|
||||
save_kwargs.update({"quality": jpeg_quality})
|
||||
img.save(buf, format=out_fmt, **save_kwargs)
|
||||
new_bytes = buf.getvalue()
|
||||
|
||||
# Explicit format conversion is honoured even if the converted bytes
|
||||
# are larger than the source (the caller asked for a format switch on
|
||||
# purpose — e.g. to serve WebP regardless). The size guard only applies
|
||||
# to the implicit recompression path.
|
||||
if not requested_convert and not resized and len(new_bytes) >= len(data):
|
||||
return data, mime_hint
|
||||
|
||||
_logger.debug(
|
||||
"optimized %s %dx%d %dB -> %s %dB (resized=%s, convert=%s)",
|
||||
fmt,
|
||||
w,
|
||||
h,
|
||||
len(data),
|
||||
out_fmt,
|
||||
len(new_bytes),
|
||||
resized,
|
||||
requested_convert or "-",
|
||||
)
|
||||
return new_bytes, out_mime
|
||||
240
plugins/wordpress/handlers/_media_security.py
Normal file
240
plugins/wordpress/handlers/_media_security.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""Media upload security primitives: MIME sniff, size validation, SSRF guard, filename safety."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
import magic as _magic # type: ignore
|
||||
except Exception:
|
||||
_magic = None
|
||||
|
||||
|
||||
class UploadError(Exception):
|
||||
"""Structured upload error with stable code for JSON responses."""
|
||||
|
||||
def __init__(self, code: str, message: str, details: dict | None = None):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.details = details or {}
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"error_code": self.code, "message": self.message, "details": self.details}
|
||||
|
||||
|
||||
DEFAULT_MAX_BYTES = int(os.environ.get("WP_MEDIA_MAX_MB", "10")) * 1024 * 1024
|
||||
|
||||
ALLOWED_MIMES: set[str] = {
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
"image/avif",
|
||||
"image/heic",
|
||||
"image/bmp",
|
||||
"image/tiff",
|
||||
"application/pdf",
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/quicktime",
|
||||
"audio/mpeg",
|
||||
"audio/mp4",
|
||||
"audio/webm",
|
||||
"audio/ogg",
|
||||
"audio/wav",
|
||||
}
|
||||
|
||||
_EXT_MAP = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
"image/avif": ".avif",
|
||||
"image/heic": ".heic",
|
||||
"application/pdf": ".pdf",
|
||||
"video/mp4": ".mp4",
|
||||
"video/webm": ".webm",
|
||||
"video/quicktime": ".mov",
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/mp4": ".m4a",
|
||||
"audio/ogg": ".ogg",
|
||||
"audio/wav": ".wav",
|
||||
}
|
||||
|
||||
|
||||
def _builtin_sniff(data: bytes) -> str | None:
|
||||
"""Minimal magic-byte sniffer for common media types (libmagic-free fallback)."""
|
||||
if not data:
|
||||
return None
|
||||
if data.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return "image/png"
|
||||
if data.startswith(b"\xff\xd8\xff"):
|
||||
return "image/jpeg"
|
||||
if data[:4] == b"GIF8":
|
||||
return "image/gif"
|
||||
if data.startswith(b"%PDF-"):
|
||||
return "application/pdf"
|
||||
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
||||
return "image/webp"
|
||||
if data[:4] == b"RIFF" and data[8:12] == b"WAVE":
|
||||
return "audio/wav"
|
||||
if data[4:12] in (b"ftypmp42", b"ftypisom", b"ftypMSNV", b"ftypavc1"):
|
||||
return "video/mp4"
|
||||
if data[4:8] == b"ftyp":
|
||||
# Generic ISO BMFF — likely mp4/heic
|
||||
brand = data[8:12]
|
||||
if brand in (b"heic", b"heix", b"mif1"):
|
||||
return "image/heic"
|
||||
return "video/mp4"
|
||||
if data.startswith(b"\x1aE\xdf\xa3"):
|
||||
return "video/webm"
|
||||
if data.startswith(b"ID3") or data[:2] == b"\xff\xfb":
|
||||
return "audio/mpeg"
|
||||
if data.startswith(b"OggS"):
|
||||
return "audio/ogg"
|
||||
return None
|
||||
|
||||
|
||||
def sniff_mime(data: bytes, *, hint: str | None = None) -> str:
|
||||
"""Detect MIME from magic bytes. Uses libmagic if available, else built-in sniff."""
|
||||
if _magic is not None:
|
||||
try:
|
||||
detected = _magic.from_buffer(data[:4096], mime=True)
|
||||
if detected and detected != "application/octet-stream":
|
||||
return detected
|
||||
except Exception:
|
||||
pass
|
||||
built_in = _builtin_sniff(data)
|
||||
if built_in:
|
||||
return built_in
|
||||
if hint and "/" in hint:
|
||||
return hint.lower()
|
||||
return "application/octet-stream"
|
||||
|
||||
|
||||
def validate_size(data: bytes, *, max_bytes: int = DEFAULT_MAX_BYTES) -> None:
|
||||
if len(data) == 0:
|
||||
raise UploadError("EMPTY_FILE", "Upload data is empty.")
|
||||
if len(data) > max_bytes:
|
||||
raise UploadError(
|
||||
"TOO_LARGE",
|
||||
f"File is {len(data)} bytes; limit is {max_bytes} bytes "
|
||||
f"(~{max_bytes // 1024 // 1024} MB). Use chunked upload for larger files.",
|
||||
{"size": len(data), "max": max_bytes},
|
||||
)
|
||||
|
||||
|
||||
def validate_mime(mime: str, *, allowed: Iterable[str] = ALLOWED_MIMES) -> None:
|
||||
if mime not in allowed:
|
||||
raise UploadError(
|
||||
"MIME_REJECTED",
|
||||
f"MIME type '{mime}' is not allowed. " f"Supported: {', '.join(sorted(allowed))}.",
|
||||
{"mime": mime},
|
||||
)
|
||||
|
||||
|
||||
_FILENAME_SAFE_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
|
||||
|
||||
def safe_filename(filename: str | None, *, mime: str) -> tuple[str, str | None]:
|
||||
"""Return (ascii_filename, rfc5987_encoded_original_if_non_ascii)."""
|
||||
original = (filename or "").strip() or "upload"
|
||||
# Strip any path components
|
||||
original = original.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
|
||||
try:
|
||||
original.encode("ascii")
|
||||
ascii_name = _FILENAME_SAFE_RE.sub("_", original)
|
||||
encoded = None
|
||||
except UnicodeEncodeError:
|
||||
from urllib.parse import quote
|
||||
|
||||
ascii_name = _FILENAME_SAFE_RE.sub(
|
||||
"_", original.encode("ascii", "ignore").decode() or "upload"
|
||||
)
|
||||
encoded = "UTF-8''" + quote(original, safe="")
|
||||
|
||||
# Ensure extension matches MIME
|
||||
ext = _EXT_MAP.get(mime) or (mimetypes.guess_extension(mime) or "")
|
||||
if ext and not ascii_name.lower().endswith(ext):
|
||||
base = ascii_name.rsplit(".", 1)[0] if "." in ascii_name else ascii_name
|
||||
ascii_name = f"{base}{ext}"
|
||||
|
||||
return ascii_name[:255], encoded
|
||||
|
||||
|
||||
def content_disposition(filename_ascii: str, filename_encoded: str | None) -> str:
|
||||
"""Build Content-Disposition header, optionally with RFC 5987 filename* for non-ASCII."""
|
||||
base = f'attachment; filename="{filename_ascii}"'
|
||||
if filename_encoded:
|
||||
return f"{base}; filename*={filename_encoded}"
|
||||
return base
|
||||
|
||||
|
||||
# --- SSRF guard ------------------------------------------------------------
|
||||
|
||||
_BLOCKED_HOSTS = {
|
||||
"metadata.google.internal",
|
||||
"metadata",
|
||||
"169.254.169.254",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SSRFCheck:
|
||||
allowed: bool
|
||||
reason: str | None = None
|
||||
resolved_ip: str | None = None
|
||||
|
||||
|
||||
def ssrf_check(url: str, *, allow_http: bool = False) -> SSRFCheck:
|
||||
"""Reject URLs pointing to private/loopback/link-local/metadata endpoints."""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except Exception as e:
|
||||
return SSRFCheck(False, f"URL parse failed: {e}")
|
||||
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return SSRFCheck(False, f"Scheme '{parsed.scheme}' not allowed; use https (or http).")
|
||||
if parsed.scheme == "http" and not allow_http:
|
||||
return SSRFCheck(False, "HTTP URLs are disabled; use HTTPS.")
|
||||
|
||||
host = (parsed.hostname or "").lower()
|
||||
if not host:
|
||||
return SSRFCheck(False, "URL has no host.")
|
||||
if host in _BLOCKED_HOSTS:
|
||||
return SSRFCheck(False, f"Host '{host}' is on the SSRF blocklist.")
|
||||
|
||||
# Resolve and check each IP
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, None)
|
||||
except socket.gaierror as e:
|
||||
return SSRFCheck(False, f"DNS resolution failed: {e}")
|
||||
|
||||
seen: list[str] = []
|
||||
for info in infos:
|
||||
ip_str = info[4][0]
|
||||
seen.append(ip_str)
|
||||
try:
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
except ValueError:
|
||||
return SSRFCheck(False, f"Unparseable IP '{ip_str}'.")
|
||||
if (
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip.is_reserved
|
||||
or ip.is_multicast
|
||||
or ip.is_unspecified
|
||||
):
|
||||
return SSRFCheck(False, f"Host resolves to disallowed IP {ip}.", ip_str)
|
||||
|
||||
return SSRFCheck(True, None, seen[0] if seen else None)
|
||||
462
plugins/wordpress/handlers/ai_media.py
Normal file
462
plugins/wordpress/handlers/ai_media.py
Normal file
@@ -0,0 +1,462 @@
|
||||
"""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)
|
||||
275
plugins/wordpress/handlers/audit_hook.py
Normal file
275
plugins/wordpress/handlers/audit_hook.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""F.18.7 — Manage the companion plugin's audit-hook webhook config.
|
||||
|
||||
Wraps ``GET|POST|DELETE /airano-mcp/v1/audit-hook`` (companion plugin
|
||||
v2.7.0+). Three tools:
|
||||
|
||||
- ``wordpress_audit_hook_status`` (read): returns current config + stats.
|
||||
- ``wordpress_audit_hook_configure`` (admin): upserts endpoint_url / secret /
|
||||
enabled / events.
|
||||
- ``wordpress_audit_hook_disable`` (admin): clears config and stops pushing.
|
||||
|
||||
The actual event receiver lives in ``core/companion_audit.py``; this
|
||||
module only manages the per-site configuration on the WordPress side.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress.handlers._companion_hint import (
|
||||
companion_install_hint as _companion_install_hint,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("mcphub.wordpress.audit_hook")
|
||||
|
||||
SUPPORTED_EVENTS = (
|
||||
"transition_post_status",
|
||||
"deleted_post",
|
||||
"user_register",
|
||||
"profile_update",
|
||||
"deleted_user",
|
||||
"activated_plugin",
|
||||
"deactivated_plugin",
|
||||
"switch_theme",
|
||||
)
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "audit_hook_status",
|
||||
"method_name": "audit_hook_status",
|
||||
"description": (
|
||||
"Read the companion plugin's audit-hook configuration "
|
||||
"(v2.7.0+): current endpoint_url, whether a secret is set, "
|
||||
"enabled flag, event list, last-push timestamp, failure "
|
||||
"count. Secret is never returned in full — only the last 4 "
|
||||
"characters. Requires manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "audit_hook_configure",
|
||||
"method_name": "audit_hook_configure",
|
||||
"description": (
|
||||
"Configure the companion plugin's audit-hook webhook. Sets "
|
||||
"the MCPHub endpoint_url, shared HMAC secret (≥16 chars), "
|
||||
"enabled flag, and list of hooked events. Returns the "
|
||||
"resulting status. Requires manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"endpoint_url": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Full URL to MCPHub's /api/companion-audit "
|
||||
"route (e.g. https://mcp.example.com/api/companion-audit)."
|
||||
),
|
||||
},
|
||||
"secret": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"HMAC-SHA256 shared secret (≥16 chars). Store "
|
||||
"the identical value in MCPHub's "
|
||||
"CompanionAuditSecretStore for this site."
|
||||
),
|
||||
},
|
||||
"enabled": {"type": "boolean"},
|
||||
"events": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "enum": list(SUPPORTED_EVENTS)},
|
||||
"description": (
|
||||
"List of WP action hooks to forward. Default: " "all supported events."
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "audit_hook_disable",
|
||||
"method_name": "audit_hook_disable",
|
||||
"description": (
|
||||
"Clear the companion plugin's audit-hook configuration "
|
||||
"(endpoint_url, secret, events) and stop forwarding events. "
|
||||
"Requires manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "admin",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _validate_configure(
|
||||
*,
|
||||
endpoint_url: str | None,
|
||||
secret: str | None,
|
||||
enabled: Any,
|
||||
events: Any,
|
||||
) -> dict[str, Any] | None:
|
||||
# endpoint_url: allow empty-string to mean "clear" on the PHP side,
|
||||
# but reject obviously-broken inputs client-side.
|
||||
if (
|
||||
endpoint_url is not None
|
||||
and endpoint_url
|
||||
and not endpoint_url.startswith(("http://", "https://"))
|
||||
):
|
||||
return {
|
||||
"error": "invalid_endpoint_url",
|
||||
"message": "endpoint_url must start with http:// or https://",
|
||||
}
|
||||
if secret is not None and secret != "" and len(secret) < 16:
|
||||
return {
|
||||
"error": "secret_too_short",
|
||||
"message": "shared secret must be at least 16 characters",
|
||||
}
|
||||
if events is not None:
|
||||
if not isinstance(events, list):
|
||||
return {
|
||||
"error": "invalid_events",
|
||||
"message": "events must be a list of known event names",
|
||||
}
|
||||
for e in events:
|
||||
if not isinstance(e, str) or e not in SUPPORTED_EVENTS:
|
||||
return {
|
||||
"error": "unknown_event",
|
||||
"message": f"event {e!r} is not supported",
|
||||
"supported": list(SUPPORTED_EVENTS),
|
||||
}
|
||||
if enabled is not None and not isinstance(enabled, bool):
|
||||
return {
|
||||
"error": "invalid_enabled",
|
||||
"message": "enabled must be true or false",
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _unreachable(exc: Exception) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "companion_unreachable",
|
||||
"message": str(exc),
|
||||
"hint": (
|
||||
"Requires airano-mcp-bridge companion plugin v2.7.0+ and "
|
||||
"manage_options capability. Run wordpress_probe_capabilities "
|
||||
"to verify availability."
|
||||
),
|
||||
"install_hint": _companion_install_hint(
|
||||
min_version="2.7.0",
|
||||
required_capability="manage_options",
|
||||
route="airano-mcp/v1/audit-hook",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class AuditHookHandler:
|
||||
"""Configure + query the companion plugin's audit-hook webhook."""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
async def audit_hook_status(self) -> str:
|
||||
try:
|
||||
payload = await self.client.get(
|
||||
"airano-mcp/v1/audit-hook",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("audit_hook_status companion call failed: %s", exc)
|
||||
return json.dumps(_unreachable(exc), indent=2)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_response",
|
||||
"message": "companion returned a non-object payload",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
return json.dumps({"ok": True, **payload}, indent=2)
|
||||
|
||||
async def audit_hook_configure(
|
||||
self,
|
||||
endpoint_url: str | None = None,
|
||||
secret: str | None = None,
|
||||
enabled: Any = None,
|
||||
events: Any = None,
|
||||
) -> str:
|
||||
err = _validate_configure(
|
||||
endpoint_url=endpoint_url,
|
||||
secret=secret,
|
||||
enabled=enabled,
|
||||
events=events,
|
||||
)
|
||||
if err is not None:
|
||||
return json.dumps({"ok": False, **err}, indent=2)
|
||||
|
||||
body: dict[str, Any] = {}
|
||||
if endpoint_url is not None:
|
||||
body["endpoint_url"] = endpoint_url
|
||||
if secret is not None:
|
||||
body["secret"] = secret
|
||||
if enabled is not None:
|
||||
body["enabled"] = bool(enabled)
|
||||
if events is not None:
|
||||
body["events"] = list(events)
|
||||
|
||||
if not body:
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "no_fields",
|
||||
"message": (
|
||||
"Provide at least one of: endpoint_url, secret, " "enabled, events."
|
||||
),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
try:
|
||||
payload = await self.client.post(
|
||||
"airano-mcp/v1/audit-hook",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("audit_hook_configure companion call failed: %s", exc)
|
||||
return json.dumps(_unreachable(exc), indent=2)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_response",
|
||||
"message": "companion returned a non-object payload",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
return json.dumps({"ok": True, **payload}, indent=2)
|
||||
|
||||
async def audit_hook_disable(self) -> str:
|
||||
try:
|
||||
payload = await self.client.delete(
|
||||
"airano-mcp/v1/audit-hook",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("audit_hook_disable companion call failed: %s", exc)
|
||||
return json.dumps(_unreachable(exc), indent=2)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_response",
|
||||
"message": "companion returned a non-object payload",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
return json.dumps({"ok": True, **payload}, indent=2)
|
||||
179
plugins/wordpress/handlers/bulk_meta.py
Normal file
179
plugins/wordpress/handlers/bulk_meta.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""F.18.2 — Batch post/product meta writes via companion plugin.
|
||||
|
||||
Wraps ``POST /airano-mcp/v1/bulk-meta`` (companion plugin v2.2.0+). One
|
||||
HTTP round-trip updates meta for up to 500 posts/products in a single
|
||||
REST call; without the companion each post would need its own request.
|
||||
|
||||
Tool: ``wordpress_bulk_update_meta(updates=[...])``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress.handlers._companion_hint import (
|
||||
companion_install_hint as _companion_install_hint,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("mcphub.wordpress.bulk_meta")
|
||||
|
||||
# Matches BULK_META_MAX_ITEMS in airano-mcp-bridge.php so we reject client-side
|
||||
# before burning a round-trip on a 413.
|
||||
MAX_BULK_ITEMS = 500
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "bulk_update_meta",
|
||||
"method_name": "bulk_update_meta",
|
||||
"description": (
|
||||
"Batch-update post_meta (posts, pages, WooCommerce products) in a "
|
||||
"single REST round-trip via the airano-mcp-bridge companion "
|
||||
"plugin (v2.2.0+). Each item is permission-checked in PHP via "
|
||||
"current_user_can('edit_post', post_id). Pass a null meta value "
|
||||
"to delete that key. Maximum 500 items per call."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"updates": {
|
||||
"type": "array",
|
||||
"description": (
|
||||
"List of {post_id, meta} objects. `meta` is a dict of "
|
||||
"meta_key => value; null values delete the key."
|
||||
),
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"post_id": {"type": "integer"},
|
||||
"meta": {"type": "object"},
|
||||
},
|
||||
"required": ["post_id", "meta"],
|
||||
},
|
||||
}
|
||||
},
|
||||
"required": ["updates"],
|
||||
},
|
||||
"scope": "write",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _validate_updates(updates: Any) -> list[dict[str, Any]] | dict[str, Any]:
|
||||
"""Validate the updates list. Returns the list on success, or an error dict."""
|
||||
if not isinstance(updates, list):
|
||||
return {
|
||||
"error": "invalid_updates",
|
||||
"message": "`updates` must be a list of {post_id, meta} objects.",
|
||||
}
|
||||
if not updates:
|
||||
return {
|
||||
"error": "empty_updates",
|
||||
"message": "No updates supplied.",
|
||||
}
|
||||
if len(updates) > MAX_BULK_ITEMS:
|
||||
return {
|
||||
"error": "too_many_items",
|
||||
"message": (
|
||||
f"At most {MAX_BULK_ITEMS} items per bulk_update_meta call; " f"got {len(updates)}."
|
||||
),
|
||||
}
|
||||
|
||||
cleaned: list[dict[str, Any]] = []
|
||||
for idx, item in enumerate(updates):
|
||||
if not isinstance(item, dict):
|
||||
return {
|
||||
"error": "invalid_item",
|
||||
"message": f"updates[{idx}] is not an object.",
|
||||
"index": idx,
|
||||
}
|
||||
post_id = item.get("post_id")
|
||||
meta = item.get("meta")
|
||||
if not isinstance(post_id, int) or post_id <= 0:
|
||||
return {
|
||||
"error": "invalid_post_id",
|
||||
"message": f"updates[{idx}].post_id must be a positive integer.",
|
||||
"index": idx,
|
||||
}
|
||||
if not isinstance(meta, dict):
|
||||
return {
|
||||
"error": "invalid_meta",
|
||||
"message": f"updates[{idx}].meta must be an object.",
|
||||
"index": idx,
|
||||
}
|
||||
cleaned.append({"post_id": post_id, "meta": meta})
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
class BulkMetaHandler:
|
||||
"""Batch meta writes via the companion plugin."""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
async def bulk_update_meta(self, updates: Any) -> str:
|
||||
validated = _validate_updates(updates)
|
||||
if isinstance(validated, dict):
|
||||
# client-side rejection — don't burn a round-trip
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
**validated,
|
||||
"total": len(updates) if isinstance(updates, list) else 0,
|
||||
"updated": 0,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"results": [],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
try:
|
||||
payload = await self.client.post(
|
||||
"airano-mcp/v1/bulk-meta",
|
||||
json_data={"updates": validated},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("bulk_update_meta companion call failed: %s", exc)
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "companion_unreachable",
|
||||
"message": str(exc),
|
||||
"hint": (
|
||||
"Requires airano-mcp-bridge companion plugin v2.2.0+; "
|
||||
"install/update it from wordpress-plugin/airano-mcp-bridge.zip "
|
||||
"or run wordpress_probe_capabilities to verify availability."
|
||||
),
|
||||
"install_hint": _companion_install_hint(
|
||||
min_version="2.2.0",
|
||||
required_capability="manage_options",
|
||||
route="airano-mcp/v1/bulk-meta",
|
||||
),
|
||||
"total": len(validated),
|
||||
"updated": 0,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"results": [],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
# The companion already returns a well-shaped response — just
|
||||
# re-emit with the `ok` flag prepended so callers don't have to
|
||||
# infer success from counts.
|
||||
result = {
|
||||
"ok": True,
|
||||
"total": int(payload.get("total", 0)),
|
||||
"updated": int(payload.get("updated", 0)),
|
||||
"failed": int(payload.get("failed", 0)),
|
||||
"skipped": int(payload.get("skipped", 0)),
|
||||
"results": payload.get("results", []),
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
109
plugins/wordpress/handlers/cache_purge.py
Normal file
109
plugins/wordpress/handlers/cache_purge.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""F.18.4 — Cache purge via companion plugin.
|
||||
|
||||
Wraps ``POST /airano-mcp/v1/cache-purge`` (companion plugin v2.4.0+).
|
||||
Auto-detects active cache plugins (LiteSpeed, WP Rocket, W3 Total Cache,
|
||||
WP Super Cache, WP Fastest Cache, SiteGround Optimizer) and invokes
|
||||
their purge API. Always flushes the WP object cache. Replaces the
|
||||
previous Docker-socket + WP-CLI path on managed hosts.
|
||||
|
||||
Tool: ``wordpress_cache_purge()``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress.handlers._companion_hint import (
|
||||
companion_install_hint as _companion_install_hint,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("mcphub.wordpress.cache_purge")
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "cache_purge",
|
||||
"method_name": "cache_purge",
|
||||
"description": (
|
||||
"Purge all caches on the WordPress site via the "
|
||||
"airano-mcp-bridge companion plugin (v2.4.0+). Auto-detects "
|
||||
"active cache plugins (LiteSpeed, WP Rocket, W3 Total Cache, "
|
||||
"WP Super Cache, WP Fastest Cache, SiteGround Optimizer) and "
|
||||
"calls each one's purge API. Always flushes the object cache. "
|
||||
"Requires manage_options on the calling application password."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "admin",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class CachePurgeHandler:
|
||||
"""Cache purge via companion plugin."""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
async def cache_purge(self) -> str:
|
||||
try:
|
||||
payload = await self.client.post(
|
||||
"airano-mcp/v1/cache-purge",
|
||||
json_data={},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("cache_purge companion call failed: %s", exc)
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "companion_unreachable",
|
||||
"message": str(exc),
|
||||
"hint": (
|
||||
"Requires airano-mcp-bridge companion plugin v2.4.0+ "
|
||||
"and manage_options capability. Run "
|
||||
"wordpress_probe_capabilities to verify."
|
||||
),
|
||||
"install_hint": _companion_install_hint(
|
||||
min_version="2.4.0",
|
||||
required_capability="manage_options",
|
||||
route="airano-mcp/v1/cache-purge",
|
||||
),
|
||||
"detected": [],
|
||||
"purged": [],
|
||||
"errors": [],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_response",
|
||||
"message": "companion returned a non-object payload",
|
||||
"detected": [],
|
||||
"purged": [],
|
||||
"errors": [],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
# Pass through + normalise.
|
||||
detected = list(payload.get("detected") or [])
|
||||
purged = list(payload.get("purged") or [])
|
||||
errors = list(payload.get("errors") or [])
|
||||
ok = bool(payload.get("ok", not errors))
|
||||
|
||||
result = {
|
||||
"ok": ok,
|
||||
"detected": detected,
|
||||
"purged": purged,
|
||||
"skipped": list(payload.get("skipped") or []),
|
||||
"errors": errors,
|
||||
"plugin_version": payload.get("plugin_version"),
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
221
plugins/wordpress/handlers/capabilities.py
Normal file
221
plugins/wordpress/handlers/capabilities.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""F.18.1 — Probe companion-plugin capabilities for the current credentials.
|
||||
|
||||
Calls ``GET /airano-mcp/v1/capabilities`` which returns the exact capability
|
||||
set of the authenticated user plus the list of routes the installed companion
|
||||
plugin actually ships (so MCPHub can gracefully degrade if the site is on an
|
||||
older version). Consumed by F.7e's credential-capability probe.
|
||||
|
||||
Results are cached in-memory per ``(site_url, username)`` for 24 h, matching
|
||||
``media_probe``'s behaviour.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
|
||||
logger = logging.getLogger("mcphub.wordpress.capabilities")
|
||||
|
||||
CACHE_TTL_SECONDS = 24 * 3600
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "probe_capabilities",
|
||||
"method_name": "probe_capabilities",
|
||||
"description": (
|
||||
"Probe the airano-mcp-bridge companion plugin for the effective "
|
||||
"capability set of the calling application password plus the list of "
|
||||
"companion routes the installed version ships. Returns "
|
||||
"`companion_available: false` when the plugin is missing or outdated. "
|
||||
"Cached 24 h per (site, user)."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
# The exact capability keys the companion plugin advertises. Keep this list
|
||||
# in lock-step with ``airano-mcp-bridge.php::get_capabilities()``; anything the
|
||||
# plugin returns that isn't in this list is preserved as-is under ``extra``.
|
||||
_EXPECTED_CAPS = (
|
||||
"upload_files",
|
||||
"edit_posts",
|
||||
"publish_posts",
|
||||
"edit_others_posts",
|
||||
"delete_posts",
|
||||
"edit_pages",
|
||||
"publish_pages",
|
||||
"manage_categories",
|
||||
"moderate_comments",
|
||||
"manage_options",
|
||||
"edit_users",
|
||||
"list_users",
|
||||
"manage_woocommerce",
|
||||
"edit_shop_orders",
|
||||
"edit_products",
|
||||
)
|
||||
|
||||
_EXPECTED_ROUTES = (
|
||||
"seo_meta",
|
||||
"upload_limits",
|
||||
"upload_chunk",
|
||||
"upload_and_attach",
|
||||
"capabilities",
|
||||
"bulk_meta",
|
||||
"export",
|
||||
"cache_purge",
|
||||
"transient_flush",
|
||||
"site_health",
|
||||
"audit_hook",
|
||||
"regenerate_thumbnails",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CacheEntry:
|
||||
fetched_at: float
|
||||
data: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CapabilitiesCache:
|
||||
"""Process-local TTL cache keyed by (site_url, username)."""
|
||||
|
||||
ttl: float = CACHE_TTL_SECONDS
|
||||
_entries: dict[tuple[str, str], _CacheEntry] = field(default_factory=dict)
|
||||
_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
|
||||
async def get(self, key: tuple[str, str]) -> dict[str, Any] | None:
|
||||
async with self._lock:
|
||||
entry = self._entries.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
if (time.time() - entry.fetched_at) > self.ttl:
|
||||
self._entries.pop(key, None)
|
||||
return None
|
||||
return dict(entry.data)
|
||||
|
||||
async def set(self, key: tuple[str, str], data: dict[str, Any]) -> None:
|
||||
async with self._lock:
|
||||
self._entries[key] = _CacheEntry(fetched_at=time.time(), data=dict(data))
|
||||
|
||||
async def clear(self) -> None:
|
||||
async with self._lock:
|
||||
self._entries.clear()
|
||||
|
||||
|
||||
_cache = _CapabilitiesCache()
|
||||
|
||||
|
||||
def get_capabilities_cache() -> _CapabilitiesCache:
|
||||
return _cache
|
||||
|
||||
|
||||
def _empty_capabilities_payload(site_url: str, reason: str) -> dict[str, Any]:
|
||||
"""Stable response shape for the missing-companion case."""
|
||||
# Local import avoids a module-import cycle at startup time
|
||||
# (capabilities → _companion_hint → capabilities via __init__).
|
||||
from plugins.wordpress.handlers._companion_hint import companion_install_hint
|
||||
|
||||
return {
|
||||
"site_url": site_url,
|
||||
"companion_available": False,
|
||||
"reason": reason,
|
||||
"plugin_version": None,
|
||||
"user": None,
|
||||
"features": None,
|
||||
"routes": dict.fromkeys(_EXPECTED_ROUTES, False),
|
||||
"wordpress": None,
|
||||
"install_hint": companion_install_hint(
|
||||
min_version="2.1.0",
|
||||
required_capability="read",
|
||||
route="airano-mcp/v1/capabilities",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class CapabilitiesHandler:
|
||||
"""Read-only probe of the companion plugin's capability advertisement."""
|
||||
|
||||
def __init__(self, client: WordPressClient, *, cache: _CapabilitiesCache | None = None) -> None:
|
||||
self.client = client
|
||||
self._cache = cache or _cache
|
||||
|
||||
@property
|
||||
def _cache_key(self) -> tuple[str, str]:
|
||||
return (self.client.site_url, self.client.username)
|
||||
|
||||
async def probe_capabilities(self) -> str:
|
||||
cached = await self._cache.get(self._cache_key)
|
||||
if cached is not None:
|
||||
cached["cached"] = True
|
||||
return json.dumps(cached, indent=2)
|
||||
|
||||
result = await self._fetch_capabilities()
|
||||
await self._cache.set(self._cache_key, result)
|
||||
result_out = dict(result)
|
||||
result_out["cached"] = False
|
||||
return json.dumps(result_out, indent=2)
|
||||
|
||||
async def _fetch_capabilities(self) -> dict[str, Any]:
|
||||
try:
|
||||
payload = await self.client.get(
|
||||
"airano-mcp/v1/capabilities",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("Capabilities probe failed: %s", exc)
|
||||
return _empty_capabilities_payload(
|
||||
self.client.site_url, reason=f"companion_unreachable: {exc}"
|
||||
)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return _empty_capabilities_payload(
|
||||
self.client.site_url, reason="companion_returned_non_dict"
|
||||
)
|
||||
|
||||
# Normalise the shape: fill any missing cap/route with False so
|
||||
# downstream consumers can index without KeyError checks.
|
||||
caps_raw = (payload.get("user") or {}).get("capabilities") or {}
|
||||
caps = {k: bool(caps_raw.get(k, False)) for k in _EXPECTED_CAPS}
|
||||
extra_caps = {k: bool(v) for k, v in caps_raw.items() if k not in _EXPECTED_CAPS}
|
||||
|
||||
routes_raw = payload.get("routes") or {}
|
||||
routes = {k: bool(routes_raw.get(k, False)) for k in _EXPECTED_ROUTES}
|
||||
|
||||
user = payload.get("user") or {}
|
||||
user_out = {
|
||||
"id": user.get("id"),
|
||||
"login": user.get("login"),
|
||||
"roles": list(user.get("roles") or []),
|
||||
"capabilities": caps,
|
||||
"extra_capabilities": extra_caps,
|
||||
}
|
||||
|
||||
return {
|
||||
"site_url": self.client.site_url,
|
||||
"companion_available": True,
|
||||
"plugin_version": payload.get("plugin_version"),
|
||||
"user": user_out,
|
||||
"features": payload.get("features"),
|
||||
"routes": routes,
|
||||
"wordpress": payload.get("wordpress"),
|
||||
}
|
||||
|
||||
|
||||
async def get_cached_capabilities(
|
||||
client: WordPressClient, *, cache: _CapabilitiesCache | None = None
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the cached capability dict for ``client`` without forcing a probe."""
|
||||
c = cache or _cache
|
||||
key = (client.site_url, client.username)
|
||||
return await c.get(key)
|
||||
210
plugins/wordpress/handlers/export.py
Normal file
210
plugins/wordpress/handlers/export.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""F.18.3 — Structured JSON export via companion plugin.
|
||||
|
||||
Wraps ``GET /airano-mcp/v1/export`` (companion plugin v2.3.0+). Returns
|
||||
posts (posts, pages, products, custom post types) plus referenced media,
|
||||
taxonomy terms, and meta in a single JSON envelope, with pagination
|
||||
hints (``has_more`` + ``next_offset``). Intended for offline processing,
|
||||
migrations, and content snapshots.
|
||||
|
||||
Tool: ``wordpress_export_content(post_type="post", status="publish", ...)``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress.handlers._companion_hint import (
|
||||
companion_install_hint as _companion_install_hint,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("mcphub.wordpress.export")
|
||||
|
||||
# Matches EXPORT_MAX_LIMIT in airano-mcp-bridge.php.
|
||||
EXPORT_MAX_LIMIT = 500
|
||||
EXPORT_DEFAULT_LIMIT = 100
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "export_content",
|
||||
"method_name": "export_content",
|
||||
"description": (
|
||||
"Export posts/pages/products as structured JSON via the "
|
||||
"airano-mcp-bridge companion plugin (v2.3.0+). Includes "
|
||||
"referenced media, taxonomy terms, and post_meta. Paginates "
|
||||
"via offset/limit; response contains has_more + next_offset. "
|
||||
"Not a WXR dump — intended for AI-pipeline processing, not "
|
||||
"WP-to-WP import."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"post_type": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Comma-separated list of post types "
|
||||
"(e.g. 'post', 'post,page', 'product'). Default 'post'."
|
||||
),
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Comma-separated list of statuses, or 'any'. " "Default 'publish'."
|
||||
),
|
||||
},
|
||||
"since": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Only return posts modified after this ISO8601 " "timestamp. Optional."
|
||||
),
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": (f"1..{EXPORT_MAX_LIMIT}, default {EXPORT_DEFAULT_LIMIT}."),
|
||||
},
|
||||
"offset": {"type": "integer", "description": "Default 0."},
|
||||
"include_media": {
|
||||
"type": "boolean",
|
||||
"description": "Include featured media objects (default true).",
|
||||
},
|
||||
"include_terms": {
|
||||
"type": "boolean",
|
||||
"description": "Include taxonomy terms per post (default true).",
|
||||
},
|
||||
"include_meta": {
|
||||
"type": "boolean",
|
||||
"description": "Include post_meta (default true).",
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "read",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _normalise_bool(v: Any, default: bool) -> bool:
|
||||
if v is None:
|
||||
return default
|
||||
if isinstance(v, bool):
|
||||
return v
|
||||
if isinstance(v, (int, float)):
|
||||
return bool(v)
|
||||
s = str(v).strip().lower()
|
||||
if s in {"true", "1", "yes", "on"}:
|
||||
return True
|
||||
if s in {"false", "0", "no", "off"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def _build_query_params(
|
||||
*,
|
||||
post_type: str | None,
|
||||
status: str | None,
|
||||
since: str | None,
|
||||
limit: int | None,
|
||||
offset: int | None,
|
||||
include_media: Any,
|
||||
include_terms: Any,
|
||||
include_meta: Any,
|
||||
) -> dict[str, Any]:
|
||||
params: dict[str, Any] = {
|
||||
"post_type": post_type or "post",
|
||||
"status": status or "publish",
|
||||
}
|
||||
if since:
|
||||
params["since"] = since
|
||||
|
||||
if limit is None:
|
||||
params["limit"] = EXPORT_DEFAULT_LIMIT
|
||||
else:
|
||||
lim = int(limit)
|
||||
if lim <= 0:
|
||||
lim = EXPORT_DEFAULT_LIMIT
|
||||
if lim > EXPORT_MAX_LIMIT:
|
||||
lim = EXPORT_MAX_LIMIT
|
||||
params["limit"] = lim
|
||||
|
||||
params["offset"] = max(0, int(offset or 0))
|
||||
|
||||
# Pass booleans as "true"/"false" strings so the PHP side's
|
||||
# bool_param() helper can parse them uniformly.
|
||||
params["include_media"] = "true" if _normalise_bool(include_media, True) else "false"
|
||||
params["include_terms"] = "true" if _normalise_bool(include_terms, True) else "false"
|
||||
params["include_meta"] = "true" if _normalise_bool(include_meta, True) else "false"
|
||||
|
||||
return params
|
||||
|
||||
|
||||
class ExportHandler:
|
||||
"""Structured JSON export via the companion plugin."""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
async def export_content(
|
||||
self,
|
||||
post_type: str | None = None,
|
||||
status: str | None = None,
|
||||
since: str | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
include_media: Any = True,
|
||||
include_terms: Any = True,
|
||||
include_meta: Any = True,
|
||||
) -> str:
|
||||
params = _build_query_params(
|
||||
post_type=post_type,
|
||||
status=status,
|
||||
since=since,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
include_media=include_media,
|
||||
include_terms=include_terms,
|
||||
include_meta=include_meta,
|
||||
)
|
||||
|
||||
try:
|
||||
payload = await self.client.get(
|
||||
"airano-mcp/v1/export",
|
||||
params=params,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("export_content companion call failed: %s", exc)
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "companion_unreachable",
|
||||
"message": str(exc),
|
||||
"hint": (
|
||||
"Requires airano-mcp-bridge companion plugin v2.3.0+. "
|
||||
"Run wordpress_probe_capabilities to verify availability."
|
||||
),
|
||||
"install_hint": _companion_install_hint(
|
||||
min_version="2.3.0",
|
||||
required_capability="edit_posts",
|
||||
route="airano-mcp/v1/export",
|
||||
),
|
||||
"params": params,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_response",
|
||||
"message": "companion returned a non-object payload",
|
||||
"params": params,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
result = {"ok": True, **payload}
|
||||
return json.dumps(result, indent=2)
|
||||
@@ -1,11 +1,23 @@
|
||||
"""Media Handler - manages WordPress media library operations"""
|
||||
|
||||
import base64 as _b64
|
||||
import binascii
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from core.media_audit import log_media_upload
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
@@ -61,13 +73,17 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
{
|
||||
"name": "upload_media_from_url",
|
||||
"method_name": "upload_media_from_url",
|
||||
"description": "Upload media from URL to media library (sideload). Downloads file from public URL and uploads to WordPress.",
|
||||
"description": "Upload media from a public URL to the WordPress media library (sideload). Downloads the file with SSRF protection, sniffs MIME, and uploads via raw-binary POST.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Public URL of the media file to upload (image, video, document, etc.)",
|
||||
"description": "Public HTTPS URL of the media file",
|
||||
},
|
||||
"filename": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Override filename (default: derived from URL)",
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
@@ -75,17 +91,96 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
},
|
||||
"alt_text": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Alternative text for accessibility (important for images)",
|
||||
"description": "Alternative text for accessibility",
|
||||
},
|
||||
"caption": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Media caption (displayed below image when inserted into content)",
|
||||
"description": "Media caption",
|
||||
},
|
||||
"attach_to_post": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"description": "Attach uploaded media to this post/page ID",
|
||||
},
|
||||
"set_featured": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "If true and attach_to_post is set, also set as the post's featured image",
|
||||
},
|
||||
"skip_optimize": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Skip server-side image optimization (F.5a.2)",
|
||||
},
|
||||
"convert_to": {
|
||||
"anyOf": [{"type": "string", "enum": ["webp", "avif"]}, {"type": "null"}],
|
||||
"description": (
|
||||
"F.5a.8.1: re-encode the image in a modern format before upload. "
|
||||
"'webp' or 'avif'. Falls back to WebP if AVIF is unavailable. "
|
||||
"Leave null to keep source format (or use WP_MEDIA_CONVERT_TO env default)."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["url"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "upload_media_from_base64",
|
||||
"method_name": "upload_media_from_base64",
|
||||
"description": "Upload a base64-encoded file directly to the WordPress media library. For chat-attached images/files smaller than ~10 MB. Use upload_media_from_url for larger files or chunked path later.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string",
|
||||
"description": "Base64-encoded file bytes (no data: URL prefix required; prefix will be stripped if present)",
|
||||
},
|
||||
"filename": {
|
||||
"type": "string",
|
||||
"description": "Filename including extension (e.g. 'cover.jpg')",
|
||||
},
|
||||
"mime": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Client-supplied MIME hint; ignored if magic-byte sniff says otherwise",
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Media title",
|
||||
},
|
||||
"alt_text": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Alternative text",
|
||||
},
|
||||
"caption": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Caption",
|
||||
},
|
||||
"attach_to_post": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"description": "Attach to this post/page ID",
|
||||
},
|
||||
"set_featured": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Also set as the post's featured image",
|
||||
},
|
||||
"skip_optimize": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Skip server-side image optimization",
|
||||
},
|
||||
"convert_to": {
|
||||
"anyOf": [{"type": "string", "enum": ["webp", "avif"]}, {"type": "null"}],
|
||||
"description": (
|
||||
"F.5a.8.1: re-encode the image in a modern format before upload. "
|
||||
"'webp' or 'avif'. Falls back to WebP if AVIF is unavailable."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["data", "filename"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "update_media",
|
||||
"method_name": "update_media",
|
||||
@@ -157,17 +252,162 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
]
|
||||
|
||||
|
||||
def _decode_base64(data: str) -> bytes:
|
||||
"""Accept raw base64 or data: URL prefix; return decoded bytes."""
|
||||
s = (data or "").strip()
|
||||
if s.startswith("data:") and "," in s:
|
||||
s = s.split(",", 1)[1]
|
||||
s = s.replace("\n", "").replace("\r", "").replace(" ", "")
|
||||
try:
|
||||
return _b64.b64decode(s, validate=True)
|
||||
except (binascii.Error, ValueError) as e:
|
||||
raise UploadError("BAD_BASE64", f"Invalid base64 payload: {e}") from e
|
||||
|
||||
|
||||
def _maybe_optimize(
|
||||
data: bytes,
|
||||
mime_hint: str | None,
|
||||
*,
|
||||
skip: bool,
|
||||
convert_to: str | None = None,
|
||||
) -> tuple[bytes, str | None]:
|
||||
"""Route image bytes through the F.5a.2 optimize pipeline.
|
||||
|
||||
F.5a.8.1: ``convert_to`` is forwarded to the optimizer so callers can
|
||||
force WebP/AVIF output. A falsy value defers to the ``WP_MEDIA_CONVERT_TO``
|
||||
env var.
|
||||
"""
|
||||
if skip:
|
||||
return data, mime_hint
|
||||
try:
|
||||
from plugins.wordpress.handlers._media_optimize import optimize # type: ignore
|
||||
except ImportError:
|
||||
return data, mime_hint
|
||||
return optimize(data, mime_hint, convert_to=convert_to)
|
||||
|
||||
|
||||
async def _apply_metadata_and_attach(
|
||||
client: WordPressClient,
|
||||
media: dict[str, Any],
|
||||
*,
|
||||
title: str | None,
|
||||
alt_text: str | None,
|
||||
caption: str | None,
|
||||
attach_to_post: int | None,
|
||||
set_featured: bool,
|
||||
wc_client: WordPressClient | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply metadata + attach to post / featured-image.
|
||||
|
||||
F.5a.8.5 — when the companion's single-call ``upload-and-attach``
|
||||
route already applied these fields, skip everything (saves 1-2
|
||||
redundant round-trips).
|
||||
|
||||
F.X.fix-pass6 — return a status dict so callers can surface
|
||||
"media uploaded but featured failed" as partial success instead
|
||||
of a misleading GENERATION_FAILED. When ``set_featured`` targets
|
||||
a WooCommerce product (CPT, not addressable via /wp/v2/posts/{id}),
|
||||
route through the WC products endpoint via ``wc_client``. Both
|
||||
"metadata applied" and "featured set" steps are reported
|
||||
independently in the result dict.
|
||||
"""
|
||||
status: dict[str, Any] = {
|
||||
"metadata_applied": False,
|
||||
"featured_set": False,
|
||||
"featured_context": None,
|
||||
"warnings": [],
|
||||
}
|
||||
if media.get("_upload_route") == "companion_unified":
|
||||
# Companion did metadata + attach + featured atomically.
|
||||
status["metadata_applied"] = True
|
||||
status["featured_set"] = bool(set_featured and attach_to_post)
|
||||
status["featured_context"] = "companion_unified"
|
||||
return status
|
||||
|
||||
if any(v is not None for v in (title, alt_text, caption)) or attach_to_post is not None:
|
||||
try:
|
||||
await wp_update_media_metadata(
|
||||
client,
|
||||
media["id"],
|
||||
title=title,
|
||||
alt_text=alt_text,
|
||||
caption=caption,
|
||||
post=attach_to_post,
|
||||
)
|
||||
status["metadata_applied"] = True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
status["warnings"].append(f"metadata_failed: {exc}")
|
||||
|
||||
if set_featured and attach_to_post is not None:
|
||||
# 1. WC product first (CPT). Falls through to /wp/v2/posts on miss.
|
||||
wc = wc_client or client
|
||||
wc_product = None
|
||||
try:
|
||||
wc_product = await wc.get(f"products/{attach_to_post}", use_woocommerce=True)
|
||||
except Exception:
|
||||
wc_product = None
|
||||
|
||||
if isinstance(wc_product, dict) and wc_product.get("id"):
|
||||
try:
|
||||
existing_images = list(wc_product.get("images") or [])
|
||||
# Featured = images[0]; preserve gallery via the same
|
||||
# merge primitive used by attach_media_to_product.
|
||||
from plugins.wordpress.handlers.media_attach import _merge_product_images
|
||||
|
||||
new_images = _merge_product_images(
|
||||
existing=existing_images,
|
||||
new_ids=[media["id"]],
|
||||
role="main",
|
||||
mode="replace",
|
||||
)
|
||||
await wc.put(
|
||||
f"products/{attach_to_post}",
|
||||
json_data={"images": new_images},
|
||||
use_woocommerce=True,
|
||||
)
|
||||
status["featured_set"] = True
|
||||
status["featured_context"] = "product"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
status["warnings"].append(f"featured_set_failed (product): {exc}")
|
||||
else:
|
||||
try:
|
||||
await wp_set_featured_media(client, attach_to_post, media["id"])
|
||||
status["featured_set"] = True
|
||||
status["featured_context"] = "post"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
status["warnings"].append(f"featured_set_failed (post): {exc}")
|
||||
|
||||
return status
|
||||
|
||||
|
||||
def _format_upload_result(media: dict[str, Any], *, source: str) -> dict[str, Any]:
|
||||
title = media.get("title")
|
||||
rendered_title = title.get("rendered") if isinstance(title, dict) else title
|
||||
return {
|
||||
"id": media["id"],
|
||||
"title": rendered_title or "",
|
||||
"url": media.get("source_url", ""),
|
||||
"mime_type": media.get("mime_type", ""),
|
||||
"media_type": media.get("media_type", ""),
|
||||
"size_bytes": media.get("media_details", {}).get("filesize"),
|
||||
"source": source,
|
||||
"message": f"Media uploaded successfully (id={media['id']}).",
|
||||
}
|
||||
|
||||
|
||||
class MediaHandler:
|
||||
"""Handle media-related operations for WordPress"""
|
||||
|
||||
def __init__(self, client: WordPressClient):
|
||||
def __init__(self, client: WordPressClient, *, user_id: str | None = None):
|
||||
"""
|
||||
Initialize media handler.
|
||||
|
||||
Args:
|
||||
client: WordPress API client instance
|
||||
user_id: Calling user id, used for audit logging (None = admin/env)
|
||||
"""
|
||||
self.client = client
|
||||
self.user_id = user_id
|
||||
|
||||
# === MEDIA ===
|
||||
|
||||
@@ -207,6 +447,11 @@ class MediaHandler:
|
||||
"date": m["date"],
|
||||
"alt_text": m.get("alt_text", ""),
|
||||
"link": m.get("link", ""),
|
||||
# F.X.fix #6: expose post_parent so callers can
|
||||
# verify "is media X attached to post Y" without
|
||||
# a second WP REST round trip. WP returns 0 for
|
||||
# unattached media; we preserve that.
|
||||
"post_parent": m.get("post") or 0,
|
||||
}
|
||||
for m in media
|
||||
],
|
||||
@@ -244,6 +489,9 @@ class MediaHandler:
|
||||
"modified": media.get("modified", ""),
|
||||
"link": media.get("link", ""),
|
||||
"media_details": media.get("media_details", {}),
|
||||
# F.X.fix #6: expose post_parent so attach-verification
|
||||
# doesn't need a second round trip via posts/{id}.
|
||||
"post_parent": media.get("post") or 0,
|
||||
}
|
||||
|
||||
return json.dumps(result, indent=2)
|
||||
@@ -255,82 +503,123 @@ class MediaHandler:
|
||||
async def upload_media_from_url(
|
||||
self,
|
||||
url: str,
|
||||
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:
|
||||
"""
|
||||
Upload media from URL to media library (sideload).
|
||||
|
||||
Downloads file from a public URL and uploads it to WordPress media library.
|
||||
|
||||
Args:
|
||||
url: Public URL of the media file to upload
|
||||
title: Media title (used in media library)
|
||||
alt_text: Alternative text for accessibility
|
||||
caption: Media caption (displayed below image)
|
||||
|
||||
Returns:
|
||||
JSON string with uploaded media data
|
||||
"""
|
||||
"""Upload media from a public URL to the WordPress media library."""
|
||||
try:
|
||||
# Download file from URL
|
||||
async with aiohttp.ClientSession() as session, session.get(url) as response:
|
||||
if response.status >= 400:
|
||||
raise Exception(f"Failed to download from URL: HTTP {response.status}")
|
||||
ssrf = ssrf_check(url)
|
||||
if not ssrf.allowed:
|
||||
raise UploadError(
|
||||
"SSRF", ssrf.reason or "URL rejected by SSRF guard.", {"url": url}
|
||||
)
|
||||
|
||||
file_content = await response.read()
|
||||
content_type = response.headers.get("Content-Type", "application/octet-stream")
|
||||
data, declared_ct, fname_guess = await fetch_url_bytes(url, max_bytes=DEFAULT_MAX_BYTES)
|
||||
data, mime_hint = _maybe_optimize(
|
||||
data, declared_ct, skip=skip_optimize, convert_to=convert_to
|
||||
)
|
||||
|
||||
# Extract filename from URL
|
||||
filename = url.split("/")[-1].split("?")[0]
|
||||
if not filename:
|
||||
filename = "downloaded_file"
|
||||
media = await wp_raw_upload(
|
||||
self.client,
|
||||
data,
|
||||
filename=filename or fname_guess,
|
||||
mime_hint=mime_hint or declared_ct,
|
||||
# F.5a.8.5: forward metadata so the companion's single-
|
||||
# call route (when advertised) bundles upload + attach +
|
||||
# featured in one PHP request. ``_apply_metadata_and_attach``
|
||||
# below is a no-op in that case.
|
||||
attach_to_post=attach_to_post,
|
||||
set_featured=set_featured,
|
||||
title=title,
|
||||
alt_text=alt_text,
|
||||
caption=caption,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
# Create FormData for upload
|
||||
form = aiohttp.FormData()
|
||||
form.add_field("file", file_content, filename=filename, content_type=content_type)
|
||||
|
||||
# Upload to WordPress using client's upload method
|
||||
# Note: We need to use the client's base_url and auth directly for file upload
|
||||
upload_url = f"{self.client.base_url}/media"
|
||||
headers = {
|
||||
"Authorization": self.client.auth_header,
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(upload_url, data=form, headers=headers) as response:
|
||||
if response.status >= 400:
|
||||
error_text = await response.text()
|
||||
raise Exception(f"Upload failed (HTTP {response.status}): {error_text}")
|
||||
|
||||
media = await response.json()
|
||||
|
||||
# Update metadata if provided
|
||||
if title or alt_text or caption:
|
||||
update_data = {}
|
||||
if title:
|
||||
update_data["title"] = title
|
||||
if alt_text:
|
||||
update_data["alt_text"] = alt_text
|
||||
if caption:
|
||||
update_data["caption"] = caption
|
||||
|
||||
await self.client.post(f"media/{media['id']}", json_data=update_data)
|
||||
|
||||
result = {
|
||||
"id": media["id"],
|
||||
"title": media["title"]["rendered"],
|
||||
"url": media["source_url"],
|
||||
"mime_type": media["mime_type"],
|
||||
"message": f"Media uploaded from URL successfully with ID {media['id']}",
|
||||
}
|
||||
|
||||
return json.dumps(result, indent=2)
|
||||
log_media_upload(
|
||||
site=self.client.site_url,
|
||||
user_id=self.user_id,
|
||||
mime=media.get("mime_type") or mime_hint or declared_ct,
|
||||
size_bytes=len(data),
|
||||
source="url",
|
||||
media_id=media.get("id"),
|
||||
)
|
||||
return json.dumps(_format_upload_result(media, source=url), indent=2)
|
||||
except UploadError as e:
|
||||
return json.dumps(e.to_dict(), indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps(
|
||||
{"error": str(e), "message": f"Failed to upload media from URL: {str(e)}"}, indent=2
|
||||
{"error_code": "INTERNAL", "message": f"Upload from URL failed: {e}"}, indent=2
|
||||
)
|
||||
|
||||
async def upload_media_from_base64(
|
||||
self,
|
||||
data: str,
|
||||
filename: str,
|
||||
mime: 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:
|
||||
"""Upload a base64-encoded file to the WordPress media library."""
|
||||
try:
|
||||
raw = _decode_base64(data)
|
||||
raw, mime_hint = _maybe_optimize(raw, mime, skip=skip_optimize, convert_to=convert_to)
|
||||
|
||||
media = await wp_raw_upload(
|
||||
self.client,
|
||||
raw,
|
||||
filename=filename,
|
||||
mime_hint=mime_hint or mime,
|
||||
# F.5a.8.5: single-call path when companion advertises it.
|
||||
attach_to_post=attach_to_post,
|
||||
set_featured=set_featured,
|
||||
title=title,
|
||||
alt_text=alt_text,
|
||||
caption=caption,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
log_media_upload(
|
||||
site=self.client.site_url,
|
||||
user_id=self.user_id,
|
||||
mime=media.get("mime_type") or mime_hint or mime,
|
||||
size_bytes=len(raw),
|
||||
source="base64",
|
||||
media_id=media.get("id"),
|
||||
)
|
||||
return json.dumps(_format_upload_result(media, source="base64"), 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"Base64 upload failed: {e}"}, indent=2
|
||||
)
|
||||
|
||||
async def update_media(
|
||||
|
||||
397
plugins/wordpress/handlers/media_attach.py
Normal file
397
plugins/wordpress/handlers/media_attach.py
Normal file
@@ -0,0 +1,397 @@
|
||||
"""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
|
||||
250
plugins/wordpress/handlers/media_bulk.py
Normal file
250
plugins/wordpress/handlers/media_bulk.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""F.5a.8.3 — Bulk media library operations.
|
||||
|
||||
Wraps stock ``wp/v2/media`` REST endpoints with two batch tools:
|
||||
|
||||
* ``wordpress_bulk_delete_media`` — delete (trash or force-delete) a list of
|
||||
attachments in one call, collecting per-item results.
|
||||
* ``wordpress_bulk_reassign_media`` — change the ``post`` parent of a list
|
||||
of attachments in one call (useful for moving media between posts, or
|
||||
detaching from a deleted parent).
|
||||
|
||||
Both tools use the existing authenticated REST client. They iterate
|
||||
serially with a small concurrency cap so a large batch doesn't flood a
|
||||
shared-hosting WP backend, but the whole call returns a single JSON
|
||||
envelope with ``processed`` / ``errors`` / ``total`` — matching the
|
||||
shape used by F.18.2 bulk-meta.
|
||||
|
||||
Per-call cap: 100 attachment IDs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
|
||||
logger = logging.getLogger("mcphub.wordpress.media_bulk")
|
||||
|
||||
_MAX_IDS_PER_CALL = 100
|
||||
_CONCURRENCY = 4 # parallel in-flight REST calls
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "bulk_delete_media",
|
||||
"method_name": "bulk_delete_media",
|
||||
"description": (
|
||||
"Delete (trash or permanently remove) a list of media "
|
||||
"attachments in a single call. Max 100 IDs per request. "
|
||||
"Returns processed / errors / total. Uses stock "
|
||||
"/wp/v2/media/{id} DELETE so no companion plugin is "
|
||||
"required, but issues N requests (with a small concurrency "
|
||||
"cap); for 1000+ attachments, paginate client-side."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"media_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer", "minimum": 1},
|
||||
"minItems": 1,
|
||||
"maxItems": _MAX_IDS_PER_CALL,
|
||||
"description": f"Attachment IDs (max {_MAX_IDS_PER_CALL}).",
|
||||
},
|
||||
"force": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": (
|
||||
"true = permanently delete (bypass trash). "
|
||||
"false = move to trash. Media in trash > 30 "
|
||||
"days is removed by WP cron by default."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["media_ids"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "bulk_reassign_media",
|
||||
"method_name": "bulk_reassign_media",
|
||||
"description": (
|
||||
"Reassign the parent post of a list of media attachments "
|
||||
"in a single call. Useful for moving attachments between "
|
||||
"posts, or detaching (use target_post=0). Max 100 IDs. "
|
||||
"Returns processed / errors / total."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"media_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer", "minimum": 1},
|
||||
"minItems": 1,
|
||||
"maxItems": _MAX_IDS_PER_CALL,
|
||||
"description": f"Attachment IDs (max {_MAX_IDS_PER_CALL}).",
|
||||
},
|
||||
"target_post": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": (
|
||||
"Post ID to assign as the new parent. " "0 = detach (orphan the media)."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["media_ids", "target_post"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class MediaBulkHandler:
|
||||
"""Batch delete + reassign for WP media attachments."""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Input normalization
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _normalize_ids(self, media_ids: list[Any]) -> list[int]:
|
||||
"""Dedup, coerce to int, drop non-positive, cap at _MAX_IDS_PER_CALL.
|
||||
|
||||
Order is preserved so the caller can correlate request and response
|
||||
by index if they care.
|
||||
"""
|
||||
out: list[int] = []
|
||||
seen: set[int] = set()
|
||||
for raw in media_ids or []:
|
||||
try:
|
||||
i = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if i > 0 and i not in seen:
|
||||
out.append(i)
|
||||
seen.add(i)
|
||||
if len(out) >= _MAX_IDS_PER_CALL:
|
||||
break
|
||||
return out
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Bulk delete
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def bulk_delete_media(
|
||||
self,
|
||||
media_ids: list[int],
|
||||
force: bool = False,
|
||||
) -> str:
|
||||
ids = self._normalize_ids(media_ids or [])
|
||||
if not ids:
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_request",
|
||||
"message": "media_ids must contain at least one positive integer.",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
params = {"force": "true" if force else "false"}
|
||||
sem = asyncio.Semaphore(_CONCURRENCY)
|
||||
|
||||
processed: list[dict[str, Any]] = []
|
||||
errors: list[dict[str, Any]] = []
|
||||
|
||||
async def _one(mid: int) -> None:
|
||||
async with sem:
|
||||
try:
|
||||
await self.client.delete(f"media/{mid}", params=params)
|
||||
processed.append({"id": mid})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append({"id": mid, "error": str(exc)})
|
||||
|
||||
await asyncio.gather(*[_one(i) for i in ids])
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": not errors,
|
||||
"total": len(ids),
|
||||
"processed": len(processed),
|
||||
"errors": errors,
|
||||
"force": force,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Bulk reassign
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def bulk_reassign_media(
|
||||
self,
|
||||
media_ids: list[int],
|
||||
target_post: int,
|
||||
) -> str:
|
||||
try:
|
||||
target = int(target_post)
|
||||
except (TypeError, ValueError):
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_request",
|
||||
"message": "target_post must be an integer (0 to detach).",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
if target < 0:
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_request",
|
||||
"message": "target_post must be >= 0.",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
ids = self._normalize_ids(media_ids or [])
|
||||
if not ids:
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_request",
|
||||
"message": "media_ids must contain at least one positive integer.",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
sem = asyncio.Semaphore(_CONCURRENCY)
|
||||
processed: list[dict[str, Any]] = []
|
||||
errors: list[dict[str, Any]] = []
|
||||
|
||||
async def _one(mid: int) -> None:
|
||||
async with sem:
|
||||
try:
|
||||
await self.client.post(
|
||||
f"media/{mid}",
|
||||
json_data={"post": target},
|
||||
)
|
||||
processed.append({"id": mid})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append({"id": mid, "error": str(exc)})
|
||||
|
||||
await asyncio.gather(*[_one(i) for i in ids])
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": not errors,
|
||||
"total": len(ids),
|
||||
"processed": len(processed),
|
||||
"errors": errors,
|
||||
"target_post": target,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
322
plugins/wordpress/handlers/media_chunked.py
Normal file
322
plugins/wordpress/handlers/media_chunked.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""Chunked media upload tools (F.5a.5).
|
||||
|
||||
Exposes four tools that wrap `core.upload_sessions.UploadSessionStore`:
|
||||
|
||||
- `upload_media_chunked_start(filename, total_bytes, mime?, sha256?)`
|
||||
- `upload_media_chunked_chunk(session_id, index, data_b64)`
|
||||
- `upload_media_chunked_finish(session_id, title?, alt_text?, caption?,
|
||||
attach_to_post?, set_featured?, skip_optimize?)`
|
||||
- `upload_media_chunked_abort(session_id)`
|
||||
|
||||
At `finish`, reuses the existing F.5a.1/.2 primitives: assembled bytes →
|
||||
optional Pillow optimization → `wp_raw_upload` → metadata/attach/featured.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64 as _b64
|
||||
import binascii
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from core.media_audit import log_media_upload
|
||||
from core.upload_sessions import (
|
||||
UploadSessionError,
|
||||
UploadSessionStore,
|
||||
get_upload_session_store,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specs for ToolGenerator."""
|
||||
return [
|
||||
{
|
||||
"name": "upload_media_chunked_start",
|
||||
"method_name": "upload_media_chunked_start",
|
||||
"description": (
|
||||
"Start a chunked upload session for a large media file. "
|
||||
"Returns a session_id to use for subsequent chunk/finish/abort "
|
||||
"calls. TTL 1h, hard cap 500 MB per session."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filename": {"type": "string", "description": "Filename with extension."},
|
||||
"total_bytes": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Declared total size in bytes.",
|
||||
},
|
||||
"mime": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "MIME hint; still sniffed at finish.",
|
||||
},
|
||||
"sha256": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Optional hex sha256 of the full payload; verified at finish.",
|
||||
},
|
||||
},
|
||||
"required": ["filename", "total_bytes"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "upload_media_chunked_chunk",
|
||||
"method_name": "upload_media_chunked_chunk",
|
||||
"description": (
|
||||
"Append a single base64-encoded chunk to a chunked upload session. "
|
||||
"Chunks must arrive in order starting at index 0."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": {"type": "string"},
|
||||
"index": {"type": "integer", "minimum": 0},
|
||||
"data_b64": {
|
||||
"type": "string",
|
||||
"description": "Base64-encoded chunk bytes.",
|
||||
},
|
||||
"chunk_sha256": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Optional hex sha256 of this chunk.",
|
||||
},
|
||||
},
|
||||
"required": ["session_id", "index", "data_b64"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "upload_media_chunked_finish",
|
||||
"method_name": "upload_media_chunked_finish",
|
||||
"description": (
|
||||
"Finalize a chunked upload: assemble + optimize + upload to "
|
||||
"WordPress. Verifies sha256 if supplied at start."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": {"type": "string"},
|
||||
"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"}, {"type": "null"}],
|
||||
},
|
||||
"set_featured": {"type": "boolean", "default": False},
|
||||
"skip_optimize": {"type": "boolean", "default": False},
|
||||
},
|
||||
"required": ["session_id"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "upload_media_chunked_abort",
|
||||
"method_name": "upload_media_chunked_abort",
|
||||
"description": "Abort a chunked upload session and delete its spill file.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": {"type": "string"},
|
||||
},
|
||||
"required": ["session_id"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "upload_media_chunked_status",
|
||||
"method_name": "upload_media_chunked_status",
|
||||
"description": (
|
||||
"Query the current state of a chunked-upload session — returns "
|
||||
"``received_bytes`` and ``next_chunk`` so the caller can resume "
|
||||
"after a disconnect. The session survives for 1 h after the "
|
||||
"last activity; re-start the session with ``upload_media_chunked_start`` "
|
||||
"if it has expired or been aborted. Returns ``{error_code: "
|
||||
"NOT_FOUND}`` when the session is unknown."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": {
|
||||
"type": "string",
|
||||
"description": "Session ID returned by upload_media_chunked_start.",
|
||||
},
|
||||
},
|
||||
"required": ["session_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _decode_chunk(data_b64: str) -> bytes:
|
||||
s = (data_b64 or "").strip()
|
||||
if s.startswith("data:") and "," in s:
|
||||
s = s.split(",", 1)[1]
|
||||
s = s.replace("\n", "").replace("\r", "").replace(" ", "")
|
||||
try:
|
||||
return _b64.b64decode(s, validate=True)
|
||||
except (binascii.Error, ValueError) as e:
|
||||
raise UploadSessionError("BAD_BASE64", f"Invalid base64 chunk: {e}") from e
|
||||
|
||||
|
||||
class MediaChunkedHandler:
|
||||
"""Chunked-upload tool handler for the WordPress plugin."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: WordPressClient,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
store: UploadSessionStore | None = None,
|
||||
) -> None:
|
||||
self.client = client
|
||||
self.user_id = user_id or "admin"
|
||||
self._store = store
|
||||
|
||||
@property
|
||||
def store(self) -> UploadSessionStore:
|
||||
return self._store or get_upload_session_store()
|
||||
|
||||
async def upload_media_chunked_start(
|
||||
self,
|
||||
filename: str,
|
||||
total_bytes: int,
|
||||
mime: str | None = None,
|
||||
sha256: str | None = None,
|
||||
) -> str:
|
||||
try:
|
||||
sess = await self.store.start(
|
||||
user_id=self.user_id,
|
||||
filename=filename,
|
||||
total_bytes=total_bytes,
|
||||
mime=mime,
|
||||
sha256=sha256,
|
||||
)
|
||||
return json.dumps(sess.to_public_dict(), indent=2)
|
||||
except UploadSessionError as e:
|
||||
return json.dumps(e.to_dict(), indent=2)
|
||||
|
||||
async def upload_media_chunked_chunk(
|
||||
self,
|
||||
session_id: str,
|
||||
index: int,
|
||||
data_b64: str,
|
||||
chunk_sha256: str | None = None,
|
||||
) -> str:
|
||||
try:
|
||||
data = _decode_chunk(data_b64)
|
||||
sess = await self.store.append_chunk(session_id, index, data, chunk_sha256=chunk_sha256)
|
||||
return json.dumps(sess.to_public_dict(), indent=2)
|
||||
except UploadSessionError as e:
|
||||
return json.dumps(e.to_dict(), indent=2)
|
||||
|
||||
async def upload_media_chunked_finish(
|
||||
self,
|
||||
session_id: str,
|
||||
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,
|
||||
) -> str:
|
||||
from core.tool_rate_limiter import ToolRateLimitError, get_tool_rate_limiter
|
||||
|
||||
try:
|
||||
get_tool_rate_limiter().check(
|
||||
"wordpress_upload_media_chunked_finish",
|
||||
self.user_id if self.user_id != "admin" else None,
|
||||
)
|
||||
except ToolRateLimitError as e:
|
||||
return json.dumps(e.to_dict(), indent=2)
|
||||
|
||||
try:
|
||||
sess, assembled = await self.store.finalize(session_id)
|
||||
data, mime_hint = _maybe_optimize(assembled, sess.mime, skip=skip_optimize)
|
||||
media = await wp_raw_upload(
|
||||
self.client,
|
||||
data,
|
||||
filename=sess.filename,
|
||||
mime_hint=mime_hint or sess.mime,
|
||||
# F.5a.8.5: single-call upload+metadata+attach+featured
|
||||
# when the companion advertises it.
|
||||
attach_to_post=attach_to_post,
|
||||
set_featured=set_featured,
|
||||
title=title,
|
||||
alt_text=alt_text,
|
||||
caption=caption,
|
||||
)
|
||||
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,
|
||||
)
|
||||
log_media_upload(
|
||||
site=self.client.site_url,
|
||||
user_id=self.user_id if self.user_id != "admin" else None,
|
||||
mime=media.get("mime_type") or mime_hint or sess.mime,
|
||||
size_bytes=len(data),
|
||||
source="chunked",
|
||||
media_id=media.get("id"),
|
||||
)
|
||||
return json.dumps(_format_upload_result(media, source="chunked"), indent=2)
|
||||
except UploadSessionError as e:
|
||||
return json.dumps(e.to_dict(), indent=2)
|
||||
except UploadError as e:
|
||||
return json.dumps(e.to_dict(), indent=2)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return json.dumps(
|
||||
{"error_code": "INTERNAL", "message": f"Chunked finish failed: {e}"}, indent=2
|
||||
)
|
||||
|
||||
async def upload_media_chunked_status(self, session_id: str) -> str:
|
||||
"""Return the current received_bytes / next_chunk for a session.
|
||||
|
||||
Enables a resume-after-disconnect flow: the client keeps the
|
||||
session_id from the original ``start`` call, then queries this
|
||||
tool to discover how many bytes the server already has before
|
||||
resuming ``chunk`` calls at the reported ``next_chunk`` index.
|
||||
Aborted sessions (spill file gone) still return their last
|
||||
known status, clearly marked by the ``status`` field.
|
||||
"""
|
||||
try:
|
||||
sess = await self.store.get(session_id)
|
||||
if sess is None:
|
||||
return json.dumps(
|
||||
{
|
||||
"error_code": "NO_SESSION",
|
||||
"message": (
|
||||
"Session not found. Either it has never existed, "
|
||||
"already been finalised, or been aborted. Start a "
|
||||
"new session with upload_media_chunked_start."
|
||||
),
|
||||
"session_id": session_id,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
return json.dumps(sess.to_public_dict(), indent=2)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return json.dumps(
|
||||
{
|
||||
"error_code": "INTERNAL",
|
||||
"message": f"Status lookup failed: {exc}",
|
||||
"session_id": session_id,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
async def upload_media_chunked_abort(self, session_id: str) -> str:
|
||||
removed = await self.store.abort(session_id)
|
||||
return json.dumps({"session_id": session_id, "aborted": bool(removed)}, indent=2)
|
||||
229
plugins/wordpress/handlers/media_probe.py
Normal file
229
plugins/wordpress/handlers/media_probe.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""F.5a.6.3 — Probe WordPress upload limits with 24 h cache.
|
||||
|
||||
Tries the airano-mcp-bridge companion endpoint first (which can read
|
||||
PHP ini values directly); falls back to whatever the standard WP REST
|
||||
index publishes. Results are cached in-memory per site for 24 h.
|
||||
|
||||
The cache is keyed by ``(site_url, username)`` so admin and per-user
|
||||
clients don't poison each other's view.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
|
||||
logger = logging.getLogger("mcphub.wordpress.media_probe")
|
||||
|
||||
CACHE_TTL_SECONDS = 24 * 3600
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "probe_upload_limits",
|
||||
"method_name": "probe_upload_limits",
|
||||
"description": (
|
||||
"Probe a WordPress site for its effective upload limits "
|
||||
"(upload_max_filesize, post_max_size, memory_limit, "
|
||||
"max_input_time, wp_max_upload_size). Uses the "
|
||||
"airano-mcp-bridge companion plugin if present, else "
|
||||
"best-effort from the WP REST index. Cached 24 h per site."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CacheEntry:
|
||||
fetched_at: float
|
||||
data: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ProbeCache:
|
||||
"""Process-local TTL cache keyed by (site_url, username)."""
|
||||
|
||||
ttl: float = CACHE_TTL_SECONDS
|
||||
_entries: dict[tuple[str, str], _CacheEntry] = field(default_factory=dict)
|
||||
_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
|
||||
async def get(self, key: tuple[str, str]) -> dict[str, Any] | None:
|
||||
async with self._lock:
|
||||
entry = self._entries.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
if (time.time() - entry.fetched_at) > self.ttl:
|
||||
self._entries.pop(key, None)
|
||||
return None
|
||||
return dict(entry.data)
|
||||
|
||||
async def set(self, key: tuple[str, str], data: dict[str, Any]) -> None:
|
||||
async with self._lock:
|
||||
self._entries[key] = _CacheEntry(fetched_at=time.time(), data=dict(data))
|
||||
|
||||
async def clear(self) -> None:
|
||||
async with self._lock:
|
||||
self._entries.clear()
|
||||
|
||||
|
||||
_cache = _ProbeCache()
|
||||
|
||||
|
||||
def get_probe_cache() -> _ProbeCache:
|
||||
return _cache
|
||||
|
||||
|
||||
_LIMIT_KEYS = (
|
||||
"upload_max_filesize",
|
||||
"post_max_size",
|
||||
"memory_limit",
|
||||
"max_input_time",
|
||||
"wp_max_upload_size",
|
||||
)
|
||||
|
||||
# Byte-valued keys among `_LIMIT_KEYS` — used by F.5a.7 route selection to
|
||||
# decide whether to prefer the companion upload-chunk route over /wp/v2/media.
|
||||
_BYTE_VALUED_KEYS = ("upload_max_filesize", "post_max_size", "wp_max_upload_size")
|
||||
|
||||
|
||||
def _empty_limits() -> dict[str, Any]:
|
||||
return dict.fromkeys(_LIMIT_KEYS)
|
||||
|
||||
|
||||
def parse_php_size(value: Any) -> int | None:
|
||||
"""Parse a PHP ``ini_get`` size string like ``"64M"`` to bytes.
|
||||
|
||||
Accepts an already-numeric value (returns it as-is cast to int), a suffix
|
||||
of K/M/G/T (both upper- and lower-case), or a bare integer string.
|
||||
Returns None if the value can't be parsed or represents "no limit" (``-1``).
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return None if value < 0 else value
|
||||
if isinstance(value, float):
|
||||
return None if value < 0 else int(value)
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
return None
|
||||
if s in {"-1", "0"}:
|
||||
return None
|
||||
unit = s[-1].upper()
|
||||
multipliers = {"K": 1024, "M": 1024**2, "G": 1024**3, "T": 1024**4}
|
||||
try:
|
||||
if unit in multipliers:
|
||||
return int(float(s[:-1]) * multipliers[unit])
|
||||
return int(s)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def effective_upload_ceiling(limits: dict[str, Any] | None) -> int | None:
|
||||
"""Return the smallest relevant size ceiling (in bytes) across the limits.
|
||||
|
||||
For route-selection purposes we take the min of the byte-valued keys that
|
||||
are populated. If nothing is populated, returns None (= "unknown, treat
|
||||
every upload as small enough for the REST route").
|
||||
"""
|
||||
if not limits:
|
||||
return None
|
||||
parsed: list[int] = []
|
||||
for key in _BYTE_VALUED_KEYS:
|
||||
raw = limits.get(key)
|
||||
got = parse_php_size(raw)
|
||||
if got is not None:
|
||||
parsed.append(got)
|
||||
return min(parsed) if parsed else None
|
||||
|
||||
|
||||
class ProbeHandler:
|
||||
"""Read-only probe of a WordPress site's upload limits."""
|
||||
|
||||
def __init__(self, client: WordPressClient, *, cache: _ProbeCache | None = None) -> None:
|
||||
self.client = client
|
||||
self._cache = cache or _cache
|
||||
|
||||
@property
|
||||
def _cache_key(self) -> tuple[str, str]:
|
||||
return (self.client.site_url, self.client.username)
|
||||
|
||||
async def probe_upload_limits(self) -> str:
|
||||
cached = await self._cache.get(self._cache_key)
|
||||
if cached is not None:
|
||||
cached["cached"] = True
|
||||
return json.dumps(cached, indent=2)
|
||||
|
||||
result = await self._fetch_limits()
|
||||
await self._cache.set(self._cache_key, result)
|
||||
result_out = dict(result)
|
||||
result_out["cached"] = False
|
||||
return json.dumps(result_out, indent=2)
|
||||
|
||||
async def _fetch_limits(self) -> dict[str, Any]:
|
||||
limits = _empty_limits()
|
||||
source = "unknown"
|
||||
# Companion plugin first.
|
||||
try:
|
||||
payload = await self.client.get(
|
||||
"airano-mcp/v1/upload-limits",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
if isinstance(payload, dict):
|
||||
for k in _LIMIT_KEYS:
|
||||
if payload.get(k) is not None:
|
||||
limits[k] = payload[k]
|
||||
source = "companion"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("Companion probe failed (%s); falling back to REST index.", exc)
|
||||
|
||||
# Fallback / supplement: REST index may expose wp_max_upload_size.
|
||||
if all(v is None for v in limits.values()):
|
||||
try:
|
||||
root = await self.client.get("", use_custom_namespace=True) # /wp-json/
|
||||
if isinstance(root, dict):
|
||||
if "wp_max_upload_size" in root:
|
||||
limits["wp_max_upload_size"] = root["wp_max_upload_size"]
|
||||
source = "rest_index"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("REST index probe failed: %s", exc)
|
||||
|
||||
# F.5a.7: expose byte-parsed ceiling so _media_core can pick the
|
||||
# best upload route without re-parsing PHP ini strings.
|
||||
ceiling = effective_upload_ceiling(limits)
|
||||
return {
|
||||
"site_url": self.client.site_url,
|
||||
"source": source,
|
||||
"companion_available": source == "companion",
|
||||
"limits": limits,
|
||||
"limits_bytes": {
|
||||
"upload_max_filesize": parse_php_size(limits.get("upload_max_filesize")),
|
||||
"post_max_size": parse_php_size(limits.get("post_max_size")),
|
||||
"wp_max_upload_size": parse_php_size(limits.get("wp_max_upload_size")),
|
||||
"effective_ceiling": ceiling,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def get_cached_limits(
|
||||
client: WordPressClient, *, cache: _ProbeCache | None = None
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the cached limits dict for ``client`` without forcing a probe.
|
||||
|
||||
Used by `_media_core.wp_raw_upload` to decide whether to prefer the
|
||||
companion upload-chunk route. Returns None when no probe has been cached
|
||||
yet — callers treat that as "no hints available; take the standard path".
|
||||
"""
|
||||
c = cache or _cache
|
||||
key = (client.site_url, client.username)
|
||||
return await c.get(key)
|
||||
@@ -565,60 +565,89 @@ class PostsHandler:
|
||||
|
||||
Args:
|
||||
post_id: Post ID to retrieve
|
||||
fields: Comma-separated list of fields to return (e.g., 'id,title,status')
|
||||
fields: Comma-separated list of fields to return (e.g., 'id,title,status').
|
||||
When set, behaves as a STRICT allow-list — only the
|
||||
requested names (plus ``id``) are returned. Unknown
|
||||
names are ignored.
|
||||
|
||||
Returns:
|
||||
JSON string with post data
|
||||
JSON string with post data. Default projection includes
|
||||
``featured_media`` (int or 0), ``slug`` (str), and
|
||||
``featured_media_url`` (derived from
|
||||
``_embedded['wp:featuredmedia'][0].source_url``; empty
|
||||
string when no featured image is set) so callers can
|
||||
verify a post's featured image was set without a separate
|
||||
media-fetch round trip.
|
||||
"""
|
||||
try:
|
||||
# F.X.fix #4: default projection includes the fields every
|
||||
# caller asked for during F.X.test (featured_media, slug,
|
||||
# featured_media_url). When ``fields`` is supplied we honour
|
||||
# it as a STRICT allow-list — callers must opt in to each
|
||||
# name explicitly, no hidden always-on subset beyond id.
|
||||
params = {"_embed": "true"}
|
||||
field_map = {
|
||||
"id": "id",
|
||||
"title": "title",
|
||||
"content": "content",
|
||||
"excerpt": "excerpt",
|
||||
"status": "status",
|
||||
"date": "date",
|
||||
"modified": "modified",
|
||||
"author": "_embedded",
|
||||
"categories": "categories",
|
||||
"tags": "tags",
|
||||
"link": "link",
|
||||
"slug": "slug",
|
||||
"featured_media": "featured_media",
|
||||
"featured_media_url": "_embedded",
|
||||
"word_count": "content",
|
||||
}
|
||||
if fields:
|
||||
# Map our field names to WordPress API _fields
|
||||
wp_fields = set()
|
||||
requested = {f.strip().lower() for f in fields.split(",")}
|
||||
field_map = {
|
||||
"id": "id",
|
||||
"title": "title",
|
||||
"content": "content",
|
||||
"excerpt": "excerpt",
|
||||
"status": "status",
|
||||
"date": "date",
|
||||
"modified": "modified",
|
||||
"author": "_embedded",
|
||||
"categories": "categories",
|
||||
"tags": "tags",
|
||||
"link": "link",
|
||||
"slug": "slug",
|
||||
}
|
||||
wp_fields: set[str] = set()
|
||||
requested = {f.strip().lower() for f in fields.split(",") if f.strip()}
|
||||
for f in requested:
|
||||
if f in field_map:
|
||||
wp_fields.add(field_map[f])
|
||||
# Always include id and title for basic identification
|
||||
wp_fields.update({"id", "title"})
|
||||
wp_fields.add("id")
|
||||
params["_fields"] = ",".join(wp_fields)
|
||||
|
||||
post = await self.client.get(f"posts/{post_id}", params=params)
|
||||
|
||||
# Derive featured_media_url from the embedded media entry.
|
||||
# WP returns the embedded resource under
|
||||
# ``_embedded['wp:featuredmedia']`` (hyphenated key, list of
|
||||
# one). Empty string when no featured image is attached.
|
||||
featured_media_url = ""
|
||||
embedded = post.get("_embedded") or {}
|
||||
media_entries = embedded.get("wp:featuredmedia") or []
|
||||
if isinstance(media_entries, list) and media_entries:
|
||||
first = media_entries[0] or {}
|
||||
if isinstance(first, dict):
|
||||
featured_media_url = first.get("source_url") or ""
|
||||
|
||||
# Build full result
|
||||
full_result = {
|
||||
"id": post["id"],
|
||||
"title": post.get("title", {}).get("rendered", ""),
|
||||
"slug": post.get("slug", ""),
|
||||
"content": post.get("content", {}).get("rendered", ""),
|
||||
"excerpt": post.get("excerpt", {}).get("rendered", ""),
|
||||
"status": post.get("status", ""),
|
||||
"date": post.get("date", ""),
|
||||
"modified": post.get("modified", ""),
|
||||
"author": post.get("_embedded", {}).get("author", [{}])[0].get("name", "Unknown"),
|
||||
"author": embedded.get("author", [{}])[0].get("name", "Unknown"),
|
||||
"categories": post.get("categories", []),
|
||||
"tags": post.get("tags", []),
|
||||
"link": post.get("link", ""),
|
||||
"featured_media": post.get("featured_media", 0) or 0,
|
||||
"featured_media_url": featured_media_url,
|
||||
"word_count": _count_words(post.get("content", {}).get("rendered", "")),
|
||||
}
|
||||
|
||||
# Filter to requested fields only
|
||||
# Strict allow-list filtering when ``fields`` is provided.
|
||||
if fields:
|
||||
requested = {f.strip().lower() for f in fields.split(",")}
|
||||
# Always include id
|
||||
requested = {f.strip().lower() for f in fields.split(",") if f.strip()}
|
||||
requested.add("id")
|
||||
result = {k: v for k, v in full_result.items() if k in requested}
|
||||
else:
|
||||
|
||||
@@ -255,6 +255,18 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": (
|
||||
"Optional. Pass the global attribute "
|
||||
"id from list_product_attributes to "
|
||||
"link this product to an existing "
|
||||
"global attribute (e.g. pa_color). "
|
||||
"Omit (or set 0) to create an inline "
|
||||
"custom attribute on this product only."
|
||||
),
|
||||
},
|
||||
"name": {"type": "string"},
|
||||
"options": {"type": "array", "items": {"type": "string"}},
|
||||
"visible": {"type": "boolean"},
|
||||
@@ -264,7 +276,12 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
},
|
||||
{"type": "null"},
|
||||
],
|
||||
"description": 'Product attributes for variable products (e.g., [{"name": "Color", "options": ["Red", "Blue"], "variation": true}])',
|
||||
"description": (
|
||||
"Product attributes for variable products. "
|
||||
'Custom (per-product): [{"name": "Color", "options": ["Red", "Blue"], "variation": true}]. '
|
||||
'Global (linked, recommended): [{"id": 1, "options": ["Red", "Blue"], "variation": true}] — '
|
||||
"use list_product_attributes to find existing ids."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["name"],
|
||||
|
||||
220
plugins/wordpress/handlers/regenerate_thumbnails.py
Normal file
220
plugins/wordpress/handlers/regenerate_thumbnails.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""F.5a.8.2 — Regenerate attachment thumbnails via companion plugin.
|
||||
|
||||
Wraps ``POST /airano-mcp/v1/regenerate-thumbnails`` (companion plugin v2.8.0+).
|
||||
Rebuilds the registered WP image sub-sizes via ``wp_generate_attachment_metadata``
|
||||
after an upload, a format conversion (F.5a.8.1), or when new sizes are
|
||||
registered by the active theme. Two modes:
|
||||
|
||||
1. ``ids=[...]`` — regenerate a specific list (up to 50 per call).
|
||||
2. ``all=True`` — iterate over ``image/*`` attachments in ID order with
|
||||
``offset``/``limit`` paging.
|
||||
|
||||
Tool: ``wordpress_regenerate_thumbnails``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress.handlers._companion_hint import (
|
||||
companion_install_hint as _companion_install_hint,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("mcphub.wordpress.regenerate_thumbnails")
|
||||
|
||||
_MAX_IDS_PER_CALL = 50
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "regenerate_thumbnails",
|
||||
"method_name": "regenerate_thumbnails",
|
||||
"description": (
|
||||
"Rebuild attachment sub-sizes (the registered WP image sizes "
|
||||
"plus any from add_image_size() in the active theme). Use "
|
||||
"this after upload_media_from_url / _from_base64 with "
|
||||
"convert_to=webp|avif, after a theme switch that adds new "
|
||||
"sizes, or for legacy attachments missing thumbnails. Routes "
|
||||
"through the airano-mcp-bridge companion plugin v2.8.0+. "
|
||||
"Body shapes: either an 'ids' list to target specific "
|
||||
f"attachments (max {_MAX_IDS_PER_CALL} per call); or "
|
||||
"all=true with offset/limit for paged batch. Returns "
|
||||
"has_more + next_offset in batch mode so callers can continue."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ids": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": {"type": "integer", "minimum": 1},
|
||||
"maxItems": _MAX_IDS_PER_CALL,
|
||||
},
|
||||
{"type": "null"},
|
||||
],
|
||||
"description": (
|
||||
f"Attachment IDs (max {_MAX_IDS_PER_CALL}). "
|
||||
"Mutually exclusive with 'all'."
|
||||
),
|
||||
},
|
||||
"all": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": (
|
||||
"Batch mode: iterate image attachments in ID order. "
|
||||
"Use with offset/limit for pagination."
|
||||
),
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"default": 0,
|
||||
"description": "Batch offset (for 'all' mode).",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": _MAX_IDS_PER_CALL,
|
||||
"default": _MAX_IDS_PER_CALL,
|
||||
"description": (
|
||||
f"Max attachments per batch page (capped at "
|
||||
f"{_MAX_IDS_PER_CALL} server-side)."
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "write",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class RegenerateThumbnailsHandler:
|
||||
"""Rebuild attachment sub-sizes via the companion plugin."""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
async def regenerate_thumbnails(
|
||||
self,
|
||||
ids: list[int] | None = None,
|
||||
all: bool = False,
|
||||
offset: int = 0,
|
||||
limit: int = _MAX_IDS_PER_CALL,
|
||||
) -> str:
|
||||
"""Proxy to ``POST /airano-mcp/v1/regenerate-thumbnails``.
|
||||
|
||||
Exactly one of ``ids`` or ``all`` must be truthy. Input is validated
|
||||
client-side so obvious errors don't consume a round-trip.
|
||||
"""
|
||||
if not ids and not all:
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_request",
|
||||
"message": (
|
||||
"Provide either 'ids' (list of attachment IDs) or "
|
||||
"'all': true for batch mode."
|
||||
),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
if ids and all:
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_request",
|
||||
"message": "'ids' and 'all' are mutually exclusive.",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
body: dict[str, Any] = {}
|
||||
if ids:
|
||||
# Deduplicate + cap + coerce.
|
||||
uniq: list[int] = []
|
||||
seen: set[int] = set()
|
||||
for raw in ids:
|
||||
try:
|
||||
i = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if i > 0 and i not in seen:
|
||||
uniq.append(i)
|
||||
seen.add(i)
|
||||
body["ids"] = uniq[:_MAX_IDS_PER_CALL]
|
||||
if not body["ids"]:
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_request",
|
||||
"message": "'ids' must contain at least one positive integer.",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
else:
|
||||
body["all"] = True
|
||||
body["offset"] = max(0, int(offset))
|
||||
body["limit"] = min(_MAX_IDS_PER_CALL, max(1, int(limit)))
|
||||
|
||||
try:
|
||||
payload = await self.client.post(
|
||||
"airano-mcp/v1/regenerate-thumbnails",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("regenerate_thumbnails companion call failed: %s", exc)
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "companion_unreachable",
|
||||
"message": str(exc),
|
||||
"hint": (
|
||||
"Requires airano-mcp-bridge companion plugin v2.8.0+. "
|
||||
"Run wordpress_probe_capabilities to verify the route "
|
||||
"is advertised."
|
||||
),
|
||||
"install_hint": _companion_install_hint(
|
||||
min_version="2.8.0",
|
||||
required_capability="upload_files",
|
||||
route="airano-mcp/v1/regenerate-thumbnails",
|
||||
),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_response",
|
||||
"message": "companion returned a non-object payload",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"ok": bool(
|
||||
(payload.get("processed") or 0) > 0
|
||||
or (not payload.get("errors") and not payload.get("attempted"))
|
||||
),
|
||||
"mode": payload.get("mode"),
|
||||
"attempted": payload.get("attempted", 0),
|
||||
"processed": payload.get("processed", 0),
|
||||
"skipped": list(payload.get("skipped") or []),
|
||||
"errors": list(payload.get("errors") or []),
|
||||
"plugin_version": payload.get("plugin_version"),
|
||||
}
|
||||
if payload.get("mode") == "all":
|
||||
result["offset"] = payload.get("offset")
|
||||
result["limit"] = payload.get("limit")
|
||||
result["has_more"] = bool(payload.get("has_more"))
|
||||
result["next_offset"] = payload.get("next_offset")
|
||||
result["total"] = payload.get("total")
|
||||
return json.dumps(result, indent=2)
|
||||
@@ -188,7 +188,7 @@ class SEOHandler:
|
||||
# First, try to use the new health check endpoint (v1.1.0+)
|
||||
try:
|
||||
status_result = await self.client.get(
|
||||
"airano-mcp-seo-bridge/v1/status", use_custom_namespace=True
|
||||
"airano-mcp-bridge/v1/status", use_custom_namespace=True
|
||||
)
|
||||
|
||||
if status_result and isinstance(status_result, dict):
|
||||
@@ -234,12 +234,17 @@ class SEOHandler:
|
||||
first_item = result[0] if isinstance(result, list) and len(result) > 0 else {}
|
||||
meta = first_item.get("meta", {})
|
||||
|
||||
# Check for Rank Math fields
|
||||
# Check for Rank Math fields. Note: Rank Math stores the
|
||||
# meta title under ``rank_math_title`` (NOT
|
||||
# ``rank_math_seo_title``). The SEO API Bridge companion
|
||||
# plugin reads/writes the canonical key; this detection
|
||||
# must match or the plugin goes undetected even when
|
||||
# active.
|
||||
rank_math_active = any(
|
||||
key in meta
|
||||
for key in [
|
||||
"rank_math_focus_keyword",
|
||||
"rank_math_seo_title",
|
||||
"rank_math_title",
|
||||
"rank_math_description",
|
||||
]
|
||||
)
|
||||
@@ -312,7 +317,12 @@ class SEOHandler:
|
||||
seo_data.update(
|
||||
{
|
||||
"focus_keyword": meta.get("rank_math_focus_keyword", ""),
|
||||
"seo_title": meta.get("rank_math_seo_title", ""),
|
||||
# F.X.fix #2: Rank Math's canonical meta-title key
|
||||
# is ``rank_math_title``. The previous reader
|
||||
# used ``rank_math_seo_title`` which does not
|
||||
# exist in the Rank Math schema, so seo_title
|
||||
# was always blank.
|
||||
"seo_title": meta.get("rank_math_title", ""),
|
||||
"meta_description": meta.get("rank_math_description", ""),
|
||||
"additional_keywords": meta.get("rank_math_additional_keywords", ""),
|
||||
"canonical_url": meta.get("rank_math_canonical_url", ""),
|
||||
@@ -383,7 +393,7 @@ class SEOHandler:
|
||||
try:
|
||||
# Use SEO API Bridge endpoint for products (same as update_product_seo)
|
||||
result = await self.client.get(
|
||||
f"airano-mcp-seo-bridge/v1/products/{product_id}/seo", use_custom_namespace=True
|
||||
f"airano-mcp-bridge/v1/products/{product_id}/seo", use_custom_namespace=True
|
||||
)
|
||||
|
||||
return json.dumps(result, indent=2)
|
||||
@@ -452,11 +462,14 @@ class SEOHandler:
|
||||
meta = {}
|
||||
|
||||
if seo_check.get("rank_math", {}).get("active"):
|
||||
# Use Rank Math field names
|
||||
# Use Rank Math field names. F.X.fix #2: canonical meta
|
||||
# title key is ``rank_math_title`` (NOT
|
||||
# ``rank_math_seo_title``) — the latter was silently
|
||||
# written to an unread key so seo_title never appeared.
|
||||
if focus_keyword is not None:
|
||||
meta["rank_math_focus_keyword"] = focus_keyword
|
||||
if seo_title is not None:
|
||||
meta["rank_math_seo_title"] = seo_title
|
||||
meta["rank_math_title"] = seo_title
|
||||
if meta_description is not None:
|
||||
meta["rank_math_description"] = meta_description
|
||||
if additional_keywords is not None:
|
||||
@@ -592,7 +605,7 @@ class SEOHandler:
|
||||
|
||||
# Use SEO API Bridge endpoint for products
|
||||
await self.client.post(
|
||||
f"airano-mcp-seo-bridge/v1/products/{product_id}/seo",
|
||||
f"airano-mcp-bridge/v1/products/{product_id}/seo",
|
||||
json_data=data,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
101
plugins/wordpress/handlers/site_health.py
Normal file
101
plugins/wordpress/handlers/site_health.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""F.18.6 — Unified site-health snapshot via companion plugin.
|
||||
|
||||
Wraps ``GET /airano-mcp/v1/site-health`` (companion plugin v2.6.0+).
|
||||
Returns WordPress / PHP / server / database / plugins / theme /
|
||||
writability info in a single JSON envelope. Replaces the existing
|
||||
``get_site_health`` (which called the stock ``/wp-site-health`` REST
|
||||
endpoints) with a richer single-round-trip snapshot.
|
||||
|
||||
The legacy ``wordpress_get_site_health`` tool (site.py) is left intact;
|
||||
this adds a new ``wordpress_site_health`` tool that prefers the
|
||||
companion and gracefully reports ``companion_available: false`` when
|
||||
the plugin is missing or outdated.
|
||||
|
||||
Tool: ``wordpress_site_health()``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress.handlers._companion_hint import (
|
||||
companion_install_hint as _companion_install_hint,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("mcphub.wordpress.site_health")
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "site_health",
|
||||
"method_name": "site_health",
|
||||
"description": (
|
||||
"Unified site-health snapshot via the airano-mcp-bridge "
|
||||
"companion plugin (v2.6.0+). Single request returns WP + PHP + "
|
||||
"MySQL versions, loaded PHP extensions, server software + disk "
|
||||
"free, active plugins with versions, active theme, and "
|
||||
"writability checks. Falls back to `companion_available: false` "
|
||||
"when the plugin is missing. Requires manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class SiteHealthHandler:
|
||||
"""Unified site-health snapshot via companion plugin."""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
async def site_health(self) -> str:
|
||||
try:
|
||||
payload = await self.client.get(
|
||||
"airano-mcp/v1/site-health",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("site_health companion call failed: %s", exc)
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"companion_available": False,
|
||||
"error": "companion_unreachable",
|
||||
"message": str(exc),
|
||||
"hint": (
|
||||
"Requires airano-mcp-bridge companion plugin v2.6.0+ "
|
||||
"and manage_options capability. Run "
|
||||
"wordpress_probe_capabilities to verify. For the legacy "
|
||||
"path use wordpress_get_site_health (stock WP endpoints)."
|
||||
),
|
||||
"install_hint": _companion_install_hint(
|
||||
min_version="2.6.0",
|
||||
required_capability="manage_options",
|
||||
route="airano-mcp/v1/site-health",
|
||||
),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"companion_available": False,
|
||||
"error": "invalid_response",
|
||||
"message": "companion returned a non-object payload",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
result = {
|
||||
"ok": bool(payload.get("ok", True)),
|
||||
"companion_available": True,
|
||||
**payload,
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
184
plugins/wordpress/handlers/transient_flush.py
Normal file
184
plugins/wordpress/handlers/transient_flush.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""F.18.5 — Transient flush via companion plugin.
|
||||
|
||||
Wraps ``POST /airano-mcp/v1/transient-flush`` (companion plugin v2.5.0+).
|
||||
Native PHP transient cleanup. Scopes:
|
||||
|
||||
- ``expired`` (default): runs ``delete_expired_transients()``; reports
|
||||
the number of expired rows removed.
|
||||
- ``all``: deletes every ``_transient_%`` row (both regular and
|
||||
optionally site transients on multisite).
|
||||
- ``pattern``: shell-glob match, e.g. ``rank_math_*``.
|
||||
|
||||
Tool: ``wordpress_transient_flush(scope="expired", pattern=None, ...)``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress.handlers._companion_hint import (
|
||||
companion_install_hint as _companion_install_hint,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("mcphub.wordpress.transient_flush")
|
||||
|
||||
_VALID_SCOPES = ("expired", "all", "pattern")
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "transient_flush",
|
||||
"method_name": "transient_flush",
|
||||
"description": (
|
||||
"Flush WordPress transients via the airano-mcp-bridge "
|
||||
"companion plugin (v2.5.0+). Scopes: 'expired' (default, "
|
||||
"delete_expired_transients), 'all' (every transient), "
|
||||
"'pattern' (shell glob like 'rank_math_*'). On multisite, "
|
||||
"`include_site_transients=true` also purges "
|
||||
"_site_transient_* rows. Requires manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"enum": list(_VALID_SCOPES),
|
||||
"description": "Default 'expired'.",
|
||||
},
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Shell-style glob (e.g. 'rank_math_*'). Required "
|
||||
"when scope='pattern'."
|
||||
),
|
||||
},
|
||||
"include_site_transients": {
|
||||
"type": "boolean",
|
||||
"description": "Default true. Only relevant on multisite.",
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "admin",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _validate(scope: str | None, pattern: str | None) -> dict[str, Any] | None:
|
||||
if scope is None:
|
||||
scope = "expired"
|
||||
if scope not in _VALID_SCOPES:
|
||||
return {
|
||||
"error": "invalid_scope",
|
||||
"message": (f"scope must be one of {list(_VALID_SCOPES)}; got {scope!r}."),
|
||||
}
|
||||
if scope == "pattern" and not pattern:
|
||||
return {
|
||||
"error": "pattern_required",
|
||||
"message": "pattern is required when scope='pattern'.",
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
class TransientFlushHandler:
|
||||
"""Native PHP transient cleanup via companion plugin."""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
async def transient_flush(
|
||||
self,
|
||||
scope: str | None = "expired",
|
||||
pattern: str | None = None,
|
||||
include_site_transients: Any = True,
|
||||
) -> str:
|
||||
err = _validate(scope, pattern)
|
||||
if err is not None:
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
**err,
|
||||
"scope": scope,
|
||||
"pattern": pattern,
|
||||
"deleted_count": 0,
|
||||
"deleted_sample": [],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
# Normalise include_site_transients to a real bool for the PHP side;
|
||||
# WP's parser is lenient but the aiohttp JSON filter elsewhere in
|
||||
# the client may drop "false"-y strings.
|
||||
include_flag: bool
|
||||
if isinstance(include_site_transients, bool):
|
||||
include_flag = include_site_transients
|
||||
elif isinstance(include_site_transients, (int, float)):
|
||||
include_flag = bool(include_site_transients)
|
||||
elif isinstance(include_site_transients, str):
|
||||
include_flag = include_site_transients.strip().lower() not in {
|
||||
"false",
|
||||
"0",
|
||||
"no",
|
||||
"off",
|
||||
"",
|
||||
}
|
||||
else:
|
||||
include_flag = True
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"scope": scope or "expired",
|
||||
"include_site_transients": include_flag,
|
||||
}
|
||||
if scope == "pattern":
|
||||
body["pattern"] = pattern
|
||||
|
||||
try:
|
||||
payload = await self.client.post(
|
||||
"airano-mcp/v1/transient-flush",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("transient_flush companion call failed: %s", exc)
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "companion_unreachable",
|
||||
"message": str(exc),
|
||||
"hint": (
|
||||
"Requires airano-mcp-bridge companion plugin v2.5.0+ "
|
||||
"and manage_options capability. Run "
|
||||
"wordpress_probe_capabilities to verify."
|
||||
),
|
||||
"install_hint": _companion_install_hint(
|
||||
min_version="2.5.0",
|
||||
required_capability="manage_options",
|
||||
route="airano-mcp/v1/transient-flush",
|
||||
),
|
||||
"scope": scope,
|
||||
"pattern": pattern,
|
||||
"deleted_count": 0,
|
||||
"deleted_sample": [],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_response",
|
||||
"message": "companion returned a non-object payload",
|
||||
"scope": scope,
|
||||
"pattern": pattern,
|
||||
"deleted_count": 0,
|
||||
"deleted_sample": [],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
result = {"ok": bool(payload.get("ok", True)), **payload}
|
||||
return json.dumps(result, indent=2)
|
||||
@@ -65,7 +65,40 @@ class WordPressPlugin(BasePlugin):
|
||||
|
||||
# Initialize core WordPress handlers
|
||||
self.posts = handlers.PostsHandler(self.client)
|
||||
self.media = handlers.MediaHandler(self.client)
|
||||
self.media = handlers.MediaHandler(self.client, user_id=config.get("user_id"))
|
||||
# F.5a.4 / F.5a.9.x: AI image handler receives user_id AND site_id.
|
||||
# site_id is the primary input for resolving per-site provider API
|
||||
# keys; user_id is retained for audit / rate-limit context and for
|
||||
# the admin/env-fallback path when no site is known.
|
||||
self.ai_media = handlers.AIMediaHandler(
|
||||
self.client,
|
||||
user_id=config.get("user_id"),
|
||||
site_id=config.get("site_id"),
|
||||
)
|
||||
# F.5a.5: chunked media uploads (session store + disk spill)
|
||||
self.media_chunked = handlers.MediaChunkedHandler(
|
||||
self.client, user_id=config.get("user_id")
|
||||
)
|
||||
# F.5a.6.3: probe WP upload limits (24h cache)
|
||||
self.media_probe = handlers.ProbeHandler(self.client)
|
||||
# F.18.1: probe companion-plugin capabilities (24h cache)
|
||||
self.capabilities = handlers.CapabilitiesHandler(self.client)
|
||||
# F.18.2: batch post_meta writes via companion plugin
|
||||
self.bulk_meta = handlers.BulkMetaHandler(self.client)
|
||||
# F.18.3: structured JSON export via companion plugin
|
||||
self.export = handlers.ExportHandler(self.client)
|
||||
# F.18.4: cache purge via companion plugin
|
||||
self.cache_purge_handler = handlers.CachePurgeHandler(self.client)
|
||||
# F.18.5: native transient flush via companion plugin
|
||||
self.transient_flush_handler = handlers.TransientFlushHandler(self.client)
|
||||
# F.18.6: unified site-health snapshot via companion plugin
|
||||
self.site_health_handler = handlers.SiteHealthHandler(self.client)
|
||||
# F.18.7: audit-hook configuration + query
|
||||
self.audit_hook_handler = handlers.AuditHookHandler(self.client)
|
||||
# F.5a.8.2: regenerate attachment sub-sizes via companion plugin
|
||||
self.regenerate_thumbnails_handler = handlers.RegenerateThumbnailsHandler(self.client)
|
||||
# F.5a.8.3: bulk delete / reassign attachments via stock WP REST
|
||||
self.media_bulk = handlers.MediaBulkHandler(self.client)
|
||||
self.taxonomy = handlers.TaxonomyHandler(self.client)
|
||||
self.comments = handlers.CommentsHandler(self.client)
|
||||
self.users = handlers.UsersHandler(self.client)
|
||||
@@ -103,7 +136,19 @@ class WordPressPlugin(BasePlugin):
|
||||
|
||||
# Core WordPress handlers
|
||||
specs.extend(handlers.get_posts_specs()) # 13 tools
|
||||
specs.extend(handlers.get_media_specs()) # 5 tools
|
||||
specs.extend(handlers.get_media_specs()) # 6 tools (F.5a.1: added base64 upload)
|
||||
specs.extend(handlers.get_ai_media_specs()) # 1 tool (F.5a.4: generate+upload)
|
||||
specs.extend(handlers.get_media_chunked_specs()) # 5 tools (F.5a.5 + F.5a.8.4 status)
|
||||
specs.extend(handlers.get_media_probe_specs()) # 1 tool (F.5a.6.3: probe limits)
|
||||
specs.extend(handlers.get_capabilities_specs()) # 1 tool (F.18.1: probe capabilities)
|
||||
specs.extend(handlers.get_bulk_meta_specs()) # 1 tool (F.18.2: bulk meta write)
|
||||
specs.extend(handlers.get_export_specs()) # 1 tool (F.18.3: structured export)
|
||||
specs.extend(handlers.get_cache_purge_specs()) # 1 tool (F.18.4: cache purge)
|
||||
specs.extend(handlers.get_transient_flush_specs()) # 1 tool (F.18.5: transient flush)
|
||||
specs.extend(handlers.get_site_health_specs()) # 1 tool (F.18.6: site health)
|
||||
specs.extend(handlers.get_audit_hook_specs()) # 3 tools (F.18.7: audit hook)
|
||||
specs.extend(handlers.get_regenerate_thumbnails_specs()) # 1 tool (F.5a.8.2)
|
||||
specs.extend(handlers.get_media_bulk_specs()) # 2 tools (F.5a.8.3: bulk delete+reassign)
|
||||
specs.extend(handlers.get_taxonomy_specs()) # 11 tools
|
||||
specs.extend(handlers.get_comments_specs()) # 5 tools
|
||||
specs.extend(handlers.get_users_specs()) # 2 tools
|
||||
@@ -128,6 +173,68 @@ class WordPressPlugin(BasePlugin):
|
||||
"""
|
||||
return await self.site.health_check()
|
||||
|
||||
async def probe_credential_capabilities(self) -> dict[str, Any]:
|
||||
"""F.7e — return the capabilities the saved app_password grants.
|
||||
|
||||
Delegates to the companion-plugin-backed ``CapabilitiesHandler``
|
||||
(F.18.1), which reads ``GET /airano-mcp/v1/capabilities`` — the
|
||||
WordPress user's effective capability map. When the companion
|
||||
isn't installed we return an empty-granted payload marked as
|
||||
unavailable so the caller can show a "companion plugin needed"
|
||||
hint rather than falsely claim the key is under-privileged.
|
||||
|
||||
F.X.fix #3 — fast-fail on unreachable sites. When the low-level
|
||||
client raises :class:`SiteUnreachableError` (DNS/TCP/timeout),
|
||||
we short-circuit to a structured ``probe_available=False``
|
||||
payload carrying the ``install_hint`` so the dashboard renders
|
||||
the "check your URL / install companion" prompt in <10s instead
|
||||
of hanging on the 30s total timeout.
|
||||
"""
|
||||
from plugins.wordpress.client import SiteUnreachableError
|
||||
from plugins.wordpress.handlers.capabilities import _empty_capabilities_payload
|
||||
|
||||
try:
|
||||
payload = await self.capabilities._fetch_capabilities()
|
||||
except SiteUnreachableError as exc:
|
||||
out: dict[str, Any] = {
|
||||
"probe_available": False,
|
||||
"granted": [],
|
||||
"source": "wordpress_companion",
|
||||
"reason": f"site_unreachable: {exc.reason}",
|
||||
}
|
||||
if exc.install_hint:
|
||||
out["install_hint"] = exc.install_hint
|
||||
return out
|
||||
except Exception as exc: # noqa: BLE001
|
||||
payload = _empty_capabilities_payload(
|
||||
self.client.site_url, reason=f"probe_failed: {exc}"
|
||||
)
|
||||
|
||||
if not payload.get("companion_available"):
|
||||
return {
|
||||
"probe_available": False,
|
||||
"granted": [],
|
||||
"source": "wordpress_companion",
|
||||
"reason": (payload.get("reason") or "companion_not_installed"),
|
||||
}
|
||||
|
||||
user_caps = (payload.get("user") or {}).get("capabilities") or {}
|
||||
granted = sorted(k for k, v in user_caps.items() if v)
|
||||
roles = list((payload.get("user") or {}).get("roles") or [])
|
||||
# F.X.fix-pass3 — surface routes + features so the central
|
||||
# tool-prerequisites resolver in core/tool_access can decide
|
||||
# which tools to auto-disable (SEO needs Rank Math/Yoast,
|
||||
# cache_purge/etc. need the matching companion route, …).
|
||||
return {
|
||||
"probe_available": True,
|
||||
"granted": granted,
|
||||
"source": "wordpress_companion",
|
||||
"roles": roles,
|
||||
"plugin_version": payload.get("plugin_version"),
|
||||
"routes": dict(payload.get("routes") or {}),
|
||||
"features": dict(payload.get("features") or {}),
|
||||
}
|
||||
|
||||
# ========================================
|
||||
# Method Delegation to Handlers
|
||||
# ========================================
|
||||
@@ -187,12 +294,84 @@ class WordPressPlugin(BasePlugin):
|
||||
async def upload_media_from_url(self, **kwargs):
|
||||
return await self.media.upload_media_from_url(**kwargs)
|
||||
|
||||
async def upload_media_from_base64(self, **kwargs):
|
||||
return await self.media.upload_media_from_base64(**kwargs)
|
||||
|
||||
async def update_media(self, **kwargs):
|
||||
return await self.media.update_media(**kwargs)
|
||||
|
||||
async def delete_media(self, **kwargs):
|
||||
return await self.media.delete_media(**kwargs)
|
||||
|
||||
# === AI Image Generation (F.5a.4) ===
|
||||
async def generate_and_upload_image(self, **kwargs):
|
||||
return await self.ai_media.generate_and_upload_image(**kwargs)
|
||||
|
||||
# === Chunked Media Upload (F.5a.5) ===
|
||||
async def upload_media_chunked_start(self, **kwargs):
|
||||
return await self.media_chunked.upload_media_chunked_start(**kwargs)
|
||||
|
||||
async def upload_media_chunked_chunk(self, **kwargs):
|
||||
return await self.media_chunked.upload_media_chunked_chunk(**kwargs)
|
||||
|
||||
async def upload_media_chunked_finish(self, **kwargs):
|
||||
return await self.media_chunked.upload_media_chunked_finish(**kwargs)
|
||||
|
||||
async def upload_media_chunked_abort(self, **kwargs):
|
||||
return await self.media_chunked.upload_media_chunked_abort(**kwargs)
|
||||
|
||||
async def upload_media_chunked_status(self, **kwargs):
|
||||
return await self.media_chunked.upload_media_chunked_status(**kwargs)
|
||||
|
||||
# === Probe Upload Limits (F.5a.6.3) ===
|
||||
async def probe_upload_limits(self, **kwargs):
|
||||
return await self.media_probe.probe_upload_limits(**kwargs)
|
||||
|
||||
# === Probe Companion Capabilities (F.18.1) ===
|
||||
async def probe_capabilities(self, **kwargs):
|
||||
return await self.capabilities.probe_capabilities(**kwargs)
|
||||
|
||||
# === Bulk Meta Write (F.18.2) ===
|
||||
async def bulk_update_meta(self, **kwargs):
|
||||
return await self.bulk_meta.bulk_update_meta(**kwargs)
|
||||
|
||||
# === Structured Export (F.18.3) ===
|
||||
async def export_content(self, **kwargs):
|
||||
return await self.export.export_content(**kwargs)
|
||||
|
||||
# === Cache Purge (F.18.4) ===
|
||||
async def cache_purge(self, **kwargs):
|
||||
return await self.cache_purge_handler.cache_purge(**kwargs)
|
||||
|
||||
# === Regenerate Thumbnails (F.5a.8.2) ===
|
||||
async def regenerate_thumbnails(self, **kwargs):
|
||||
return await self.regenerate_thumbnails_handler.regenerate_thumbnails(**kwargs)
|
||||
|
||||
# === Bulk Media Operations (F.5a.8.3) ===
|
||||
async def bulk_delete_media(self, **kwargs):
|
||||
return await self.media_bulk.bulk_delete_media(**kwargs)
|
||||
|
||||
async def bulk_reassign_media(self, **kwargs):
|
||||
return await self.media_bulk.bulk_reassign_media(**kwargs)
|
||||
|
||||
# === Transient Flush (F.18.5) ===
|
||||
async def transient_flush(self, **kwargs):
|
||||
return await self.transient_flush_handler.transient_flush(**kwargs)
|
||||
|
||||
# === Unified Site Health (F.18.6) ===
|
||||
async def site_health(self, **kwargs):
|
||||
return await self.site_health_handler.site_health(**kwargs)
|
||||
|
||||
# === Audit Hook (F.18.7) ===
|
||||
async def audit_hook_status(self, **kwargs):
|
||||
return await self.audit_hook_handler.audit_hook_status(**kwargs)
|
||||
|
||||
async def audit_hook_configure(self, **kwargs):
|
||||
return await self.audit_hook_handler.audit_hook_configure(**kwargs)
|
||||
|
||||
async def audit_hook_disable(self, **kwargs):
|
||||
return await self.audit_hook_handler.audit_hook_disable(**kwargs)
|
||||
|
||||
# === Taxonomy (Categories & Tags) ===
|
||||
async def list_categories(self, **kwargs):
|
||||
return await self.taxonomy.list_categories(**kwargs)
|
||||
|
||||
Reference in New Issue
Block a user