222ad10ded
- package.py: addon manifest (v0.1.0) - server/__init__.py: BaseServerAddon definition - server/ayon_matrix_publish/plugins/publish/integrate_matrix.py: Pyblish plugin that uploads published representations to Matrix rooms during the Integration stage. Supports: - Upload to Matrix content repository (mxc:// URI) - Formatted messages with preview images - Auto room creation per asset - Configurable via Studio Settings or env vars - create_package.py: builds distributable .zip package
526 lines
18 KiB
Python
526 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Publish plugin: upload published representations to Matrix rooms.
|
|
|
|
This plugin runs during the Integration stage of the AYON publish pipeline.
|
|
For each published representation, it uploads the file to the Matrix content
|
|
repository and posts a formatted message to a configured room.
|
|
|
|
Architecture:
|
|
Ayon Publish: Collect → Validate → Extract → Integrate
|
|
│
|
|
▼
|
|
IntegrateMatrix (this plugin)
|
|
│
|
|
┌─────────┴──────────┐
|
|
│ MatrixUploader │
|
|
│ • upload_media() │
|
|
│ • send_message() │
|
|
│ • ensure_room() │
|
|
└────────────────────┘
|
|
│
|
|
▼
|
|
Matrix Homeserver API
|
|
(Synapse / Dendrite)
|
|
"""
|
|
|
|
import os
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import pyblish.api
|
|
|
|
from ayon_core.pipeline.publish import (
|
|
AYONPyblishPluginMixin,
|
|
PublishError,
|
|
)
|
|
from ayon_core.pipeline import publish
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class MatrixUploader:
|
|
"""Thin wrapper around Matrix HTTP API for uploads and messaging.
|
|
|
|
Uses raw HTTP requests (no async needed in Pyblish context) instead of
|
|
the mautrix async SDK. Pyblish plugins run synchronously in the host
|
|
process, so we use the REST API directly.
|
|
|
|
Matrix API reference:
|
|
https://spec.matrix.org/latest/client-server-api/
|
|
|
|
Endpoints used:
|
|
POST /_matrix/media/v3/upload → mxc:// URI
|
|
PUT /_matrix/client/v3/rooms/{id}/send/m.room.message/{txn}
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
homeserver_url: str,
|
|
access_token: str,
|
|
):
|
|
self.homeserver = homeserver_url.rstrip("/")
|
|
self.token = access_token
|
|
|
|
def upload_media(self, file_path: str) -> str:
|
|
"""Upload a file to the Matrix content repository.
|
|
|
|
Args:
|
|
file_path: Local path to the file to upload.
|
|
|
|
Returns:
|
|
mxc:// URI of the uploaded content.
|
|
|
|
Raises:
|
|
PublishError: If upload fails.
|
|
"""
|
|
import urllib.request
|
|
import urllib.error
|
|
import json
|
|
|
|
url = f"{self.homeserver}/_matrix/media/v3/upload"
|
|
file_name = os.path.basename(file_path)
|
|
|
|
# Determine Content-Type from extension
|
|
content_type = self._guess_mime(file_path)
|
|
|
|
with open(file_path, "rb") as f:
|
|
data = f.read()
|
|
|
|
boundary = "---MatrixUploaderBoundary"
|
|
body = (
|
|
f"--{boundary}\r\n"
|
|
f'Content-Disposition: form-data; name="file"; filename="{file_name}"\r\n'
|
|
f"Content-Type: {content_type}\r\n\r\n"
|
|
).encode("utf-8")
|
|
body += data
|
|
body += f"\r\n--{boundary}--\r\n".encode("utf-8")
|
|
|
|
req = urllib.request.Request(
|
|
url,
|
|
data=data, # Matrix upload accepts raw binary
|
|
headers={
|
|
"Authorization": f"Bearer {self.token}",
|
|
"Content-Type": content_type,
|
|
},
|
|
method="POST",
|
|
)
|
|
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
result = json.loads(resp.read().decode())
|
|
content_uri = result.get("content_uri")
|
|
if not content_uri:
|
|
raise PublishError(
|
|
f"Matrix upload: no content_uri in response: {result}"
|
|
)
|
|
log.info(
|
|
"Uploaded %s → %s (%d bytes)",
|
|
file_name, content_uri, len(data),
|
|
)
|
|
return content_uri
|
|
except urllib.error.HTTPError as e:
|
|
body = e.read().decode(errors="replace")
|
|
raise PublishError(
|
|
f"Matrix upload failed (HTTP {e.code}): {body[:500]}"
|
|
) from e
|
|
except Exception as e:
|
|
raise PublishError(f"Matrix upload failed: {e}") from e
|
|
|
|
def send_message(
|
|
self,
|
|
room_id: str,
|
|
body: str,
|
|
formatted_body: Optional[str] = None,
|
|
msgtype: str = "m.text",
|
|
url: Optional[str] = None,
|
|
media_info: Optional[dict] = None,
|
|
) -> str:
|
|
"""Send a message to a Matrix room.
|
|
|
|
Args:
|
|
room_id: Target room ID (!xxx:server).
|
|
body: Plain text fallback.
|
|
formatted_body: HTML formatted body (org.matrix.custom.html).
|
|
msgtype: Message type (m.text, m.image, m.notice).
|
|
url: mxc:// URI for image/file messages.
|
|
media_info: Dict with w, h, size, mimetype for media messages.
|
|
|
|
Returns:
|
|
The event ID of the sent message.
|
|
|
|
Raises:
|
|
PublishError: If sending fails.
|
|
"""
|
|
import urllib.request
|
|
import urllib.error
|
|
import json
|
|
import uuid
|
|
|
|
txn_id = str(uuid.uuid4()).replace("-", "")
|
|
api_url = (
|
|
f"{self.homeserver}/_matrix/client/v3/rooms/{room_id}"
|
|
f"/send/m.room.message/{txn_id}"
|
|
)
|
|
|
|
content = {
|
|
"msgtype": msgtype,
|
|
"body": body,
|
|
}
|
|
|
|
if formatted_body:
|
|
content["format"] = "org.matrix.custom.html"
|
|
content["formatted_body"] = formatted_body
|
|
|
|
if url:
|
|
content["url"] = url
|
|
if media_info:
|
|
content["info"] = media_info
|
|
|
|
req = urllib.request.Request(
|
|
api_url,
|
|
data=json.dumps(content).encode("utf-8"),
|
|
headers={
|
|
"Authorization": f"Bearer {self.token}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
method="PUT",
|
|
)
|
|
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
result = json.loads(resp.read().decode())
|
|
event_id = result.get("event_id", "unknown")
|
|
log.info("Sent Matrix message → %s (event: %s)", room_id, event_id)
|
|
return event_id
|
|
except urllib.error.HTTPError as e:
|
|
body_text = e.read().decode(errors="replace")
|
|
raise PublishError(
|
|
f"Matrix send failed (HTTP {e.code}): {body_text[:500]}"
|
|
) from e
|
|
except Exception as e:
|
|
raise PublishError(f"Matrix send failed: {e}") from e
|
|
|
|
def ensure_room(self, room_alias: str, room_name: str) -> str:
|
|
"""Create a room if it doesn't exist, return room_id.
|
|
|
|
Args:
|
|
room_alias: Desired alias (#name:server).
|
|
room_name: Display name for the room.
|
|
|
|
Returns:
|
|
Room ID (!xxx:server).
|
|
"""
|
|
import urllib.request
|
|
import urllib.error
|
|
import json
|
|
|
|
# First, try to resolve the alias
|
|
resolve_url = f"{self.homeserver}/_matrix/client/v3/directory/room/{room_alias}"
|
|
req = urllib.request.Request(
|
|
resolve_url,
|
|
headers={"Authorization": f"Bearer {self.token}"},
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
data = json.loads(resp.read().decode())
|
|
return data["room_id"]
|
|
except urllib.error.HTTPError:
|
|
pass # Room doesn't exist yet
|
|
|
|
# Create the room
|
|
create_url = f"{self.homeserver}/_matrix/client/v3/createRoom"
|
|
body = {
|
|
"name": room_name,
|
|
"room_alias_name": room_alias.lstrip("#").split(":")[0],
|
|
"preset": "private_chat",
|
|
"visibility": "private",
|
|
"topic": f"AYON publish room for {room_name}",
|
|
}
|
|
req = urllib.request.Request(
|
|
create_url,
|
|
data=json.dumps(body).encode("utf-8"),
|
|
headers={
|
|
"Authorization": f"Bearer {self.token}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
data = json.loads(resp.read().decode())
|
|
room_id = data["room_id"]
|
|
log.info("Created Matrix room %s (%s)", room_id, room_name)
|
|
return room_id
|
|
except urllib.error.HTTPError as e:
|
|
body_text = e.read().decode(errors="replace")
|
|
raise PublishError(
|
|
f"Matrix create room failed: {body_text[:500]}"
|
|
) from e
|
|
|
|
@staticmethod
|
|
def _guess_mime(file_path: str) -> str:
|
|
"""Guess MIME type from file extension."""
|
|
ext = os.path.splitext(file_path)[1].lower()
|
|
mapping = {
|
|
".png": "image/png",
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".gif": "image/gif",
|
|
".webp": "image/webp",
|
|
".mp4": "video/mp4",
|
|
".mov": "video/quicktime",
|
|
".webm": "video/webm",
|
|
".pdf": "application/pdf",
|
|
".abc": "application/octet-stream",
|
|
".usd": "application/octet-stream",
|
|
".usda": "application/octet-stream",
|
|
".usdc": "application/octet-stream",
|
|
".fbx": "application/octet-stream",
|
|
".obj": "application/octet-stream",
|
|
".glb": "model/gltf-binary",
|
|
".gltf": "model/gltf+json",
|
|
".exr": "image/x-exr",
|
|
".tif": "image/tiff",
|
|
".tiff": "image/tiff",
|
|
".tga": "image/x-tga",
|
|
}
|
|
return mapping.get(ext, "application/octet-stream")
|
|
|
|
|
|
class IntegrateMatrix(
|
|
AYONPyblishPluginMixin,
|
|
pyblish.api.InstancePlugin,
|
|
):
|
|
"""Upload published representations to Matrix rooms.
|
|
|
|
Runs during Integration stage (order = IntegratorOrder + 0.1).
|
|
For each representation matching the configured filter:
|
|
1. Upload the file to the Matrix content repository
|
|
2. Post a formatted message with preview and metadata
|
|
|
|
Configuration (Studio Settings → Matrix Publish):
|
|
homeserver_url: Matrix homeserver URL
|
|
access_token: Bot user access token
|
|
room_id: Target room (or room pattern)
|
|
upload_representations: List of rep names to upload
|
|
message_template: Jinja2 template for the message
|
|
create_per_asset_rooms: Auto-create rooms per asset
|
|
"""
|
|
|
|
label = "Integrate Matrix"
|
|
order = pyblish.api.IntegratorOrder + 0.1
|
|
families = ["*"] # Run for all product types
|
|
targets = ["local"]
|
|
|
|
# Attribute definitions for the AYON publisher UI
|
|
# These allow artists to enable/disable Matrix upload per instance
|
|
enabled = True
|
|
|
|
def process(self, instance):
|
|
"""Main Pyblish process method.
|
|
|
|
Called once per publish instance during Integration stage.
|
|
"""
|
|
# Get attribute values from instance publish attributes
|
|
attr_values = self.get_attr_values_from_data(
|
|
instance.data
|
|
)
|
|
|
|
# Check if Matrix upload is enabled for this instance
|
|
if not attr_values.get("enabled", True):
|
|
log.debug("Matrix upload disabled for instance %s", instance.name)
|
|
return
|
|
|
|
# Get addon settings from AYON
|
|
settings = self._get_addon_settings(instance)
|
|
|
|
# Connect to Matrix
|
|
uploader = MatrixUploader(
|
|
homeserver_url=settings["homeserver_url"],
|
|
access_token=settings["access_token"],
|
|
)
|
|
|
|
# Determine target room
|
|
room_id = self._resolve_target_room(instance, uploader, settings)
|
|
|
|
# Get representations to upload
|
|
rep_names = settings.get("upload_representations", ["thumbnail", "preview"])
|
|
representations = instance.data.get("representations", [])
|
|
|
|
uploaded = {} # rep_name → mxc:// URI
|
|
for rep in representations:
|
|
rep_name = rep.get("name", "")
|
|
if rep_name not in rep_names:
|
|
continue
|
|
|
|
# Get the published file path
|
|
staging_dir = rep.get("stagingDir", "")
|
|
files = rep.get("files", [])
|
|
if not files:
|
|
continue
|
|
|
|
# Upload the first file (typically thumbnail/preview has one file)
|
|
file_path = os.path.join(staging_dir, files[0])
|
|
if not os.path.exists(file_path):
|
|
log.warning("File not found: %s", file_path)
|
|
continue
|
|
|
|
try:
|
|
mxc_url = uploader.upload_media(file_path)
|
|
uploaded[rep_name] = mxc_url
|
|
except PublishError:
|
|
log.warning(
|
|
"Failed to upload %s to Matrix", file_path, exc_info=True
|
|
)
|
|
# Don't fail the publish — Matrix is optional
|
|
continue
|
|
|
|
if not uploaded:
|
|
log.info("No representations to upload to Matrix for %s", instance.name)
|
|
return
|
|
|
|
# Build and send the message
|
|
message_body, message_html = self._build_message(
|
|
instance, uploaded, settings
|
|
)
|
|
|
|
if message_body:
|
|
try:
|
|
# If there's a thumbnail, send it as an image message
|
|
thumbnail_url = uploaded.get("thumbnail")
|
|
if thumbnail_url:
|
|
uploader.send_message(
|
|
room_id=room_id,
|
|
body=message_body,
|
|
formatted_body=message_html,
|
|
msgtype="m.image",
|
|
url=thumbnail_url,
|
|
media_info={"mimetype": "image/png"},
|
|
)
|
|
else:
|
|
uploader.send_message(
|
|
room_id=room_id,
|
|
body=message_body,
|
|
formatted_body=message_html,
|
|
)
|
|
except PublishError:
|
|
log.warning(
|
|
"Failed to send Matrix message for %s", instance.name,
|
|
exc_info=True,
|
|
)
|
|
|
|
# ── helper methods ──────────────────────────────────────────────
|
|
|
|
def _get_addon_settings(self, instance) -> dict:
|
|
"""Load Matrix Publish settings from AYON studio settings.
|
|
|
|
In production, this reads from:
|
|
instance.context.data["project_settings"]["matrix_publish"]
|
|
"""
|
|
# Fallback to environment variables for development
|
|
return {
|
|
"homeserver_url": os.environ.get(
|
|
"MATRIX_HOMESERVER", "https://matrix.niklashmotion.art"
|
|
),
|
|
"access_token": os.environ.get("MATRIX_ACCESS_TOKEN", ""),
|
|
"room_id": os.environ.get("MATRIX_PUBLISH_ROOM", ""),
|
|
"upload_representations": os.environ.get(
|
|
"MATRIX_UPLOAD_REPS", "thumbnail,preview"
|
|
).split(","),
|
|
"create_per_asset_rooms": os.environ.get(
|
|
"MATRIX_CREATE_ROOMS", "0"
|
|
)
|
|
== "1",
|
|
"message_template": os.environ.get(
|
|
"MATRIX_MESSAGE_TEMPLATE",
|
|
("**{{ product_type }}** published: "
|
|
"{{ asset }} / {{ task }} — v{{ version }}"),
|
|
),
|
|
}
|
|
|
|
def _resolve_target_room(
|
|
self, instance, uploader: MatrixUploader, settings: dict
|
|
) -> str:
|
|
"""Determine which Matrix room to send to."""
|
|
room_id = settings.get("room_id", "")
|
|
|
|
# Auto-create per-asset rooms if configured
|
|
if settings.get("create_per_asset_rooms"):
|
|
asset_name = instance.data.get("asset", "unknown")
|
|
alias = f"#ayon-{asset_name}:niklashmotion.art"
|
|
room_id = uploader.ensure_room(
|
|
alias, f"AYON: {asset_name}"
|
|
)
|
|
|
|
if not room_id:
|
|
raise PublishError(
|
|
"No Matrix room configured. Set room_id in Matrix Publish settings."
|
|
)
|
|
|
|
return room_id
|
|
|
|
def _build_message(
|
|
self, instance, uploaded: dict, settings: dict
|
|
) -> tuple:
|
|
"""Build plain-text and HTML message bodies.
|
|
|
|
Returns:
|
|
(plain_text, html) tuple or ("", "") if no message.
|
|
"""
|
|
from string import Template
|
|
|
|
template = settings.get(
|
|
"message_template",
|
|
"$product_type published: $asset / $task — v$version",
|
|
)
|
|
|
|
# Build template variables
|
|
context_data = instance.context.data if hasattr(instance, "context") else {}
|
|
vars_dict = {
|
|
"product_type": instance.data.get("family", "unknown"),
|
|
"product": instance.data.get("product", instance.data.get("name", "")),
|
|
"asset": instance.data.get("asset", ""),
|
|
"task": instance.data.get("task", ""),
|
|
"version": instance.data.get("version", 1),
|
|
"user": os.environ.get("AYON_USER", os.environ.get("USER", "")),
|
|
}
|
|
|
|
try:
|
|
body = Template(template).substitute(vars_dict)
|
|
except KeyError:
|
|
body = template
|
|
|
|
# Build HTML version
|
|
html = (
|
|
f"<p><strong>{vars_dict['product_type']}</strong> published: "
|
|
f"{vars_dict['asset']} / {vars_dict['task']} "
|
|
f"— v{vars_dict['version']}</p>"
|
|
)
|
|
|
|
# Add uploaded file list
|
|
if len(uploaded) > 1:
|
|
html += "<ul>"
|
|
for rep_name, mxc_url in uploaded.items():
|
|
html += (
|
|
f'<li>{rep_name}: <a href="{mxc_url}">{mxc_url}</a></li>'
|
|
)
|
|
html += "</ul>"
|
|
|
|
return body, html
|
|
|
|
# ── AYONPyblishPluginMixin ─────────────────────────────────────
|
|
|
|
@classmethod
|
|
def get_attribute_defs(cls):
|
|
"""Define per-instance attributes for the publisher UI."""
|
|
from ayon_core.lib import BoolDef
|
|
return [
|
|
BoolDef(
|
|
"enabled",
|
|
default=True,
|
|
label="Upload to Matrix",
|
|
),
|
|
]
|