Kaynağa Gözat

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 5 ay önce
ebeveyn
işleme
cb0c4b5ffe
2 değiştirilmiş dosya ile 147 ekleme ve 25 silme
  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.
 - **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
 ### 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.
 - **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.
 - **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]}"
             return False, f"HTTP {response.status_code}: {response.text[:200]}"
 
 
     async def _send_webhook(
     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]:
     ) -> tuple[bool, str]:
         """Send notification via generic webhook (POST JSON).
         """Send notification via generic webhook (POST JSON).
 
 
         Supports two payload formats:
         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": "..."})
         - slack: Slack/Mattermost compatible format (just {"text": "..."})
         """
         """
         webhook_url = config.get("webhook_url", "").strip()
         webhook_url = config.get("webhook_url", "").strip()
@@ -460,6 +466,15 @@ class NotificationService:
                 "source": "Bambuddy",
                 "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)
         # Attach base64-encoded image when available (generic format only)
         if image_data and payload_format != "slack":
         if image_data and payload_format != "slack":
             import base64
             import base64
@@ -573,6 +588,8 @@ class NotificationService:
         message: str,
         message: str,
         db: AsyncSession | None = None,
         db: AsyncSession | None = None,
         image_data: bytes | None = None,
         image_data: bytes | None = None,
+        event_type: str | None = None,
+        variables: dict | None = None,
     ) -> tuple[bool, str]:
     ) -> tuple[bool, str]:
         """Send notification to a specific provider."""
         """Send notification to a specific provider."""
         # Check quiet hours
         # Check quiet hours
@@ -596,7 +613,9 @@ class NotificationService:
             elif provider.provider_type == "discord":
             elif provider.provider_type == "discord":
                 return await self._send_discord(config, title, message, image_data=image_data)
                 return await self._send_discord(config, title, message, image_data=image_data)
             elif provider.provider_type == "webhook":
             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":
             elif provider.provider_type == "homeassistant":
                 return await self._send_homeassistant(config, title, message, db=db)
                 return await self._send_homeassistant(config, title, message, db=db)
             else:
             else:
@@ -681,6 +700,7 @@ class NotificationService:
         printer_name: str | None = None,
         printer_name: str | None = None,
         force_immediate: bool = False,
         force_immediate: bool = False,
         image_data: bytes | None = None,
         image_data: bytes | None = None,
+        variables: dict | None = None,
     ):
     ):
         """Send notification to multiple providers and log the results.
         """Send notification to multiple providers and log the results.
 
 
@@ -690,7 +710,9 @@ class NotificationService:
         for provider in providers:
         for provider in providers:
             try:
             try:
                 # Always send notification immediately
                 # 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)
                 # Also queue for digest if enabled (digest is a summary, not a queue)
                 if provider.daily_digest_enabled and provider.daily_digest_time:
                 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])
         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)
         title, message = await self._build_message_from_template(db, "print_start", variables)
         await self._send_to_providers(
         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(
     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])
         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)
         title, message = await self._build_message_from_template(db, event_type, variables)
         await self._send_to_providers(
         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(
     async def on_print_progress(
@@ -931,7 +969,15 @@ class NotificationService:
 
 
         title, message = await self._build_message_from_template(db, "print_progress", variables)
         title, message = await self._build_message_from_template(db, "print_progress", variables)
         await self._send_to_providers(
         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(
     async def on_print_missing_spool_assignment(
@@ -973,6 +1019,7 @@ class NotificationService:
             printer_id,
             printer_id,
             printer_name,
             printer_name,
             force_immediate=True,
             force_immediate=True,
+            variables=variables,
         )
         )
 
 
     async def on_printer_offline(self, printer_id: int, printer_name: str, db: AsyncSession):
     async def on_printer_offline(self, printer_id: int, printer_name: str, db: AsyncSession):
@@ -984,7 +1031,9 @@ class NotificationService:
         variables = {"printer": printer_name}
         variables = {"printer": printer_name}
 
 
         title, message = await self._build_message_from_template(db, "printer_offline", variables)
         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(
     async def on_printer_error(
         self,
         self,
@@ -1008,7 +1057,15 @@ class NotificationService:
 
 
         title, message = await self._build_message_from_template(db, "printer_error", variables)
         title, message = await self._build_message_from_template(db, "printer_error", variables)
         await self._send_to_providers(
         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(
     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)
         title, message = await self._build_message_from_template(db, "plate_not_empty", variables)
         await self._send_to_providers(
         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(
     async def on_filament_low(
@@ -1055,7 +1120,9 @@ class NotificationService:
         }
         }
 
 
         title, message = await self._build_message_from_template(db, "filament_low", variables)
         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(
     async def on_maintenance_due(
         self,
         self,
@@ -1087,7 +1154,9 @@ class NotificationService:
 
 
         logger.info("Found %s providers for maintenance_due: %s", len(providers), [p.name for p in providers])
         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)
         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(
     async def on_ams_humidity_high(
         self,
         self,
@@ -1113,7 +1182,15 @@ class NotificationService:
         title, message = await self._build_message_from_template(db, "ams_humidity_high", variables)
         title, message = await self._build_message_from_template(db, "ams_humidity_high", variables)
         # Alarms always send immediately, bypassing digest mode
         # Alarms always send immediately, bypassing digest mode
         await self._send_to_providers(
         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(
     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)
         title, message = await self._build_message_from_template(db, "ams_temperature_high", variables)
         # Alarms always send immediately, bypassing digest mode
         # Alarms always send immediately, bypassing digest mode
         await self._send_to_providers(
         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(
     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)
         title, message = await self._build_message_from_template(db, "ams_humidity_high", variables)
         # Alarms always send immediately, bypassing digest mode
         # Alarms always send immediately, bypassing digest mode
         await self._send_to_providers(
         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(
     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)
         title, message = await self._build_message_from_template(db, "ams_temperature_high", variables)
         # Alarms always send immediately, bypassing digest mode
         # Alarms always send immediately, bypassing digest mode
         await self._send_to_providers(
         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(
     async def on_bed_cooled(
@@ -1221,7 +1322,9 @@ class NotificationService:
         }
         }
 
 
         title, message = await self._build_message_from_template(db, "bed_cooled", variables)
         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(
     async def on_first_layer_complete(
         self,
         self,
@@ -1245,7 +1348,15 @@ class NotificationService:
 
 
         title, message = await self._build_message_from_template(db, "first_layer_complete", variables)
         title, message = await self._build_message_from_template(db, "first_layer_complete", variables)
         await self._send_to_providers(
         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):
     def clear_template_cache(self):
@@ -1388,7 +1499,9 @@ class NotificationService:
         }
         }
 
 
         title, message = await self._build_message_from_template(db, "queue_job_added", variables)
         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(
     async def on_queue_job_assigned(
         self,
         self,
@@ -1410,7 +1523,9 @@ class NotificationService:
         }
         }
 
 
         title, message = await self._build_message_from_template(db, "queue_job_assigned", variables)
         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(
     async def on_queue_job_started(
         self,
         self,
@@ -1435,7 +1550,9 @@ class NotificationService:
         }
         }
 
 
         title, message = await self._build_message_from_template(db, "queue_job_started", variables)
         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(
     async def on_queue_job_waiting(
         self,
         self,
@@ -1456,7 +1573,7 @@ class NotificationService:
         }
         }
 
 
         title, message = await self._build_message_from_template(db, "queue_job_waiting", variables)
         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(
     async def on_queue_job_skipped(
         self,
         self,
@@ -1478,7 +1595,9 @@ class NotificationService:
         }
         }
 
 
         title, message = await self._build_message_from_template(db, "queue_job_skipped", variables)
         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(
     async def on_queue_job_failed(
         self,
         self,
@@ -1500,7 +1619,9 @@ class NotificationService:
         }
         }
 
 
         title, message = await self._build_message_from_template(db, "queue_job_failed", variables)
         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(
     async def on_queue_completed(
         self,
         self,
@@ -1517,7 +1638,7 @@ class NotificationService:
         }
         }
 
 
         title, message = await self._build_message_from_template(db, "queue_completed", variables)
         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(
     async def _queue_for_digest(
         self,
         self,