Explorar o código

Standardize webhook notification payloads with structured event data (#871)

  Thread event_type and template variables through the webhook call chain so
  all generic webhook payloads include an "event" field and event-specific
  data (printer, filename, duration, etc.) as top-level JSON fields. Existing
  title/message/timestamp/source fields are unchanged. Slack format unaffected.
maziggy hai 1 mes
pai
achega
cb0c4b5ffe
Modificáronse 2 ficheiros con 147 adicións e 25 borrados
  1. 1 0
      CHANGELOG.md
  2. 146 25
      backend/app/services/notification_service.py

+ 1 - 0
CHANGELOG.md

@@ -18,6 +18,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **GitHub Backup: Spool Inventory & Print Archives** ([#870](https://github.com/maziggy/bambuddy/issues/870)) — GitHub backup can now include spool inventory and print archive history as optional toggles alongside the existing K-profiles, cloud profiles, and settings. Spool backup exports all spools with their material, brand, color, weight, cost tracking, RFID tags, and full usage history. Archive backup exports print history metadata (filament, temperatures, times, costs, energy) — no gcode/3MF binary files. Both are off by default and can be enabled independently in Settings → Backup & Restore.
 
 ### Improved
+- **Standardized Webhook Notification Payloads** ([#871](https://github.com/maziggy/bambuddy/issues/871)) — Custom webhook notifications now include structured event data fields (`event`, `printer`, `filename`, `duration`, etc.) alongside the existing `title`, `message`, `timestamp`, and `source` fields. Previously, only `title` and `message` were sent, requiring automation tools to parse the message text for event details. All event-specific template variables are now included as top-level JSON fields, making it easy for n8n, Node-RED, Home Assistant, and other automation platforms to route and process notifications based on structured data. Slack/Mattermost format is unchanged.
 - **Queue Page Visual Refresh** — Compact stats bar replaces the five summary cards (saves vertical space), color-coded left borders on all queue items for instant status scanning, collapsible history section (collapsed by default), and condensed single-line rows for history items showing more prints at a glance.
 - **Developer Mode Detection for A1/P1 Printers** — Printers that don't send the `fun` field in MQTT status (A1, P1 series) now have developer mode detected via a probe command. After receiving the first full status update, Bambuddy sends a no-op external slot configure and checks whether the printer accepts or rejects it (`mqtt message verify failed`). Printers that do send the `fun` field (X1C, H2D, etc.) continue to use the existing bit-based detection. Developer mode state is re-checked on every reconnect.
 

+ 146 - 25
backend/app/services/notification_service.py

@@ -430,12 +430,18 @@ class NotificationService:
             return False, f"HTTP {response.status_code}: {response.text[:200]}"
 
     async def _send_webhook(
-        self, config: dict, title: str, message: str, image_data: bytes | None = None
+        self,
+        config: dict,
+        title: str,
+        message: str,
+        image_data: bytes | None = None,
+        event_type: str | None = None,
+        variables: dict | None = None,
     ) -> tuple[bool, str]:
         """Send notification via generic webhook (POST JSON).
 
         Supports two payload formats:
-        - generic: Custom field names with timestamp/source metadata
+        - generic: Custom field names with timestamp/source metadata + structured event data
         - slack: Slack/Mattermost compatible format (just {"text": "..."})
         """
         webhook_url = config.get("webhook_url", "").strip()
@@ -460,6 +466,15 @@ class NotificationService:
                 "source": "Bambuddy",
             }
 
+        # For generic format, include structured event data for automation tools
+        if payload_format != "slack":
+            if event_type:
+                data["event"] = event_type
+            if variables:
+                for key, value in variables.items():
+                    if key not in data:  # Don't overwrite title/message/timestamp/source
+                        data[key] = value
+
         # Attach base64-encoded image when available (generic format only)
         if image_data and payload_format != "slack":
             import base64
@@ -573,6 +588,8 @@ class NotificationService:
         message: str,
         db: AsyncSession | None = None,
         image_data: bytes | None = None,
+        event_type: str | None = None,
+        variables: dict | None = None,
     ) -> tuple[bool, str]:
         """Send notification to a specific provider."""
         # Check quiet hours
@@ -596,7 +613,9 @@ class NotificationService:
             elif provider.provider_type == "discord":
                 return await self._send_discord(config, title, message, image_data=image_data)
             elif provider.provider_type == "webhook":
-                return await self._send_webhook(config, title, message, image_data=image_data)
+                return await self._send_webhook(
+                    config, title, message, image_data=image_data, event_type=event_type, variables=variables
+                )
             elif provider.provider_type == "homeassistant":
                 return await self._send_homeassistant(config, title, message, db=db)
             else:
@@ -681,6 +700,7 @@ class NotificationService:
         printer_name: str | None = None,
         force_immediate: bool = False,
         image_data: bytes | None = None,
+        variables: dict | None = None,
     ):
         """Send notification to multiple providers and log the results.
 
@@ -690,7 +710,9 @@ class NotificationService:
         for provider in providers:
             try:
                 # Always send notification immediately
-                success, error = await self._send_to_provider(provider, title, message, db, image_data=image_data)
+                success, error = await self._send_to_provider(
+                    provider, title, message, db, image_data=image_data, event_type=event_type, variables=variables
+                )
 
                 # Also queue for digest if enabled (digest is a summary, not a queue)
                 if provider.daily_digest_enabled and provider.daily_digest_time:
@@ -807,7 +829,15 @@ class NotificationService:
         logger.info("Found %s providers for print_start: %s", len(providers), [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, image_data=image_data
+            providers,
+            title,
+            message,
+            db,
+            "print_start",
+            printer_id,
+            printer_name,
+            image_data=image_data,
+            variables=variables,
         )
 
     async def on_print_complete(
@@ -901,7 +931,15 @@ class NotificationService:
         logger.info("Found %s providers for %s: %s", len(providers), 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, image_data=image_data
+            providers,
+            title,
+            message,
+            db,
+            event_type,
+            printer_id,
+            printer_name,
+            image_data=image_data,
+            variables=variables,
         )
 
     async def on_print_progress(
@@ -931,7 +969,15 @@ class NotificationService:
 
         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, image_data=image_data
+            providers,
+            title,
+            message,
+            db,
+            "print_progress",
+            printer_id,
+            printer_name,
+            image_data=image_data,
+            variables=variables,
         )
 
     async def on_print_missing_spool_assignment(
@@ -973,6 +1019,7 @@ class NotificationService:
             printer_id,
             printer_name,
             force_immediate=True,
+            variables=variables,
         )
 
     async def on_printer_offline(self, printer_id: int, printer_name: str, db: AsyncSession):
@@ -984,7 +1031,9 @@ class NotificationService:
         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)
+        await self._send_to_providers(
+            providers, title, message, db, "printer_offline", printer_id, printer_name, variables=variables
+        )
 
     async def on_printer_error(
         self,
@@ -1008,7 +1057,15 @@ class NotificationService:
 
         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, image_data=image_data
+            providers,
+            title,
+            message,
+            db,
+            "printer_error",
+            printer_id,
+            printer_name,
+            image_data=image_data,
+            variables=variables,
         )
 
     async def on_plate_not_empty(
@@ -1030,7 +1087,15 @@ class NotificationService:
 
         title, message = await self._build_message_from_template(db, "plate_not_empty", variables)
         await self._send_to_providers(
-            providers, title, message, db, "plate_not_empty", printer_id, printer_name, force_immediate=True
+            providers,
+            title,
+            message,
+            db,
+            "plate_not_empty",
+            printer_id,
+            printer_name,
+            force_immediate=True,
+            variables=variables,
         )
 
     async def on_filament_low(
@@ -1055,7 +1120,9 @@ class NotificationService:
         }
 
         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)
+        await self._send_to_providers(
+            providers, title, message, db, "filament_low", printer_id, printer_name, variables=variables
+        )
 
     async def on_maintenance_due(
         self,
@@ -1087,7 +1154,9 @@ class NotificationService:
 
         logger.info("Found %s providers for maintenance_due: %s", len(providers), [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)
+        await self._send_to_providers(
+            providers, title, message, db, "maintenance_due", printer_id, printer_name, variables=variables
+        )
 
     async def on_ams_humidity_high(
         self,
@@ -1113,7 +1182,15 @@ class NotificationService:
         title, message = await self._build_message_from_template(db, "ams_humidity_high", variables)
         # Alarms always send immediately, bypassing digest mode
         await self._send_to_providers(
-            providers, title, message, db, "ams_humidity_high", printer_id, printer_name, force_immediate=True
+            providers,
+            title,
+            message,
+            db,
+            "ams_humidity_high",
+            printer_id,
+            printer_name,
+            force_immediate=True,
+            variables=variables,
         )
 
     async def on_ams_temperature_high(
@@ -1140,7 +1217,15 @@ class NotificationService:
         title, message = await self._build_message_from_template(db, "ams_temperature_high", variables)
         # Alarms always send immediately, bypassing digest mode
         await self._send_to_providers(
-            providers, title, message, db, "ams_temperature_high", printer_id, printer_name, force_immediate=True
+            providers,
+            title,
+            message,
+            db,
+            "ams_temperature_high",
+            printer_id,
+            printer_name,
+            force_immediate=True,
+            variables=variables,
         )
 
     async def on_ams_ht_humidity_high(
@@ -1168,7 +1253,15 @@ class NotificationService:
         title, message = await self._build_message_from_template(db, "ams_humidity_high", variables)
         # Alarms always send immediately, bypassing digest mode
         await self._send_to_providers(
-            providers, title, message, db, "ams_ht_humidity_high", printer_id, printer_name, force_immediate=True
+            providers,
+            title,
+            message,
+            db,
+            "ams_ht_humidity_high",
+            printer_id,
+            printer_name,
+            force_immediate=True,
+            variables=variables,
         )
 
     async def on_ams_ht_temperature_high(
@@ -1196,7 +1289,15 @@ class NotificationService:
         title, message = await self._build_message_from_template(db, "ams_temperature_high", variables)
         # Alarms always send immediately, bypassing digest mode
         await self._send_to_providers(
-            providers, title, message, db, "ams_ht_temperature_high", printer_id, printer_name, force_immediate=True
+            providers,
+            title,
+            message,
+            db,
+            "ams_ht_temperature_high",
+            printer_id,
+            printer_name,
+            force_immediate=True,
+            variables=variables,
         )
 
     async def on_bed_cooled(
@@ -1221,7 +1322,9 @@ class NotificationService:
         }
 
         title, message = await self._build_message_from_template(db, "bed_cooled", variables)
-        await self._send_to_providers(providers, title, message, db, "bed_cooled", printer_id, printer_name)
+        await self._send_to_providers(
+            providers, title, message, db, "bed_cooled", printer_id, printer_name, variables=variables
+        )
 
     async def on_first_layer_complete(
         self,
@@ -1245,7 +1348,15 @@ class NotificationService:
 
         title, message = await self._build_message_from_template(db, "first_layer_complete", variables)
         await self._send_to_providers(
-            providers, title, message, db, "first_layer_complete", printer_id, printer_name, image_data=image_data
+            providers,
+            title,
+            message,
+            db,
+            "first_layer_complete",
+            printer_id,
+            printer_name,
+            image_data=image_data,
+            variables=variables,
         )
 
     def clear_template_cache(self):
@@ -1388,7 +1499,9 @@ class NotificationService:
         }
 
         title, message = await self._build_message_from_template(db, "queue_job_added", variables)
-        await self._send_to_providers(providers, title, message, db, "queue_job_added", printer_id, printer_name)
+        await self._send_to_providers(
+            providers, title, message, db, "queue_job_added", printer_id, printer_name, variables=variables
+        )
 
     async def on_queue_job_assigned(
         self,
@@ -1410,7 +1523,9 @@ class NotificationService:
         }
 
         title, message = await self._build_message_from_template(db, "queue_job_assigned", variables)
-        await self._send_to_providers(providers, title, message, db, "queue_job_assigned", printer_id, printer_name)
+        await self._send_to_providers(
+            providers, title, message, db, "queue_job_assigned", printer_id, printer_name, variables=variables
+        )
 
     async def on_queue_job_started(
         self,
@@ -1435,7 +1550,9 @@ class NotificationService:
         }
 
         title, message = await self._build_message_from_template(db, "queue_job_started", variables)
-        await self._send_to_providers(providers, title, message, db, "queue_job_started", printer_id, printer_name)
+        await self._send_to_providers(
+            providers, title, message, db, "queue_job_started", printer_id, printer_name, variables=variables
+        )
 
     async def on_queue_job_waiting(
         self,
@@ -1456,7 +1573,7 @@ class NotificationService:
         }
 
         title, message = await self._build_message_from_template(db, "queue_job_waiting", variables)
-        await self._send_to_providers(providers, title, message, db, "queue_job_waiting")
+        await self._send_to_providers(providers, title, message, db, "queue_job_waiting", variables=variables)
 
     async def on_queue_job_skipped(
         self,
@@ -1478,7 +1595,9 @@ class NotificationService:
         }
 
         title, message = await self._build_message_from_template(db, "queue_job_skipped", variables)
-        await self._send_to_providers(providers, title, message, db, "queue_job_skipped", printer_id, printer_name)
+        await self._send_to_providers(
+            providers, title, message, db, "queue_job_skipped", printer_id, printer_name, variables=variables
+        )
 
     async def on_queue_job_failed(
         self,
@@ -1500,7 +1619,9 @@ class NotificationService:
         }
 
         title, message = await self._build_message_from_template(db, "queue_job_failed", variables)
-        await self._send_to_providers(providers, title, message, db, "queue_job_failed", printer_id, printer_name)
+        await self._send_to_providers(
+            providers, title, message, db, "queue_job_failed", printer_id, printer_name, variables=variables
+        )
 
     async def on_queue_completed(
         self,
@@ -1517,7 +1638,7 @@ class NotificationService:
         }
 
         title, message = await self._build_message_from_template(db, "queue_completed", variables)
-        await self._send_to_providers(providers, title, message, db, "queue_completed")
+        await self._send_to_providers(providers, title, message, db, "queue_completed", variables=variables)
 
     async def _queue_for_digest(
         self,