# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ Messaging service for sending messages across different channels. Provides subscription-based messaging functionality. Supports both service center (context, get_service) and legacy (services) initialization. """ import logging import re from typing import List, Optional, Callable, Any from modules.datamodels.datamodelMessaging import ( MessagingSubscription, MessagingSubscriptionRegistration, MessagingDelivery, MessagingChannel, MessagingEventParameters, MessagingSendResult, MessagingSubscriptionExecutionResult, DeliveryStatus ) from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface from modules.shared.timeUtils import getUtcTimestamp logger = logging.getLogger(__name__) class _ServicesAdapter: """Minimal adapter providing interfaceDbComponent for service center mode.""" def __init__(self, context: Any): from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface self.interfaceDbComponent = getComponentInterface( context.user, mandateId=context.mandate_id ) class MessagingService: """ Messaging service providing subscription-based messaging functionality. """ def __init__(self, context_or_services: Any, get_service: Optional[Callable[[str], Any]] = None): """Initialize messaging service. Args: context_or_services: ServiceCenterContext (when get_service is callable) or legacy Services hub get_service: Callable to resolve services (service center mode only) """ if get_service is not None and callable(get_service): # Service center: (context, get_service) self.services = _ServicesAdapter(context_or_services) else: # Legacy: (services,) self.services = context_or_services self._messagingInterface = None def sendMessage( self, subject: str, message: str, registration: MessagingSubscriptionRegistration ) -> MessagingSendResult: """ Sendet eine Nachricht über einen Channel an einen User. Erstellt MessagingDelivery Record. Args: subject: Subject der Nachricht (für E-Mail, leer für SMS) message: Nachrichtentext registration: MessagingSubscriptionRegistration mit Channel-Info und userId Returns: MessagingSendResult mit Status und Delivery-ID """ # Erstelle Delivery Record delivery = MessagingDelivery( subscriptionId=registration.subscriptionId, userId=registration.userId, channel=registration.channel, status=DeliveryStatus.PENDING ) # Speichere Delivery Record try: deliveryRecord = self.services.interfaceDbComponent.createDelivery(delivery) except Exception as e: logger.error(f"Failed to create delivery record: {str(e)}") return MessagingSendResult( success=False, errorMessage=f"Failed to create delivery record: {str(e)}" ) try: # Convert plain text to HTML for email channel messageToSend = message if registration.channel == MessagingChannel.EMAIL: messageToSend = self._textToHtml(message) # Versende über interfaceMessaging success = self._getMessagingInterface().send( channel=registration.channel, recipient=registration.channelConfig, subject=subject, message=messageToSend ) if success: # Update Delivery Record self.services.interfaceDbComponent.updateDelivery( deliveryRecord["id"], { "status": DeliveryStatus.SENT, "sentAt": getUtcTimestamp() } ) return MessagingSendResult( success=True, deliveryId=deliveryRecord["id"] ) else: # Update Delivery Record mit Fehler self.services.interfaceDbComponent.updateDelivery( deliveryRecord["id"], { "status": DeliveryStatus.FAILED, "errorMessage": "Failed to send message" } ) return MessagingSendResult( success=False, deliveryId=deliveryRecord["id"], errorMessage="Failed to send message" ) except Exception as e: logger.error(f"Error sending message: {str(e)}") # Update Delivery Record mit Fehler try: self.services.interfaceDbComponent.updateDelivery( deliveryRecord["id"], { "status": DeliveryStatus.FAILED, "errorMessage": str(e) } ) except Exception as updateError: logger.error(f"Failed to update delivery record: {str(updateError)}") return MessagingSendResult( success=False, deliveryId=deliveryRecord["id"], errorMessage=str(e) ) def _textToHtml(self, text: str) -> str: """ Convert plain text to simple HTML for email display. - Escapes HTML special characters - Converts newlines to
tags - Wraps URLs in clickable links - Wraps in a basic HTML structure with nice styling Args: text: Plain text message Returns: HTML formatted message """ import html # Check if already HTML (contains HTML tags) if re.search(r'<[^>]+>', text): return text # Escape HTML special characters escaped = html.escape(text) # Convert URLs to clickable links (before converting newlines) urlPattern = r'(https?://[^\s<>"\']+)' escaped = re.sub(urlPattern, r'\1', escaped) # Convert newlines to
tags escaped = escaped.replace('\n', '
\n') # Wrap in a nice HTML structure htmlContent = f""" {escaped} """ return htmlContent def sendEmailDirect( self, recipient: str, subject: str, message: str, userId: Optional[str] = None ) -> bool: """ Send email directly without requiring a subscription. Used for authentication flows (registration, password reset). Plain text messages are automatically converted to HTML format. Args: recipient: Email address of the recipient subject: Email subject message: Email body (can be HTML or plain text - plain text is auto-converted) userId: Optional user ID for logging/audit purposes Returns: bool: True if email was sent successfully, False otherwise """ try: # Convert plain text to HTML if needed htmlMessage = self._textToHtml(message) messagingInterface = self._getMessagingInterface() success = messagingInterface.send( channel=MessagingChannel.EMAIL, recipient=recipient, subject=subject, message=htmlMessage ) if success: logger.info(f"Email sent successfully to {recipient} (userId: {userId})") else: logger.warning(f"Failed to send email to {recipient} (userId: {userId})") return success except Exception as e: logger.error(f"Error sending email to {recipient}: {str(e)}", exc_info=True) return False def executeSubscription( self, subscriptionId: str, eventParameters: MessagingEventParameters ) -> MessagingSubscriptionExecutionResult: """ Führt eine Subscription-Funktion aus. Args: subscriptionId: ID der Subscription eventParameters: Parameter vom Trigger (als Pydantic Model) Returns: MessagingSubscriptionExecutionResult Raises: ValueError: Wenn Subscription nicht existiert oder nicht enabled ist FileNotFoundError: Wenn Subscription-Funktion nicht gefunden wird """ # Prüfe ob Subscription existiert und enabled ist subscription = self.services.interfaceDbComponent.getSubscription(subscriptionId) if not subscription: raise ValueError(f"Subscription {subscriptionId} not found") if not subscription.enabled: logger.warning(f"Subscription {subscriptionId} is disabled, skipping execution") return MessagingSubscriptionExecutionResult( success=False, messagesSent=0, errorMessage="Subscription is disabled" ) # Hole alle aktiven Registrierungen für diese Subscription registrations = self._getSubscribers(subscriptionId) if not registrations: logger.info(f"No active registrations for subscription {subscriptionId}") return MessagingSubscriptionExecutionResult( success=True, messagesSent=0 ) # Lade Subscription-Funktion dynamisch subscriptionFunction = self._loadSubscriptionFunction(subscriptionId) if not subscriptionFunction: errorMsg = f"Subscription function not found for {subscriptionId}" logger.error(errorMsg) raise FileNotFoundError(errorMsg) # Führe Funktion aus mit Registrierungen try: return subscriptionFunction.execute(eventParameters, registrations, self) except Exception as e: logger.error(f"Error executing subscription {subscriptionId}: {str(e)}", exc_info=True) return MessagingSubscriptionExecutionResult( success=False, messagesSent=0, errorMessage=str(e) ) def _getSubscribers( self, subscriptionId: str, channel: Optional[MessagingChannel] = None ) -> List[MessagingSubscriptionRegistration]: """Holt alle aktiven Subscriber einer Subscription""" filters = {"enabled": True} if channel: filters["channel"] = channel.value registrations = self.services.interfaceDbComponent.getAllRegistrations( subscriptionId=subscriptionId ) # Filter nach enabled und channel filteredRegistrations = [] for reg in registrations: if reg.enabled and (not channel or reg.channel == channel): filteredRegistrations.append(reg) return filteredRegistrations def _loadSubscriptionFunction(self, subscriptionId: str) -> Optional[Callable]: """ Lädt die Subscription-Funktion dynamisch. Returns: Callable mit execute-Methode oder None wenn nicht gefunden Note: subscriptionId wird direkt als Dateiname verwendet (z.B. "SystemErrors" -> subSubscriptionSystemErrors.py) """ # Format: subSubscription{subscriptionId}.py functionName = f"subSubscription{subscriptionId}" moduleName = f"modules.serviceCenter.services.serviceMessaging.subscriptions.{functionName}" try: # Dynamisches Import import importlib subscriptionModule = importlib.import_module(moduleName) return subscriptionModule except ImportError: # Funktion existiert noch nicht - das ist OK logger.debug(f"Subscription function {moduleName} not found (this is OK if not yet implemented)") return None def _getMessagingInterface(self): """Holt das Messaging-Interface (interfaceMessaging)""" if not self._messagingInterface: self._messagingInterface = getMessagingInterface() return self._messagingInterface