Browse Source

feat: add thumbnail images to Telegram and ntfy notifications (#372)

Pushover and Discord already received print thumbnails, but Telegram
and ntfy only got text. Telegram now uses sendPhoto with the image as
a caption attachment. ntfy sends the image as a binary PUT with
Filename/Message headers. No config changes needed.
maziggy 3 months ago
parent
commit
8b7a1697a8
2 changed files with 32 additions and 14 deletions
  1. 1 0
      CHANGELOG.md
  2. 31 14
      backend/app/services/notification_service.py

+ 1 - 0
CHANGELOG.md

@@ -17,6 +17,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **K-Profiles View — Accurate Filament Name Resolution** — K-profile filament names are now resolved from builtin filament tables and user cloud presets (via new `/cloud/filament-id-map` endpoint) instead of showing raw IDs like "GFU99" or "P4d64437". Falls back to extracting names from the profile name field.
 - **Print Log** — New view mode on the Archives page showing a chronological table of all print activity. Columns include date/time, print name, printer, user, status, duration, and filament. Supports filtering by search text, printer, user, status, and date range. Pagination with configurable page size. A dedicated clear button deletes only log entries without affecting archives. Data is stored in a separate `print_log_entries` database table.
 - **Sync Spool Weights from AMS** — New button in Settings → Filament Tracking (built-in inventory mode) to force-sync all inventory spool weights from the live AMS remain% values of connected printers. Overwrites the database weight data with current sensor readings. Useful for recovering from corrupted weight data (e.g., after a power-off event zeroed all fill levels). Requires printers to be online. Includes a confirmation modal.
+- **Notification Thumbnails for Telegram & ntfy** ([#372](https://github.com/maziggy/bambuddy/issues/372)) — Print thumbnail images are now attached to Telegram and ntfy notifications (previously only Pushover and Discord). Telegram uses the `sendPhoto` API with the image as caption attachment. ntfy sends the image as a binary PUT with `Filename` and `Message` headers. No configuration needed — images are sent automatically when available.
 
 ### Fixed
 - **Firmware Upload Uses Wrong Filename on Cache Hit** — The firmware update uploader cached downloaded firmware files under a mangled name (e.g., `X1C_01_09_00_10.bin`) instead of the original filename from Bambu Lab's CDN. On the first download the correct filename was uploaded to the SD card, but on subsequent attempts the cached file with the wrong name was used — causing the printer to not recognize the firmware file. Now caches using the original filename so the SD card always receives the correct file.

+ 31 - 14
backend/app/services/notification_service.py

@@ -188,7 +188,9 @@ class NotificationService:
         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]:
+    async def _send_ntfy(
+        self, config: dict, title: str, message: str, image_data: bytes | None = None
+    ) -> tuple[bool, str]:
         """Send notification via ntfy."""
         server = config.get("server", "https://ntfy.sh").rstrip("/")
         topic = config.get("topic", "").strip()
@@ -204,7 +206,14 @@ class NotificationService:
             headers["Authorization"] = f"Bearer {auth_token}"
 
         client = await self._get_client()
-        response = await client.post(url, content=message, headers=headers)
+
+        if image_data:
+            # ntfy supports image attachments via multipart form-data
+            headers["Filename"] = "photo.jpg"
+            headers["Message"] = message
+            response = await client.put(url, content=image_data, headers=headers)
+        else:
+            response = await client.post(url, content=message, headers=headers)
 
         if response.status_code in (200, 204):
             return True, "Message sent successfully"
@@ -257,7 +266,7 @@ class NotificationService:
             except Exception:
                 return False, f"HTTP {response.status_code}: {response.text[:200]}"
 
-    async def _send_telegram(self, config: dict, message: str) -> tuple[bool, str]:
+    async def _send_telegram(self, config: dict, message: str, image_data: bytes | None = None) -> tuple[bool, str]:
         """Send notification via Telegram bot."""
         bot_token = config.get("bot_token", "").strip()
         chat_id = config.get("chat_id", "").strip()
@@ -265,8 +274,6 @@ class NotificationService:
         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"
-
         # Escape underscores in the message body so Telegram Markdown
         # parsing doesn't break on job names like "A1_plate_8" or error
         # codes like "0300_0001".  The title is already wrapped in *bold*
@@ -276,14 +283,24 @@ class NotificationService:
             body_part = body_part.replace("_", "\\_")
             message = f"{title_part}\n{body_part}"
 
-        data = {
-            "chat_id": chat_id,
-            "text": message,
-            "parse_mode": "Markdown",
-        }
-
         client = await self._get_client()
-        response = await client.post(url, json=data)
+
+        if image_data:
+            # Use sendPhoto to attach the thumbnail with the caption
+            url = f"https://api.telegram.org/bot{bot_token}/sendPhoto"
+            response = await client.post(
+                url,
+                data={"chat_id": chat_id, "caption": message, "parse_mode": "Markdown"},
+                files={"photo": ("photo.jpg", image_data, "image/jpeg")},
+            )
+        else:
+            url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
+            data = {
+                "chat_id": chat_id,
+                "text": message,
+                "parse_mode": "Markdown",
+            }
+            response = await client.post(url, json=data)
 
         if response.status_code == 200:
             result = response.json()
@@ -451,11 +468,11 @@ class NotificationService:
             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)
+                return await self._send_ntfy(config, title, message, image_data=image_data)
             elif provider.provider_type == "pushover":
                 return await self._send_pushover(config, title, message, image_data=image_data)
             elif provider.provider_type == "telegram":
-                return await self._send_telegram(config, f"*{title}*\n{message}")
+                return await self._send_telegram(config, f"*{title}*\n{message}", image_data=image_data)
             elif provider.provider_type == "email":
                 return await self._send_email(config, title, message)
             elif provider.provider_type == "discord":