import logging from typing import Any, Dict, List, Optional import apprise from app.config import settings logger = logging.getLogger(__name__) # Global Apprise instance _apprise = None def init_apprise() -> apprise.Apprise: """Initialize the Apprise instance with configured notification services""" global _apprise if _apprise is None: _apprise = apprise.Apprise() # Add all configured notification services if settings.notification_urls: for url in settings.notification_urls: try: _apprise.add(url) logger.info(f"Added notification service: {_mask_sensitive_url(url)}") except Exception as e: logger.error(f"Failed to add notification service: {str(e)}") else: logger.warning("No notification services configured") return _apprise def _mask_sensitive_url(url: str) -> str: """Mask sensitive parts of notification URLs for logging""" # Simple masking for common URL formats with credentials import re # Match patterns like user:pass@host or token in URL parameters masked = re.sub(r"://([^:]+):([^@]+)@", r"://\1:****@", url) masked = re.sub(r"(discord://)[^/]+/[^/]+", r"\1webhook_id/****", masked) masked = re.sub(r"(tgram://)[^/]+/[^/]+", r"\1bot_token/****", masked) masked = re.sub(r"([?&](token|key|api_key|password|secret)=)([^&]+)", r"\1****", masked) return masked def send_notification( title: str, message: str, notification_type: str = "info", tags: Optional[List[str]] = None, attachments: Optional[List[str]] = None, data: Optional[Dict[str, Any]] = None, ) -> bool: """ Send a notification through all configured channels Args: title: The notification title message: The notification body message notification_type: Type of notification (info, success, warning, failure) tags: Optional list of tags for filtering notifications attachments: Optional list of file paths to attach data: Optional additional data for the notification Returns: bool: True if notification was sent successfully to at least one service """ if not settings.notification_urls: logger.debug(f"Notification not sent (no services configured): {title}") return False try: apprise_obj = init_apprise() # Set notification type notify_type = apprise.NotifyType.INFO if notification_type == "success": notify_type = apprise.NotifyType.SUCCESS elif notification_type in ("warning", "warn"): notify_type = apprise.NotifyType.WARNING elif notification_type in ("failure", "error", "failed"): notify_type = apprise.NotifyType.FAILURE # Send the notification to each service individually for better error reporting if not apprise_obj.servers: # Access servers as an attribute, not a method logger.warning("No notification servers available despite having URLs configured") return False total_services = len(apprise_obj.servers) successful_services = 0 for server in apprise_obj.servers: # Iterate through the list directly try: service_name = str(server).split("://")[0] if "://" in str(server) else str(server) service_result = server.notify(title=title, body=message, notify_type=notify_type, attach=attachments) if service_result: successful_services += 1 logger.debug(f"Notification sent via {service_name}") else: logger.warning(f"Failed to send notification via {service_name}") except Exception as e: logger.error(f"Error sending notification via {str(server)}: {str(e)}") overall_result = successful_services > 0 if overall_result: logger.debug(f"Notification sent: '{title}' (successful: {successful_services}/{total_services})") else: logger.warning(f"Failed to send notification to ALL services: '{title}' (0/{total_services})") return overall_result except Exception as e: logger.exception(f"Error sending notification: {e}") return False def notify_celery_failure(task_name: str, task_id: str, exc: Exception, args: list, kwargs: dict) -> bool: """Send a notification about a failed Celery task""" if not settings.notify_on_task_failure: return False title = f"Task Failed: {task_name}" message = f""" Task {task_name} ({task_id}) failed with error: {type(exc).__name__}: {str(exc)} Arguments: {args} Keyword arguments: {kwargs} """ return send_notification( title=title, message=message, notification_type="failure", tags=["celery", "failure", task_name] ) def notify_credential_failure(service_name: str, error: str) -> bool: """Send a notification about a credential failure""" if not settings.notify_on_credential_failure: return False title = f"Credential Failure: {service_name}" message = f""" The credentials for {service_name} have failed: {error} Please check and update the credentials in the system settings. """ return send_notification( title=title, message=message, notification_type="warning", tags=["credentials", "warning", service_name] ) def notify_startup() -> bool: """Send a notification that the application has started""" if not settings.notify_on_startup: return False title = "DocuElevate Started" message = f"DocuElevate has been started successfully on {settings.external_hostname}" return send_notification(title=title, message=message, notification_type="success", tags=["system", "startup"]) def notify_shutdown() -> bool: """Send a notification that the application is shutting down""" if not settings.notify_on_shutdown: return False title = "DocuElevate Shutting Down" message = f"DocuElevate on {settings.external_hostname} is shutting down" return send_notification(title=title, message=message, notification_type="info", tags=["system", "shutdown"]) def notify_file_processed(filename: str, file_size: int, metadata: dict, destinations: list) -> bool: """Send a notification that a file has been successfully processed""" if not settings.notify_on_file_processed: return False # Format file size for display size_mb = file_size / (1024 * 1024) size_str = f"{size_mb:.2f} MB" if size_mb >= 1 else f"{file_size / 1024:.2f} KB" # Extract key metadata fields doc_type = metadata.get("document_type", "Unknown") tags = metadata.get("tags", []) tags_str = ", ".join(tags) if tags else "None" # Format destinations destinations_str = ", ".join(destinations) if destinations else "None configured" title = f"File Processed: {filename}" message = f""" File: {filename} Size: {size_str} Document Type: {doc_type} Tags: {tags_str} Destinations: {destinations_str} The file has been successfully processed and is being uploaded to all configured destinations. """ return send_notification( title=title, message=message.strip(), notification_type="success", tags=["document", "processed", "success"] ) def notify_user_signup(user_id: str, display_name: str | None = None, email: str | None = None) -> bool: """Send a notification to admins when a new user signs up. Args: user_id: The stable user identifier (preferred_username / email / sub). display_name: Optional human-readable name for the user. email: Optional email address for the user. Returns: bool: True if the notification was sent successfully. """ if not settings.notify_on_user_signup: return False name_str = display_name or user_id email_str = email or "N/A" title = f"New User Signup: {name_str}" message = f"""A new user has signed up for DocuElevate. User ID: {user_id} Display Name: {name_str} Email: {email_str} Review the new account in the admin panel.""" return send_notification( title=title, message=message.strip(), notification_type="info", tags=["user", "signup"], ) def notify_plan_changed( user_id: str, old_tier: str, new_tier: str, changed_by: str = "user", ) -> bool: """Send a notification to admins when a user changes their subscription plan. Args: user_id: The stable user identifier. old_tier: The previous subscription tier. new_tier: The new subscription tier. changed_by: Who initiated the change (``"user"`` or ``"admin"``). Returns: bool: True if the notification was sent successfully. """ if not settings.notify_on_plan_change: return False title = f"Plan Changed: {user_id}" message = f"""A user's subscription plan has changed. User ID: {user_id} Previous Plan: {old_tier} New Plan: {new_tier} Changed By: {changed_by} Review the account in the admin panel.""" return send_notification( title=title, message=message.strip(), notification_type="info", tags=["user", "plan", "subscription"], ) def notify_payment_issue(user_id: str, issue: str) -> bool: """Send a notification to admins when a payment issue is reported for a user. Args: user_id: The stable user identifier. issue: A human-readable description of the payment issue. Returns: bool: True if the notification was sent successfully. """ if not settings.notify_on_payment_issue: return False title = f"Payment Issue: {user_id}" message = f"""A payment issue has been reported for a user. User ID: {user_id} Issue: {issue} Please review the account in the admin panel and follow up with the user.""" return send_notification( title=title, message=message.strip(), notification_type="warning", tags=["user", "payment", "billing"], )