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