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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user