gateway/modules/services/serviceMessaging/mainServiceMessaging.py

350 lines
12 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Messaging service for sending messages across different channels.
Provides subscription-based messaging functionality.
"""
import logging
import re
from typing import List, Optional, Callable
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 MessagingService:
"""
Messaging service providing subscription-based messaging functionality.
"""
def __init__(self, services):
"""Initialize messaging service with service center access.
Args:
services: Service center instance providing access to interfaces
"""
self.services = 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 <br> 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'<a href="\1" style="color: #0066cc;">\1</a>', escaped)
# Convert newlines to <br> tags
escaped = escaped.replace('\n', '<br>\n')
# Wrap in a nice HTML structure
htmlContent = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #333333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}}
a {{
color: #0066cc;
}}
</style>
</head>
<body>
{escaped}
</body>
</html>"""
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.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