Files
ayon-matrix-publish/server/ayon_matrix_publish/plugins/publish/integrate_matrix.py
T
Hermes 222ad10ded Initial commit: AYON Matrix Publish addon
- 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
2026-06-06 13:51:31 +00:00

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",
),
]