"""Notification service for sending push notifications via various providers.""" import asyncio import json import logging import re import smtplib from datetime import datetime from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from typing import Any from urllib.parse import quote import httpx from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from backend.app.models.notification import NotificationLog, NotificationProvider, NotificationDigestQueue from backend.app.models.notification_template import NotificationTemplate logger = logging.getLogger(__name__) class NotificationService: """Service for sending notifications through various providers.""" def __init__(self): self._http_client: httpx.AsyncClient | None = None self._template_cache: dict[str, NotificationTemplate] = {} self._digest_scheduler_task: asyncio.Task | None = None self._last_digest_check: str = "" # "HH:MM" to avoid duplicate checks async def _get_client(self) -> httpx.AsyncClient: """Get or create HTTP client.""" if self._http_client is None or self._http_client.is_closed: self._http_client = httpx.AsyncClient(timeout=30.0) return self._http_client async def close(self): """Close HTTP client.""" if self._http_client and not self._http_client.is_closed: await self._http_client.aclose() def _is_in_quiet_hours(self, provider: NotificationProvider) -> bool: """Check if current time is within provider's quiet hours.""" if not provider.quiet_hours_enabled: return False if not provider.quiet_hours_start or not provider.quiet_hours_end: return False try: now = datetime.now() current_time = now.hour * 60 + now.minute start_parts = provider.quiet_hours_start.split(":") end_parts = provider.quiet_hours_end.split(":") start_minutes = int(start_parts[0]) * 60 + int(start_parts[1]) end_minutes = int(end_parts[0]) * 60 + int(end_parts[1]) # Handle overnight quiet hours (e.g., 22:00 to 07:00) if start_minutes > end_minutes: # Quiet hours span midnight return current_time >= start_minutes or current_time < end_minutes else: # Same day quiet hours return start_minutes <= current_time < end_minutes except (ValueError, TypeError, AttributeError): logger.warning(f"Invalid quiet hours format for provider {provider.name}") return False async def _get_template(self, db: AsyncSession, event_type: str) -> NotificationTemplate | None: """Get a notification template by event type.""" # Check cache first if event_type in self._template_cache: return self._template_cache[event_type] result = await db.execute( select(NotificationTemplate).where(NotificationTemplate.event_type == event_type) ) template = result.scalar_one_or_none() if template: self._template_cache[event_type] = template return template def _render_template(self, template_str: str, variables: dict[str, Any]) -> str: """Render a template string with variables. Missing variables become empty.""" result = template_str for key, value in variables.items(): result = result.replace("{" + key + "}", str(value) if value is not None else "") # Remove any remaining unreplaced placeholders result = re.sub(r"\{[a-z_]+\}", "", result) return result def _format_duration(self, seconds: int | None) -> str: """Format duration in seconds to human-readable string.""" if seconds is None: return "Unknown" hours = seconds // 3600 minutes = (seconds % 3600) // 60 if hours > 0: return f"{hours}h {minutes}m" return f"{minutes}m" def _clean_filename(self, filename: str) -> str: """Remove file extensions from filename.""" if filename.endswith(".gcode.3mf"): return filename[:-10] elif filename.endswith(".3mf"): return filename[:-4] return filename async def _build_message_from_template( self, db: AsyncSession, event_type: str, variables: dict[str, Any] ) -> tuple[str, str]: """Build notification title and body from template.""" # Add common variables variables["timestamp"] = datetime.now().strftime("%Y-%m-%d %H:%M") variables["app_name"] = "Bambuddy" template = await self._get_template(db, event_type) if not template: # Fallback to simple message logger.warning(f"Template not found for event type: {event_type}") return event_type.replace("_", " ").title(), str(variables) title = self._render_template(template.title_template, variables) body = self._render_template(template.body_template, variables) return title, body async def send_test_notification( self, provider_type: str, config: dict[str, Any], db: AsyncSession | None = None ) -> tuple[bool, str]: """Send a test notification to verify configuration.""" if db: title, message = await self._build_message_from_template(db, "test", {}) else: title = "Bambuddy Test" message = "This is a test notification. If you see this, notifications are working!" try: if provider_type == "callmebot": return await self._send_callmebot(config, f"{title}\n{message}") elif provider_type == "ntfy": return await self._send_ntfy(config, title, message) elif provider_type == "pushover": return await self._send_pushover(config, title, message) elif provider_type == "telegram": return await self._send_telegram(config, f"*{title}*\n{message}") elif provider_type == "email": return await self._send_email(config, title, message) elif provider_type == "discord": return await self._send_discord(config, title, message) elif provider_type == "webhook": return await self._send_webhook(config, title, message) else: return False, f"Unknown provider type: {provider_type}" except Exception as e: logger.exception(f"Error sending test notification via {provider_type}") return False, str(e) async def _send_callmebot(self, config: dict, message: str) -> tuple[bool, str]: """Send notification via CallMeBot (WhatsApp).""" phone = config.get("phone", "").strip() apikey = config.get("apikey", "").strip() if not phone or not apikey: return False, "Phone number and API key are required" # URL encode the message encoded_message = quote(message) url = f"https://api.callmebot.com/whatsapp.php?phone={phone}&text={encoded_message}&apikey={apikey}" client = await self._get_client() response = await client.get(url) if response.status_code == 200: return True, "Message sent successfully" else: return False, f"HTTP {response.status_code}: {response.text[:200]}" async def _send_ntfy(self, config: dict, title: str, message: str) -> tuple[bool, str]: """Send notification via ntfy.""" server = config.get("server", "https://ntfy.sh").rstrip("/") topic = config.get("topic", "").strip() auth_token = config.get("auth_token", "").strip() if not topic: return False, "Topic is required" url = f"{server}/{topic}" headers = {"Title": title} if auth_token: headers["Authorization"] = f"Bearer {auth_token}" client = await self._get_client() response = await client.post(url, content=message, headers=headers) if response.status_code in (200, 204): return True, "Message sent successfully" else: return False, f"HTTP {response.status_code}: {response.text[:200]}" async def _send_pushover(self, config: dict, title: str, message: str) -> tuple[bool, str]: """Send notification via Pushover.""" user_key = config.get("user_key", "").strip() app_token = config.get("app_token", "").strip() priority = config.get("priority", 0) if not user_key or not app_token: return False, "User key and app token are required" url = "https://api.pushover.net/1/messages.json" data = { "token": app_token, "user": user_key, "title": title, "message": message, "priority": priority, } client = await self._get_client() response = await client.post(url, data=data) if response.status_code == 200: return True, "Message sent successfully" else: try: error_data = response.json() errors = error_data.get("errors", []) return False, f"Pushover error: {', '.join(errors)}" except Exception: return False, f"HTTP {response.status_code}: {response.text[:200]}" async def _send_telegram(self, config: dict, message: str) -> tuple[bool, str]: """Send notification via Telegram bot.""" bot_token = config.get("bot_token", "").strip() chat_id = config.get("chat_id", "").strip() if not bot_token or not chat_id: return False, "Bot token and chat ID are required" url = f"https://api.telegram.org/bot{bot_token}/sendMessage" data = { "chat_id": chat_id, "text": message, "parse_mode": "Markdown", } client = await self._get_client() response = await client.post(url, json=data) if response.status_code == 200: result = response.json() if result.get("ok"): return True, "Message sent successfully" else: return False, f"Telegram error: {result.get('description', 'Unknown error')}" else: return False, f"HTTP {response.status_code}: {response.text[:200]}" async def _send_email(self, config: dict, subject: str, body: str) -> tuple[bool, str]: """Send notification via email (SMTP).""" smtp_server = config.get("smtp_server", "").strip() smtp_port = int(config.get("smtp_port", 587)) username = config.get("username", "").strip() password = config.get("password", "").strip() from_email = config.get("from_email", "").strip() to_email = config.get("to_email", "").strip() # Security: "starttls" (port 587), "ssl" (port 465), "none" (port 25) security = config.get("security", "starttls") # Authentication: "true" or "false" auth_enabled = config.get("auth_enabled", "true").lower() == "true" if not all([smtp_server, from_email, to_email]): return False, "SMTP server, from email, and to email are required" if auth_enabled and not all([username, password]): return False, "Username and password are required when authentication is enabled" try: msg = MIMEMultipart() msg["From"] = from_email msg["To"] = to_email msg["Subject"] = f"[Bambuddy] {subject}" msg.attach(MIMEText(body, "plain")) if security == "ssl": # Direct SSL connection (typically port 465) server = smtplib.SMTP_SSL(smtp_server, smtp_port) elif security == "starttls": # STARTTLS upgrade (typically port 587) server = smtplib.SMTP(smtp_server, smtp_port) server.starttls() else: # No encryption (typically port 25) - use with caution server = smtplib.SMTP(smtp_server, smtp_port) if auth_enabled: server.login(username, password) server.sendmail(from_email, to_email, msg.as_string()) server.quit() return True, "Email sent successfully" except smtplib.SMTPAuthenticationError: return False, "SMTP authentication failed - check username/password" except smtplib.SMTPException as e: return False, f"SMTP error: {str(e)}" except Exception as e: return False, f"Email error: {str(e)}" async def _send_discord(self, config: dict, title: str, message: str) -> tuple[bool, str]: """Send notification via Discord webhook.""" webhook_url = config.get("webhook_url", "").strip() if not webhook_url: return False, "Webhook URL is required" if not webhook_url.startswith("https://discord.com/api/webhooks/"): return False, "Invalid Discord webhook URL" # Discord embed format for nicer messages data = { "embeds": [{ "title": title, "description": message, "color": 0x00AE42, # Bambu green }] } client = await self._get_client() response = await client.post(webhook_url, json=data) if response.status_code in (200, 204): return True, "Message sent successfully" else: return False, f"HTTP {response.status_code}: {response.text[:200]}" async def _send_webhook(self, config: dict, title: str, message: str) -> tuple[bool, str]: """Send notification via generic webhook (POST JSON).""" webhook_url = config.get("webhook_url", "").strip() auth_header = config.get("auth_header", "").strip() custom_field_title = config.get("field_title", "title").strip() or "title" custom_field_message = config.get("field_message", "message").strip() or "message" if not webhook_url: return False, "Webhook URL is required" # Build payload with custom field names data = { custom_field_title: title, custom_field_message: message, "timestamp": datetime.now().isoformat(), "source": "Bambuddy", } headers = {"Content-Type": "application/json"} if auth_header: # Support "Bearer token" or just "token" format if " " in auth_header: headers["Authorization"] = auth_header else: headers["Authorization"] = f"Bearer {auth_header}" client = await self._get_client() try: response = await client.post(webhook_url, json=data, headers=headers) if response.status_code in (200, 201, 202, 204): return True, "Webhook delivered successfully" else: return False, f"HTTP {response.status_code}: {response.text[:200]}" except Exception as e: return False, f"Webhook error: {str(e)}" async def _send_to_provider( self, provider: NotificationProvider, title: str, message: str ) -> tuple[bool, str]: """Send notification to a specific provider.""" # Check quiet hours if self._is_in_quiet_hours(provider): logger.info(f"Skipping notification to {provider.name} - quiet hours active") return True, "Skipped - quiet hours" config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config try: if provider.provider_type == "callmebot": return await self._send_callmebot(config, f"{title}\n{message}") elif provider.provider_type == "ntfy": return await self._send_ntfy(config, title, message) elif provider.provider_type == "pushover": return await self._send_pushover(config, title, message) elif provider.provider_type == "telegram": return await self._send_telegram(config, f"*{title}*\n{message}") elif provider.provider_type == "email": return await self._send_email(config, title, message) elif provider.provider_type == "discord": return await self._send_discord(config, title, message) elif provider.provider_type == "webhook": return await self._send_webhook(config, title, message) else: return False, f"Unknown provider type: {provider.provider_type}" except Exception as e: logger.exception(f"Error sending notification via {provider.provider_type}") return False, str(e) async def _update_provider_status( self, db: AsyncSession, provider_id: int, success: bool, error: str | None = None ): """Update provider status after sending notification.""" result = await db.execute( select(NotificationProvider).where(NotificationProvider.id == provider_id) ) provider = result.scalar_one_or_none() if provider: if success: provider.last_success = datetime.utcnow() else: provider.last_error = error provider.last_error_at = datetime.utcnow() await db.commit() async def _get_providers_for_event( self, db: AsyncSession, event_field: str, printer_id: int | None = None, ) -> list[NotificationProvider]: """Get all enabled providers that want a specific event type.""" # Build the query dynamically based on event field query = select(NotificationProvider).where( NotificationProvider.enabled == True, getattr(NotificationProvider, event_field) == True, ) if printer_id is not None: query = query.where( (NotificationProvider.printer_id == None) | (NotificationProvider.printer_id == printer_id) ) result = await db.execute(query) return list(result.scalars().all()) async def _log_notification( self, db: AsyncSession, provider_id: int, event_type: str, title: str, message: str, success: bool, error_message: str | None = None, printer_id: int | None = None, printer_name: str | None = None, ): """Create a log entry for a sent notification.""" try: log = NotificationLog( provider_id=provider_id, event_type=event_type, title=title, message=message, success=success, error_message=error_message, printer_id=printer_id, printer_name=printer_name, ) db.add(log) await db.commit() except Exception as e: logger.warning(f"Failed to log notification: {e}") # Don't fail the notification just because logging failed async def _send_to_providers( self, providers: list[NotificationProvider], title: str, message: str, db: AsyncSession, event_type: str = "unknown", printer_id: int | None = None, printer_name: str | None = None, ): """Send notification to multiple providers and log the results.""" for provider in providers: try: # Check if provider wants digest mode if provider.daily_digest_enabled and provider.daily_digest_time: await self._queue_for_digest( provider=provider, event_type=event_type, title=title, message=message, db=db, printer_id=printer_id, printer_name=printer_name, ) continue success, error = await self._send_to_provider(provider, title, message) await self._update_provider_status(db, provider.id, success, error if not success else None) await self._log_notification( db=db, provider_id=provider.id, event_type=event_type, title=title, message=message, success=success, error_message=error if not success else None, printer_id=printer_id, printer_name=printer_name, ) if success: logger.info(f"Sent notification via {provider.name}") else: logger.warning(f"Failed to send notification via {provider.name}: {error}") except Exception as e: logger.exception(f"Error sending notification via {provider.name}") await self._update_provider_status(db, provider.id, False, str(e)) await self._log_notification( db=db, provider_id=provider.id, event_type=event_type, title=title, message=message, success=False, error_message=str(e), printer_id=printer_id, printer_name=printer_name, ) async def on_print_start( self, printer_id: int, printer_name: str, data: dict, db: AsyncSession ): """Handle print start event - send notifications to relevant providers.""" logger.info(f"on_print_start called for printer {printer_id} ({printer_name})") providers = await self._get_providers_for_event(db, "on_print_start", printer_id) if not providers: logger.info(f"No notification providers configured for print_start event on printer {printer_id}") return filename = self._clean_filename(data.get("filename", "Unknown")) estimated_time = data.get("raw_data", {}).get("print", {}).get("mc_remaining_time") time_str = self._format_duration(estimated_time * 60 if estimated_time else None) variables = { "printer": printer_name, "filename": filename, "estimated_time": time_str, } logger.info(f"Found {len(providers)} providers for print_start: {[p.name for p in providers]}") title, message = await self._build_message_from_template(db, "print_start", variables) await self._send_to_providers(providers, title, message, db, "print_start", printer_id, printer_name) async def on_print_complete( self, printer_id: int, printer_name: str, status: str, data: dict, db: AsyncSession, archive_data: dict | None = None, ): """Handle print complete event - send notifications to relevant providers.""" logger.info(f"on_print_complete called for printer {printer_id} ({printer_name}), status={status}") # Determine event type based on status if status == "completed": event_field = "on_print_complete" event_type = "print_complete" elif status in ("failed",): event_field = "on_print_failed" event_type = "print_failed" elif status in ("aborted", "stopped", "cancelled"): event_field = "on_print_stopped" event_type = "print_stopped" else: logger.warning(f"Unknown print status '{status}', defaulting to on_print_complete") event_field = "on_print_complete" event_type = "print_complete" providers = await self._get_providers_for_event(db, event_field, printer_id) if not providers: logger.info(f"No notification providers configured for {event_field} event on printer {printer_id}") return filename = self._clean_filename(data.get("filename", "Unknown")) variables = { "printer": printer_name, "filename": filename, "duration": "", "filament_grams": "", "reason": "", } if archive_data: if archive_data.get("print_time_seconds"): variables["duration"] = self._format_duration(archive_data["print_time_seconds"]) if archive_data.get("actual_filament_grams"): variables["filament_grams"] = f"{archive_data['actual_filament_grams']:.1f}" if status == "failed" and archive_data.get("failure_reason"): variables["reason"] = archive_data["failure_reason"] logger.info(f"Found {len(providers)} providers for {event_field}: {[p.name for p in providers]}") title, message = await self._build_message_from_template(db, event_type, variables) await self._send_to_providers(providers, title, message, db, event_type, printer_id, printer_name) async def on_print_progress( self, printer_id: int, printer_name: str, filename: str, progress: int, db: AsyncSession, remaining_time: int | None = None, ): """Handle print progress milestone (25%, 50%, 75%).""" providers = await self._get_providers_for_event(db, "on_print_progress", printer_id) if not providers: return variables = { "printer": printer_name, "filename": self._clean_filename(filename), "progress": str(progress), "remaining_time": self._format_duration(remaining_time) if remaining_time else "", } title, message = await self._build_message_from_template(db, "print_progress", variables) await self._send_to_providers(providers, title, message, db, "print_progress", printer_id, printer_name) async def on_printer_offline( self, printer_id: int, printer_name: str, db: AsyncSession ): """Handle printer offline event.""" providers = await self._get_providers_for_event(db, "on_printer_offline", printer_id) if not providers: return variables = {"printer": printer_name} title, message = await self._build_message_from_template(db, "printer_offline", variables) await self._send_to_providers(providers, title, message, db, "printer_offline", printer_id, printer_name) async def on_printer_error( self, printer_id: int, printer_name: str, error_type: str, db: AsyncSession, error_detail: str | None = None, ): """Handle printer error event (AMS issues, etc.).""" providers = await self._get_providers_for_event(db, "on_printer_error", printer_id) if not providers: return variables = { "printer": printer_name, "error_type": error_type, "error_detail": error_detail or "", } title, message = await self._build_message_from_template(db, "printer_error", variables) await self._send_to_providers(providers, title, message, db, "printer_error", printer_id, printer_name) async def on_filament_low( self, printer_id: int, printer_name: str, slot: int, remaining_percent: int, db: AsyncSession, color: str | None = None, ): """Handle low filament event.""" providers = await self._get_providers_for_event(db, "on_filament_low", printer_id) if not providers: return variables = { "printer": printer_name, "slot": str(slot), "remaining_percent": str(remaining_percent), "color": color or "", } title, message = await self._build_message_from_template(db, "filament_low", variables) await self._send_to_providers(providers, title, message, db, "filament_low", printer_id, printer_name) async def on_maintenance_due( self, printer_id: int, printer_name: str, maintenance_items: list[dict], db: AsyncSession, ): """Handle maintenance due event - sends notification when maintenance is due or warning.""" if not maintenance_items: return providers = await self._get_providers_for_event(db, "on_maintenance_due", printer_id) if not providers: logger.info(f"No notification providers configured for maintenance_due event on printer {printer_id}") return # Format maintenance items list items_list = [] for item in maintenance_items: status = "OVERDUE" if item.get("is_due") else "Soon" items_list.append(f"- {item['name']} ({status})") items_str = "\n".join(items_list) variables = { "printer": printer_name, "items": items_str, } logger.info(f"Found {len(providers)} providers for maintenance_due: {[p.name for p in providers]}") title, message = await self._build_message_from_template(db, "maintenance_due", variables) await self._send_to_providers(providers, title, message, db, "maintenance_due", printer_id, printer_name) def clear_template_cache(self): """Clear the template cache. Call this when templates are updated.""" self._template_cache.clear() async def _queue_for_digest( self, provider: NotificationProvider, event_type: str, title: str, message: str, db: AsyncSession, printer_id: int | None = None, printer_name: str | None = None, ): """Queue a notification for later delivery in the daily digest.""" try: queue_entry = NotificationDigestQueue( provider_id=provider.id, event_type=event_type, title=title, message=message, printer_id=printer_id, printer_name=printer_name, ) db.add(queue_entry) await db.commit() logger.info(f"Queued notification for digest: {event_type} for provider {provider.name}") except Exception as e: logger.warning(f"Failed to queue notification for digest: {e}") async def send_digest(self, provider_id: int): """Send all queued notifications as a single digest for a provider.""" from backend.app.core.database import async_session async with async_session() as db: # Get the provider result = await db.execute( select(NotificationProvider).where(NotificationProvider.id == provider_id) ) provider = result.scalar_one_or_none() if not provider or not provider.enabled: return # Get all queued notifications for this provider result = await db.execute( select(NotificationDigestQueue) .where(NotificationDigestQueue.provider_id == provider_id) .order_by(NotificationDigestQueue.created_at) ) queue_entries = list(result.scalars().all()) if not queue_entries: logger.debug(f"No queued notifications for provider {provider.name}") return # Build digest message title = f"Daily Digest - {len(queue_entries)} Events" # Group by event type events_by_type: dict[str, list] = {} for entry in queue_entries: if entry.event_type not in events_by_type: events_by_type[entry.event_type] = [] events_by_type[entry.event_type].append(entry) # Format the digest body body_parts = [] for event_type, entries in events_by_type.items(): event_label = event_type.replace("_", " ").title() body_parts.append(f"== {event_label} ({len(entries)}) ==") for entry in entries: time_str = entry.created_at.strftime("%H:%M") printer_info = f"[{entry.printer_name}] " if entry.printer_name else "" body_parts.append(f" {time_str} {printer_info}{entry.title}") body_parts.append("") body = "\n".join(body_parts) # Send the digest success, error = await self._send_to_provider(provider, title, body) # Log the digest await self._log_notification( db=db, provider_id=provider.id, event_type="daily_digest", title=title, message=body, success=success, error_message=error if not success else None, ) # Clear the queue for entry in queue_entries: await db.delete(entry) await db.commit() if success: logger.info(f"Sent daily digest with {len(queue_entries)} events to {provider.name}") else: logger.warning(f"Failed to send daily digest to {provider.name}: {error}") async def check_and_send_digests(self): """Check all providers and send digests if it's their scheduled time.""" from backend.app.core.database import async_session current_time = datetime.now().strftime("%H:%M") # Avoid duplicate checks within the same minute if current_time == self._last_digest_check: return self._last_digest_check = current_time async with async_session() as db: # Find all providers with digest enabled at this time result = await db.execute( select(NotificationProvider).where( NotificationProvider.enabled == True, NotificationProvider.daily_digest_enabled == True, NotificationProvider.daily_digest_time == current_time, ) ) providers = result.scalars().all() for provider in providers: try: await self.send_digest(provider.id) except Exception as e: logger.error(f"Error sending digest for provider {provider.id}: {e}") def start_digest_scheduler(self): """Start the background scheduler for daily digest notifications.""" if self._digest_scheduler_task is None: self._digest_scheduler_task = asyncio.create_task(self._digest_scheduler_loop()) logger.info("Notification digest scheduler started") def stop_digest_scheduler(self): """Stop the background scheduler for daily digests.""" if self._digest_scheduler_task: self._digest_scheduler_task.cancel() self._digest_scheduler_task = None logger.info("Notification digest scheduler stopped") async def _digest_scheduler_loop(self): """Background loop that checks for scheduled digests every minute.""" while True: try: await self.check_and_send_digests() except Exception as e: logger.error(f"Error in digest scheduler: {e}") # Wait until the next minute await asyncio.sleep(60) # Global instance notification_service = NotificationService()