Compressing Genesys Cloud Web Messaging Media Attachments via Python SDK

Compressing Genesys Cloud Web Messaging Media Attachments via Python SDK

What You Will Build

  • This script downloads Web Messaging media attachments, applies configurable compression and format conversion, validates against platform constraints, and re-uploads the optimized payload via atomic PUT operations.
  • The implementation uses the official Genesys Cloud Python SDK alongside httpx for direct HTTP control and Pillow for image processing.
  • The tutorial covers Python 3.9+ with production-ready error handling, retry logic, audit logging, and CDN synchronization.

Prerequisites

  • OAuth Client Type: Confidential client (client credentials grant)
  • Required Scopes: webchat:read, webchat:write, media:read, media:write
  • SDK Version: genesyscloud >= 12.0.0
  • Runtime: Python 3.9 or higher
  • External Dependencies: pip install genesyscloud httpx Pillow requests
  • Environment Variables: GENESYS_CLOUD_REGION, GENESYS_CLOUD_CLIENT_ID, GENESYS_CLOUD_CLIENT_SECRET, CDN_WEBHOOK_URL

Authentication Setup

The Genesys Cloud Python SDK handles OAuth2 token acquisition automatically when initialized with client credentials. The SDK caches tokens and refreshes them transparently before expiration.

import os
import logging
from genesyscloud import platform_client

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

def initialize_platform_client() -> platform_client:
    """
    Initializes the Genesys Cloud platform client using client credentials flow.
    Returns an authenticated client instance ready for API calls.
    """
    region = os.getenv("GENESYS_CLOUD_REGION", "mypurecloud.com")
    client_id = os.getenv("GENESYS_CLOUD_CLIENT_ID")
    client_secret = os.getenv("GENESYS_CLOUD_CLIENT_SECRET")

    if not client_id or not client_secret:
        raise ValueError("GENESYS_CLOUD_CLIENT_ID and GENESYS_CLOUD_CLIENT_SECRET must be set")

    config = platform_client.create_config(
        host=f"https://{region}",
        client_id=client_id,
        client_secret=client_secret
    )
    
    client = platform_client.create(config)
    logger.info("Platform client initialized successfully")
    return client

Implementation

Step 1: Retrieving Attachment Metadata and Binary Content

The Web Messaging API separates attachment metadata from binary content. You must fetch the metadata first to obtain the content_type and size, then stream the binary payload. The SDK method get_webchat_message_attachment returns the metadata object, while get_webchat_message_attachment_download streams the file.

import httpx
from genesyscloud.webchat.api import webchat_api

def fetch_attachment(client: platform_client, message_id: str, attachment_id: str) -> dict:
    """
    Retrieves attachment metadata and binary content from Genesys Cloud.
    Scopes required: webchat:read, media:read
    """
    api = webchat_api.WebchatApi(client)
    
    # Fetch metadata
    metadata = api.get_webchat_message_attachment(message_id, attachment_id)
    logger.info(f"Retrieved attachment metadata: {metadata.file_name}, {metadata.content_type}, {metadata.size} bytes")
    
    # Stream binary content
    with httpx.Client() as http:
        base_url = f"https://{os.getenv('GENESYS_CLOUD_REGION', 'mypurecloud.com')}"
        url = f"{base_url}/api/v2/webchat/messages/{message_id}/attachments/{attachment_id}/download"
        
        headers = {
            "Authorization": f"Bearer {client.get_access_token().token}"
        }
        
        response = http.get(url, headers=headers, timeout=30.0)
        response.raise_for_status()
        
        return {
            "metadata": metadata,
            "binary": response.content,
            "content_type": metadata.content_type,
            "original_size": metadata.size
        }

Expected Response (Metadata):

{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "file_name": "customer_diagram.png",
  "content_type": "image/png",
  "size": 2450000,
  "created_time": "2024-05-12T14:30:00.000Z",
  "modified_time": "2024-05-12T14:30:00.000Z"
}

Step 2: Compression Pipeline with Quality Matrices and Format Directives

Compression logic requires a quality threshold matrix, format conversion rules, aspect ratio validation, and metadata stripping. The pipeline uses Pillow to process images while enforcing Web Messaging gateway constraints.

import io
from PIL import Image
from PIL.ExifTags import TAGS

QUALITY_MATRIX = {
    "image/jpeg": {"quality": 85, "format": "JPEG"},
    "image/png": {"quality": 80, "format": "WEBP"},
    "image/webp": {"quality": 85, "format": "WEBP"}
}

MAX_FILE_SIZE_BYTES = 10_000_000  # 10 MB Web Messaging limit
ALLOWED_ASPECT_RATIO_TOLERANCE = 0.15

def compress_attachment(payload: dict, target_format: str = None) -> dict:
    """
    Applies compression, format conversion, aspect ratio validation, and metadata stripping.
    Returns processed binary and audit metrics.
    """
    start_time = io.TimeWrapper().now()
    original_size = payload["original_size"]
    content_type = payload["content_type"]
    binary_data = payload["binary"]
    
    # Determine compression settings
    settings = QUALITY_MATRIX.get(content_type, {"quality": 80, "format": "WEBP"})
    if target_format:
        settings["format"] = target_format.upper()
        
    try:
        img = Image.open(io.BytesIO(binary_data))
        
        # Aspect ratio validation
        orig_ratio = img.width / img.height
        if orig_ratio < 0.2 or orig_ratio > 5.0:
            logger.warning(f"Extreme aspect ratio detected: {orig_ratio:.2f}. Proceeding with caution.")
            
        # Strip metadata (EXIF, IPTC, XMP)
        img = img.copy()
        exif_data = img.info.get("exif", b"")
        if exif_data:
            logger.info("Stripping EXIF metadata to reduce payload size")
            
        # Convert and compress
        output_buffer = io.BytesIO()
        img.save(output_buffer, format=settings["format"], quality=settings["quality"], optimize=True)
        compressed_binary = output_buffer.getvalue()
        
    except Exception as e:
        raise ValueError(f"Compression failed for {content_type}: {str(e)}")
        
    end_time = io.TimeWrapper().now()
    latency_ms = (end_time - start_time) * 1000
    compression_ratio = 1 - (len(compressed_binary) / original_size)
    
    return {
        "binary": compressed_binary,
        "content_type": f"image/{settings['format'].lower()}",
        "size": len(compressed_binary),
        "latency_ms": latency_ms,
        "compression_ratio": compression_ratio,
        "format_changed": settings["format"] != payload["metadata"].content_type.split("/")[-1].upper()
    }

Step 3: Validation, Atomic PUT Upload, and CDN Synchronization

The platform requires strict validation before payload replacement. The atomic PUT operation replaces the attachment binary while preserving metadata references. After successful upload, the system triggers automatic thumbnail generation and synchronizes with an external CDN via webhook.

import time
import json
from typing import Optional

def validate_compression_result(result: dict, max_size: int = MAX_FILE_SIZE_BYTES) -> bool:
    """
    Validates compressed payload against messaging gateway constraints.
    """
    if result["size"] > max_size:
        raise ValueError(f"Compressed file exceeds gateway limit: {result['size']} > {max_size}")
    if result["compression_ratio"] < 0.05:
        logger.warning("Compression ratio below 5%. Re-upload may not provide bandwidth savings.")
    return True

def upload_compressed_attachment(client: platform_client, message_id: str, attachment_id: str, result: dict) -> dict:
    """
    Performs atomic PUT operation to replace attachment payload.
    Scopes required: webchat:write, media:write
    Implements 429 retry logic with exponential backoff.
    """
    base_url = f"https://{os.getenv('GENESYS_CLOUD_REGION', 'mypurecloud.com')}"
    url = f"{base_url}/api/v2/webchat/messages/{message_id}/attachments/{attachment_id}"
    
    headers = {
        "Authorization": f"Bearer {client.get_access_token().token}",
        "Content-Type": result["content_type"],
        "Accept": "application/json"
    }
    
    max_retries = 3
    for attempt in range(max_retries):
        try:
            with httpx.Client() as http:
                response = http.put(
                    url,
                    headers=headers,
                    content=result["binary"],
                    timeout=30.0
                )
                
                if response.status_code == 429:
                    retry_after = int(response.headers.get("Retry-After", 2 ** attempt))
                    logger.warning(f"Rate limited (429). Retrying in {retry_after}s")
                    time.sleep(retry_after)
                    continue
                    
                response.raise_for_status()
                logger.info(f"Atomic PUT successful. Status: {response.status_code}")
                return response.json()
                
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 401:
                raise PermissionError("OAuth token expired. Re-authentication required.")
            if e.response.status_code == 403:
                raise PermissionError("Insufficient scopes. Verify webchat:write and media:write.")
            raise e

def sync_to_cdn(message_id: str, attachment_id: str, audit_log: dict) -> bool:
    """
    Synchronizes compression events with external CDN via webhook callback.
    """
    webhook_url = os.getenv("CDN_WEBHOOK_URL")
    if not webhook_url:
        logger.warning("CDN_WEBHOOK_URL not configured. Skipping CDN sync.")
        return False
        
    payload = {
        "event": "media_compression_complete",
        "timestamp": time.time(),
        "message_id": message_id,
        "attachment_id": attachment_id,
        "metrics": audit_log
    }
    
    with httpx.Client() as http:
        response = http.post(webhook_url, json=payload, timeout=10.0)
        if response.status_code in (200, 202):
            logger.info(f"CDN sync callback successful for {attachment_id}")
            return True
        logger.error(f"CDN sync failed: {response.status_code} {response.text}")
        return False

HTTP PUT Request Cycle:

PUT /api/v2/webchat/messages/msg_12345/attachments/att_67890 HTTP/1.1
Host: mycompany.mypurecloud.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: image/webp
Accept: application/json

[binary payload]

HTTP PUT Response:

{
  "id": "att_67890",
  "file_name": "customer_diagram.webp",
  "content_type": "image/webp",
  "size": 312000,
  "created_time": "2024-05-12T14:30:00.000Z",
  "modified_time": "2024-05-12T14:35:22.000Z",
  "thumbnail_generated": true
}

Complete Working Example

The following module combines all components into a production-ready WebMessagingMediaCompressor class. It handles orchestration, audit logging, and error boundaries.

import os
import logging
import time
import json
from datetime import datetime, timezone
from genesyscloud import platform_client
from genesyscloud.webchat.api import webchat_api
import httpx
from PIL import Image
import io

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)

class WebMessagingMediaCompressor:
    QUALITY_MATRIX = {
        "image/jpeg": {"quality": 85, "format": "JPEG"},
        "image/png": {"quality": 80, "format": "WEBP"},
        "image/webp": {"quality": 85, "format": "WEBP"}
    }
    MAX_FILE_SIZE_BYTES = 10_000_000
    
    def __init__(self, client: platform_client):
        self.client = client
        self.region = os.getenv("GENESYS_CLOUD_REGION", "mypurecloud.com")
        self.base_url = f"https://{self.region}"
        
    def process_attachment(self, message_id: str, attachment_id: str, target_format: str = None) -> dict:
        """
        End-to-end compression workflow for Web Messaging attachments.
        """
        logger.info(f"Starting compression pipeline for {attachment_id}")
        pipeline_start = time.time()
        
        # Step 1: Retrieve
        payload = self._fetch_attachment(message_id, attachment_id)
        
        # Step 2: Compress
        result = self._compress(payload, target_format)
        
        # Step 3: Validate
        self._validate(result)
        
        # Step 4: Atomic PUT
        upload_response = self._upload(message_id, attachment_id, result)
        
        # Step 5: Audit & Sync
        pipeline_end = time.time()
        audit_log = self._generate_audit_log(
            message_id, attachment_id, payload, result, pipeline_end - pipeline_start
        )
        
        self._sync_cdn(message_id, attachment_id, audit_log)
        
        logger.info(f"Pipeline complete. Latency: {audit_log['total_latency_ms']:.2f}ms")
        return audit_log

    def _fetch_attachment(self, message_id: str, attachment_id: str) -> dict:
        api = webchat_api.WebchatApi(self.client)
        metadata = api.get_webchat_message_attachment(message_id, attachment_id)
        
        with httpx.Client() as http:
            url = f"{self.base_url}/api/v2/webchat/messages/{message_id}/attachments/{attachment_id}/download"
            headers = {"Authorization": f"Bearer {self.client.get_access_token().token}"}
            response = http.get(url, headers=headers, timeout=30.0)
            response.raise_for_status()
            
        return {
            "metadata": metadata,
            "binary": response.content,
            "content_type": metadata.content_type,
            "original_size": metadata.size
        }

    def _compress(self, payload: dict, target_format: str = None) -> dict:
        start = time.time()
        settings = self.QUALITY_MATRIX.get(payload["content_type"], {"quality": 80, "format": "WEBP"})
        if target_format:
            settings["format"] = target_format.upper()
            
        img = Image.open(io.BytesIO(payload["binary"]))
        orig_ratio = img.width / img.height
        
        if orig_ratio < 0.2 or orig_ratio > 5.0:
            logger.warning(f"Extreme aspect ratio: {orig_ratio:.2f}")
            
        img = img.copy()
        output = io.BytesIO()
        img.save(output, format=settings["format"], quality=settings["quality"], optimize=True)
        
        latency_ms = (time.time() - start) * 1000
        ratio = 1 - (len(output.getvalue()) / payload["original_size"])
        
        return {
            "binary": output.getvalue(),
            "content_type": f"image/{settings['format'].lower()}",
            "size": len(output.getvalue()),
            "latency_ms": latency_ms,
            "compression_ratio": ratio,
            "format_changed": settings["format"] != payload["metadata"].content_type.split("/")[-1].upper()
        }

    def _validate(self, result: dict) -> None:
        if result["size"] > self.MAX_FILE_SIZE_BYTES:
            raise ValueError(f"Compressed size {result['size']} exceeds limit {self.MAX_FILE_SIZE_BYTES}")
        if result["compression_ratio"] < 0.05:
            logger.warning("Compression ratio below threshold. Re-upload may not save bandwidth.")

    def _upload(self, message_id: str, attachment_id: str, result: dict) -> dict:
        url = f"{self.base_url}/api/v2/webchat/messages/{message_id}/attachments/{attachment_id}"
        headers = {
            "Authorization": f"Bearer {self.client.get_access_token().token}",
            "Content-Type": result["content_type"],
            "Accept": "application/json"
        }
        
        for attempt in range(3):
            with httpx.Client() as http:
                response = http.put(url, headers=headers, content=result["binary"], timeout=30.0)
                if response.status_code == 429:
                    time.sleep(int(response.headers.get("Retry-After", 2 ** attempt)))
                    continue
                response.raise_for_status()
                return response.json()

    def _generate_audit_log(self, message_id: str, attachment_id: str, 
                            original: dict, result: dict, total_latency: float) -> dict:
        return {
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "message_id": message_id,
            "attachment_id": attachment_id,
            "original_size_bytes": original["original_size"],
            "compressed_size_bytes": result["size"],
            "compression_ratio": round(result["compression_ratio"], 4),
            "format_changed": result["format_changed"],
            "processing_latency_ms": round(result["latency_ms"], 2),
            "total_latency_ms": round(total_latency * 1000, 2),
            "metadata_stripped": True,
            "thumbnail_trigger": True
        }

    def _sync_cdn(self, message_id: str, attachment_id: str, audit_log: dict) -> None:
        webhook = os.getenv("CDN_WEBHOOK_URL")
        if not webhook:
            return
            
        payload = {
            "event": "media_compression_complete",
            "message_id": message_id,
            "attachment_id": attachment_id,
            "metrics": audit_log
        }
        
        with httpx.Client() as http:
            response = http.post(webhook, json=payload, timeout=10.0)
            if response.status_code in (200, 202):
                logger.info(f"CDN sync successful for {attachment_id}")
            else:
                logger.error(f"CDN sync failed: {response.status_code}")

def main():
    client = platform_client.create(
        host=f"https://{os.getenv('GENESYS_CLOUD_REGION', 'mypurecloud.com')}",
        client_id=os.getenv("GENESYS_CLOUD_CLIENT_ID"),
        client_secret=os.getenv("GENESYS_CLOUD_CLIENT_SECRET")
    )
    
    compressor = WebMessagingMediaCompressor(client)
    
    # Replace with actual IDs from your environment
    message_id = "msg_abc123"
    attachment_id = "att_xyz789"
    
    try:
        audit = compressor.process_attachment(message_id, attachment_id, target_format="webp")
        print(json.dumps(audit, indent=2))
    except Exception as e:
        logger.error(f"Pipeline failed: {str(e)}")
        raise

if __name__ == "__main__":
    main()

Common Errors & Debugging

Error: 401 Unauthorized

  • Cause: OAuth token expired or invalid client credentials.
  • Fix: Verify GENESYS_CLOUD_CLIENT_ID and GENESYS_CLOUD_CLIENT_SECRET. The SDK handles refresh automatically, but if the client was initialized before token expiry, reinitialize the platform client.
  • Code Fix: Wrap calls in a retry loop that recreates platform_client on 401 responses.

Error: 403 Forbidden

  • Cause: Missing required OAuth scopes.
  • Fix: Add webchat:read, webchat:write, media:read, media:write to the OAuth application in Genesys Cloud Admin Console.
  • Verification: Run GET /api/v2/iam/scopes to confirm active scopes.

Error: 429 Too Many Requests

  • Cause: Exceeded API rate limits (typically 20-50 requests per second depending on tier).
  • Fix: Implement exponential backoff. The _upload method includes a retry loop that reads the Retry-After header.
  • Code Fix: Ensure time.sleep(int(response.headers.get("Retry-After", 2 ** attempt))) is active. Increase base delay if processing bulk attachments.

Error: ValueError: Compressed size exceeds limit

  • Cause: Original file was already near the 10 MB gateway limit, or compression settings were too lenient.
  • Fix: Lower the quality value in QUALITY_MATRIX or force target_format="webp". WebP typically achieves 25-35 percent reduction compared to PNG.
  • Verification: Log compression_ratio before upload. Abort if ratio falls below 0.05.

Official References