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).
This commit is contained in:
maziggy
2026-03-16 15:10:39 +01:00
parent 105ed7b50a
commit 446da087a0
3 changed files with 92 additions and 2 deletions
+1
View File
@@ -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
View File
@@ -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:
@@ -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."""