"""EspoCRM API client for Motia III""" import aiohttp import asyncio import logging import redis import os from typing import Optional, Dict, Any, List logger = logging.getLogger(__name__) class EspoCRMError(Exception): """Base exception for EspoCRM API errors""" pass class EspoCRMAuthError(EspoCRMError): """Authentication error""" pass class EspoCRMAPI: """ EspoCRM API Client for BitByLaw integration. Supports: - API Key authentication (X-Api-Key header) - Standard REST operations (GET, POST, PUT, DELETE) - Entity management (CBeteiligte, CVmhErstgespraech, etc.) Environment variables required: - ESPOCRM_API_BASE_URL (e.g., https://crm.bitbylaw.com/api/v1) - ESPOCRM_API_KEY (Marvin API key) - ESPOCRM_API_TIMEOUT_SECONDS (optional, default: 30) - REDIS_HOST, REDIS_PORT, REDIS_DB_ADVOWARE_CACHE (for caching) """ def __init__(self, context=None): """ Initialize EspoCRM API client. Args: context: Motia FlowContext for logging (optional) """ self.context = context self._log("EspoCRMAPI initializing", level='debug') # Load configuration from environment self.api_base_url = os.getenv('ESPOCRM_API_BASE_URL', 'https://crm.bitbylaw.com/api/v1') self.api_key = os.getenv('ESPOCRM_API_KEY', '') self.api_timeout_seconds = int(os.getenv('ESPOCRM_API_TIMEOUT_SECONDS', '30')) if not self.api_key: raise EspoCRMAuthError("ESPOCRM_API_KEY not configured in environment") self._log(f"EspoCRM API initialized with base URL: {self.api_base_url}") # Optional Redis for caching/rate limiting try: redis_host = os.getenv('REDIS_HOST', 'localhost') redis_port = int(os.getenv('REDIS_PORT', '6379')) redis_db = int(os.getenv('REDIS_DB_ADVOWARE_CACHE', '1')) redis_timeout = int(os.getenv('REDIS_TIMEOUT_SECONDS', '5')) self.redis_client = redis.Redis( host=redis_host, port=redis_port, db=redis_db, socket_timeout=redis_timeout, socket_connect_timeout=redis_timeout, decode_responses=True ) self.redis_client.ping() self._log("Connected to Redis for EspoCRM operations") except Exception as e: self._log(f"Could not connect to Redis: {e}. Continuing without caching.", level='warning') self.redis_client = None def _log(self, message: str, level: str = 'info'): """Log message via context.logger if available, otherwise use module logger""" if self.context and hasattr(self.context, 'logger'): log_func = getattr(self.context.logger, level, self.context.logger.info) log_func(f"[EspoCRM] {message}") else: log_func = getattr(logger, level, logger.info) log_func(f"[EspoCRM] {message}") def _get_headers(self) -> Dict[str, str]: """Generate request headers with API key""" return { 'X-Api-Key': self.api_key, 'Content-Type': 'application/json', 'Accept': 'application/json' } async def api_call( self, endpoint: str, method: str = 'GET', params: Optional[Dict] = None, json_data: Optional[Dict] = None, timeout_seconds: Optional[int] = None ) -> Any: """ Make an API call to EspoCRM. Args: endpoint: API endpoint (e.g., '/CBeteiligte/123' or '/CVmhErstgespraech') method: HTTP method (GET, POST, PUT, DELETE) params: Query parameters json_data: JSON body for POST/PUT timeout_seconds: Request timeout Returns: Parsed JSON response or None Raises: EspoCRMError: On API errors """ # Ensure endpoint starts with / if not endpoint.startswith('/'): endpoint = '/' + endpoint url = self.api_base_url.rstrip('/') + endpoint headers = self._get_headers() effective_timeout = aiohttp.ClientTimeout( total=timeout_seconds or self.api_timeout_seconds ) self._log(f"API call: {method} {url}", level='debug') if params: self._log(f"Params: {params}", level='debug') async with aiohttp.ClientSession(timeout=effective_timeout) as session: try: async with session.request( method, url, headers=headers, params=params, json=json_data ) as response: # Log response status self._log(f"Response status: {response.status}", level='debug') # Handle errors if response.status == 401: raise EspoCRMAuthError("Authentication failed - check API key") elif response.status == 403: raise EspoCRMError("Access forbidden") elif response.status == 404: raise EspoCRMError(f"Resource not found: {endpoint}") elif response.status >= 400: error_text = await response.text() raise EspoCRMError(f"API error {response.status}: {error_text}") # Parse response if response.content_type == 'application/json': result = await response.json() self._log(f"Response received", level='debug') return result else: # For DELETE or other non-JSON responses return None except aiohttp.ClientError as e: self._log(f"API call failed: {e}", level='error') raise EspoCRMError(f"Request failed: {e}") from e async def get_entity(self, entity_type: str, entity_id: str) -> Dict[str, Any]: """ Get a single entity by ID. Args: entity_type: Entity type (e.g., 'CBeteiligte', 'CVmhErstgespraech') entity_id: Entity ID Returns: Entity data as dict """ self._log(f"Getting {entity_type} with ID: {entity_id}") return await self.api_call(f"/{entity_type}/{entity_id}", method='GET') async def list_entities( self, entity_type: str, where: Optional[List[Dict]] = None, select: Optional[str] = None, order_by: Optional[str] = None, offset: int = 0, max_size: int = 50 ) -> Dict[str, Any]: """ List entities with filtering and pagination. Args: entity_type: Entity type where: Filter conditions (EspoCRM format) select: Comma-separated field list order_by: Sort field offset: Pagination offset max_size: Max results per page Returns: Dict with 'list' and 'total' keys """ params = { 'offset': offset, 'maxSize': max_size } if where: import json # EspoCRM expects JSON-encoded where clause params['where'] = where if isinstance(where, str) else json.dumps(where) if select: params['select'] = select if order_by: params['orderBy'] = order_by self._log(f"Listing {entity_type} entities") return await self.api_call(f"/{entity_type}", method='GET', params=params) async def create_entity( self, entity_type: str, data: Dict[str, Any] ) -> Dict[str, Any]: """ Create a new entity. Args: entity_type: Entity type data: Entity data Returns: Created entity with ID """ self._log(f"Creating {entity_type} entity") return await self.api_call(f"/{entity_type}", method='POST', json_data=data) async def update_entity( self, entity_type: str, entity_id: str, data: Dict[str, Any] ) -> Dict[str, Any]: """ Update an existing entity. Args: entity_type: Entity type entity_id: Entity ID data: Updated fields Returns: Updated entity """ self._log(f"Updating {entity_type} with ID: {entity_id}") return await self.api_call(f"/{entity_type}/{entity_id}", method='PUT', json_data=data) async def delete_entity(self, entity_type: str, entity_id: str) -> bool: """ Delete an entity. Args: entity_type: Entity type entity_id: Entity ID Returns: True if successful """ self._log(f"Deleting {entity_type} with ID: {entity_id}") await self.api_call(f"/{entity_type}/{entity_id}", method='DELETE') return True async def search_entities( self, entity_type: str, query: str, fields: Optional[List[str]] = None ) -> List[Dict[str, Any]]: """ Search entities by text query. Args: entity_type: Entity type query: Search query fields: Fields to search in Returns: List of matching entities """ where = [{ 'type': 'textFilter', 'value': query }] result = await self.list_entities(entity_type, where=where) return result.get('list', []) async def upload_attachment( self, file_content: bytes, filename: str, parent_type: str, parent_id: str, field: str, mime_type: str = 'application/octet-stream', role: str = 'Attachment' ) -> Dict[str, Any]: """ Upload an attachment to EspoCRM. Args: file_content: File content as bytes filename: Name of the file parent_type: Parent entity type (e.g., 'Document') parent_id: Parent entity ID field: Field name for the attachment (e.g., 'preview') mime_type: MIME type of the file role: Attachment role (default: 'Attachment') Returns: Attachment entity data """ self._log(f"Uploading attachment: {filename} ({len(file_content)} bytes) to {parent_type}/{parent_id}/{field}") url = self.api_base_url.rstrip('/') + '/Attachment' headers = { 'X-Api-Key': self.api_key, # Content-Type wird automatisch von aiohttp gesetzt für FormData } # Erstelle FormData form_data = aiohttp.FormData() form_data.add_field('file', file_content, filename=filename, content_type=mime_type) form_data.add_field('parentType', parent_type) form_data.add_field('parentId', parent_id) form_data.add_field('field', field) form_data.add_field('role', role) form_data.add_field('name', filename) self._log(f"Upload params: parentType={parent_type}, parentId={parent_id}, field={field}, role={role}") effective_timeout = aiohttp.ClientTimeout(total=self.api_timeout_seconds) async with aiohttp.ClientSession(timeout=effective_timeout) as session: try: async with session.post(url, headers=headers, data=form_data) as response: self._log(f"Upload response status: {response.status}") if response.status == 401: raise EspoCRMAuthError("Authentication failed - check API key") elif response.status == 403: raise EspoCRMError("Access forbidden") elif response.status == 404: raise EspoCRMError(f"Attachment endpoint not found") elif response.status >= 400: error_text = await response.text() self._log(f"❌ Upload failed with {response.status}. Response: {error_text}", level='error') raise EspoCRMError(f"Upload error {response.status}: {error_text}") # Parse response if response.content_type == 'application/json': result = await response.json() attachment_id = result.get('id') self._log(f"✅ Attachment uploaded successfully: {attachment_id}") return result else: response_text = await response.text() self._log(f"⚠️ Non-JSON response: {response_text[:200]}", level='warn') return {'success': True, 'response': response_text} except aiohttp.ClientError as e: self._log(f"Upload failed: {e}", level='error') raise EspoCRMError(f"Upload request failed: {e}") from e async def download_attachment(self, attachment_id: str) -> bytes: """ Download an attachment from EspoCRM. Args: attachment_id: Attachment ID Returns: File content as bytes """ self._log(f"Downloading attachment: {attachment_id}") url = self.api_base_url.rstrip('/') + f'/Attachment/file/{attachment_id}' headers = { 'X-Api-Key': self.api_key, } effective_timeout = aiohttp.ClientTimeout(total=self.api_timeout_seconds) async with aiohttp.ClientSession(timeout=effective_timeout) as session: try: async with session.get(url, headers=headers) as response: if response.status == 401: raise EspoCRMAuthError("Authentication failed - check API key") elif response.status == 403: raise EspoCRMError("Access forbidden") elif response.status == 404: raise EspoCRMError(f"Attachment not found: {attachment_id}") elif response.status >= 400: error_text = await response.text() raise EspoCRMError(f"Download error {response.status}: {error_text}") content = await response.read() self._log(f"✅ Downloaded {len(content)} bytes") return content except aiohttp.ClientError as e: self._log(f"Download failed: {e}", level='error') raise EspoCRMError(f"Download request failed: {e}") from e