# -*- 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"

{vars_dict['product_type']} published: " f"{vars_dict['asset']} / {vars_dict['task']} " f"— v{vars_dict['version']}

" ) # Add uploaded file list if len(uploaded) > 1: html += "" 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", ), ]