350 lines
12 KiB
Python
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
|
|
|