Browse Source

Fix webhook notifications missing camera snapshot (#679)

  Webhook providers did not include image data (e.g. camera snapshots
  from first layer complete notifications) even though other providers
  like Telegram, Pushover, and Discord already attached them. The webhook
  payload now includes a base64-encoded "image" field when a snapshot is
  available (generic format only, excluded from Slack format).
maziggy 2 months ago
parent
commit
446da087a0

+ 1 - 0
CHANGELOG.md

@@ -7,6 +7,7 @@ All notable changes to Bambuddy will be documented in this file.
 ### New Features
 
 ### Fixed
+- **Webhook Notifications Missing Camera Snapshot** ([#679](https://github.com/maziggy/bambuddy/issues/679)) — Webhook notification providers did not include camera snapshots (e.g. from First Layer Complete notifications), even though providers like Telegram, Pushover, ntfy, and Discord already attached them. The webhook payload now includes a base64-encoded `image` field when a snapshot is available (generic format only, not Slack format). Reported by @Arn0uDz.
 
 ### Changed
 

+ 10 - 2
backend/app/services/notification_service.py

@@ -425,7 +425,9 @@ class NotificationService:
         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]:
+    async def _send_webhook(
+        self, config: dict, title: str, message: str, image_data: bytes | None = None
+    ) -> tuple[bool, str]:
         """Send notification via generic webhook (POST JSON).
 
         Supports two payload formats:
@@ -454,6 +456,12 @@ class NotificationService:
                 "source": "Bambuddy",
             }
 
+        # Attach base64-encoded image when available (generic format only)
+        if image_data and payload_format != "slack":
+            import base64
+
+            data["image"] = base64.b64encode(image_data).decode("ascii")
+
         headers = {"Content-Type": "application/json"}
         if auth_header:
             # Support "Bearer token" or just "token" format
@@ -556,7 +564,7 @@ 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)
+                return await self._send_webhook(config, title, message, image_data=image_data)
             elif provider.provider_type == "homeassistant":
                 return await self._send_homeassistant(config, title, message, db=db)
             else:

+ 81 - 0
backend/tests/unit/services/test_notification_service.py

@@ -588,6 +588,87 @@ class TestNotificationProviderTypes:
             assert "timestamp" not in payload
             assert "source" not in payload
 
+    @pytest.mark.asyncio
+    async def test_webhook_generic_format_includes_image(self, service):
+        """Verify generic webhook includes base64-encoded image when provided."""
+        config = {
+            "webhook_url": "http://test.local/webhook",
+            "field_title": "title",
+            "field_message": "message",
+        }
+
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+
+        with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
+            mock_get_client.return_value = mock_client
+
+            image_bytes = b"\xff\xd8\xff\xe0fake-jpeg-data"
+            success, message = await service._send_webhook(config, "Test Title", "Test Message", image_data=image_bytes)
+
+            assert success is True
+            call_args = mock_client.post.call_args
+            payload = call_args.kwargs.get("json") or call_args[1].get("json")
+            assert "image" in payload
+
+            import base64
+
+            assert payload["image"] == base64.b64encode(image_bytes).decode("ascii")
+
+    @pytest.mark.asyncio
+    async def test_webhook_generic_format_no_image_when_none(self, service):
+        """Verify generic webhook omits image field when no image_data provided."""
+        config = {
+            "webhook_url": "http://test.local/webhook",
+            "field_title": "title",
+            "field_message": "message",
+        }
+
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+
+        with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
+            mock_get_client.return_value = mock_client
+
+            success, message = await service._send_webhook(config, "Test Title", "Test Message")
+
+            assert success is True
+            call_args = mock_client.post.call_args
+            payload = call_args.kwargs.get("json") or call_args[1].get("json")
+            assert "image" not in payload
+
+    @pytest.mark.asyncio
+    async def test_webhook_slack_format_excludes_image(self, service):
+        """Verify Slack format does not include image even when provided."""
+        config = {
+            "webhook_url": "http://mattermost.local/hooks/abc123",
+            "payload_format": "slack",
+        }
+
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+
+        with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
+            mock_get_client.return_value = mock_client
+
+            success, message = await service._send_webhook(
+                config, "Test Title", "Test Message", image_data=b"fake-image"
+            )
+
+            assert success is True
+            call_args = mock_client.post.call_args
+            payload = call_args.kwargs.get("json") or call_args[1].get("json")
+            assert "image" not in payload
+
 
 class TestHomeAssistantProvider:
     """Tests for Home Assistant notification provider."""