Add document type and content_type support for send_telegram_notification
All checks were successful
Validate / Hassfest (push) Successful in 3s
All checks were successful
Validate / Hassfest (push) Successful in 3s
- Add type: document as default media type (instead of photo) - Add optional content_type field for explicit MIME type specification - Documents are sent separately (Telegram API limitation for media groups) - Default content types: image/jpeg (photo), video/mp4 (video), auto-detect (document) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -348,18 +348,39 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
|
||||
try:
|
||||
# Handle single photo
|
||||
if len(urls) == 1 and urls[0].get("type", "photo") == "photo":
|
||||
if len(urls) == 1 and urls[0].get("type") == "photo":
|
||||
return await self._send_telegram_photo(
|
||||
session, token, chat_id, urls[0].get("url"), caption, reply_to_message_id, parse_mode,
|
||||
max_asset_data_size, send_large_photos_as_documents
|
||||
max_asset_data_size, send_large_photos_as_documents, urls[0].get("content_type")
|
||||
)
|
||||
|
||||
# Handle single video
|
||||
if len(urls) == 1 and urls[0].get("type") == "video":
|
||||
return await self._send_telegram_video(
|
||||
session, token, chat_id, urls[0].get("url"), caption, reply_to_message_id, parse_mode, max_asset_data_size
|
||||
session, token, chat_id, urls[0].get("url"), caption, reply_to_message_id, parse_mode,
|
||||
max_asset_data_size, urls[0].get("content_type")
|
||||
)
|
||||
|
||||
# Handle single document (default type)
|
||||
if len(urls) == 1 and urls[0].get("type", "document") == "document":
|
||||
url = urls[0].get("url")
|
||||
item_content_type = urls[0].get("content_type")
|
||||
try:
|
||||
download_url = self.coordinator.get_internal_download_url(url)
|
||||
async with session.get(download_url) as resp:
|
||||
if resp.status != 200:
|
||||
return {"success": False, "error": f"Failed to download media: HTTP {resp.status}"}
|
||||
data = await resp.read()
|
||||
if max_asset_data_size is not None and len(data) > max_asset_data_size:
|
||||
return {"success": False, "error": f"Media size ({len(data)} bytes) exceeds max_asset_data_size limit ({max_asset_data_size} bytes)"}
|
||||
# Detect filename from URL or use generic name
|
||||
filename = url.split("/")[-1].split("?")[0] or "file"
|
||||
return await self._send_telegram_document(
|
||||
session, token, chat_id, data, filename, caption, reply_to_message_id, parse_mode, url, item_content_type
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
return {"success": False, "error": f"Failed to download media: {err}"}
|
||||
|
||||
# Handle multiple items - send as media group(s)
|
||||
return await self._send_telegram_media_group(
|
||||
session, token, chat_id, urls, caption, reply_to_message_id, max_group_size, chunk_delay, parse_mode,
|
||||
@@ -599,11 +620,16 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
parse_mode: str = "HTML",
|
||||
max_asset_data_size: int | None = None,
|
||||
send_large_photos_as_documents: bool = False,
|
||||
content_type: str | None = None,
|
||||
) -> ServiceResponse:
|
||||
"""Send a single photo to Telegram."""
|
||||
import aiohttp
|
||||
from aiohttp import FormData
|
||||
|
||||
# Use provided content type or default to image/jpeg
|
||||
if not content_type:
|
||||
content_type = "image/jpeg"
|
||||
|
||||
if not url:
|
||||
return {"success": False, "error": "Missing 'url' for photo"}
|
||||
|
||||
@@ -689,7 +715,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
# Build multipart form
|
||||
form = FormData()
|
||||
form.add_field("chat_id", chat_id)
|
||||
form.add_field("photo", data, filename="photo.jpg", content_type="image/jpeg")
|
||||
form.add_field("photo", data, filename="photo.jpg", content_type=content_type)
|
||||
form.add_field("parse_mode", parse_mode)
|
||||
|
||||
if caption:
|
||||
@@ -745,11 +771,16 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
reply_to_message_id: int | None = None,
|
||||
parse_mode: str = "HTML",
|
||||
max_asset_data_size: int | None = None,
|
||||
content_type: str | None = None,
|
||||
) -> ServiceResponse:
|
||||
"""Send a single video to Telegram."""
|
||||
import aiohttp
|
||||
from aiohttp import FormData
|
||||
|
||||
# Use provided content type or default to video/mp4
|
||||
if not content_type:
|
||||
content_type = "video/mp4"
|
||||
|
||||
if not url:
|
||||
return {"success": False, "error": "Missing 'url' for video"}
|
||||
|
||||
@@ -816,7 +847,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
# Build multipart form
|
||||
form = FormData()
|
||||
form.add_field("chat_id", chat_id)
|
||||
form.add_field("video", data, filename="video.mp4", content_type="video/mp4")
|
||||
form.add_field("video", data, filename="video.mp4", content_type=content_type)
|
||||
form.add_field("parse_mode", parse_mode)
|
||||
|
||||
if caption:
|
||||
@@ -867,16 +898,24 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
token: str,
|
||||
chat_id: str,
|
||||
data: bytes,
|
||||
filename: str = "photo.jpg",
|
||||
filename: str = "file",
|
||||
caption: str | None = None,
|
||||
reply_to_message_id: int | None = None,
|
||||
parse_mode: str = "HTML",
|
||||
source_url: str | None = None,
|
||||
content_type: str | None = None,
|
||||
) -> ServiceResponse:
|
||||
"""Send a photo as a document to Telegram (for oversized photos)."""
|
||||
"""Send a file as a document to Telegram."""
|
||||
import aiohttp
|
||||
import mimetypes
|
||||
from aiohttp import FormData
|
||||
|
||||
# Use provided content type or detect from filename
|
||||
if not content_type:
|
||||
content_type, _ = mimetypes.guess_type(filename)
|
||||
if not content_type:
|
||||
content_type = "application/octet-stream"
|
||||
|
||||
# Check cache for file_id if source_url is provided
|
||||
cache = self.coordinator.telegram_cache
|
||||
if source_url:
|
||||
@@ -915,7 +954,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
# Build multipart form
|
||||
form = FormData()
|
||||
form.add_field("chat_id", chat_id)
|
||||
form.add_field("document", data, filename=filename, content_type="image/jpeg")
|
||||
form.add_field("document", data, filename=filename, content_type=content_type)
|
||||
form.add_field("parse_mode", parse_mode)
|
||||
|
||||
if caption:
|
||||
@@ -927,7 +966,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
# Send to Telegram
|
||||
telegram_url = f"https://api.telegram.org/bot{token}/sendDocument"
|
||||
|
||||
_LOGGER.debug("Uploading oversized photo as document to Telegram (%d bytes)", len(data))
|
||||
_LOGGER.debug("Uploading document to Telegram (%d bytes, %s)", len(data), content_type)
|
||||
async with session.post(telegram_url, data=form) as response:
|
||||
result = await response.json()
|
||||
_LOGGER.debug("Telegram API response: status=%d, ok=%s", response.status, result.get("ok"))
|
||||
@@ -1003,8 +1042,9 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
# Optimize: Use single-item APIs for chunks with 1 item
|
||||
if len(chunk) == 1:
|
||||
item = chunk[0]
|
||||
media_type = item.get("type", "photo")
|
||||
media_type = item.get("type", "document")
|
||||
url = item.get("url")
|
||||
item_content_type = item.get("content_type")
|
||||
|
||||
# Only apply caption and reply_to to the first chunk
|
||||
chunk_caption = caption if chunk_idx == 0 else None
|
||||
@@ -1014,13 +1054,31 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
_LOGGER.debug("Sending chunk %d/%d as single photo", chunk_idx + 1, len(chunks))
|
||||
result = await self._send_telegram_photo(
|
||||
session, token, chat_id, url, chunk_caption, chunk_reply_to, parse_mode,
|
||||
max_asset_data_size, send_large_photos_as_documents
|
||||
max_asset_data_size, send_large_photos_as_documents, item_content_type
|
||||
)
|
||||
else: # video
|
||||
elif media_type == "video":
|
||||
_LOGGER.debug("Sending chunk %d/%d as single video", chunk_idx + 1, len(chunks))
|
||||
result = await self._send_telegram_video(
|
||||
session, token, chat_id, url, chunk_caption, chunk_reply_to, parse_mode, max_asset_data_size
|
||||
session, token, chat_id, url, chunk_caption, chunk_reply_to, parse_mode,
|
||||
max_asset_data_size, item_content_type
|
||||
)
|
||||
else: # document
|
||||
_LOGGER.debug("Sending chunk %d/%d as single document", chunk_idx + 1, len(chunks))
|
||||
try:
|
||||
download_url = self.coordinator.get_internal_download_url(url)
|
||||
async with session.get(download_url) as resp:
|
||||
if resp.status != 200:
|
||||
return {"success": False, "error": f"Failed to download media: HTTP {resp.status}", "failed_at_chunk": chunk_idx + 1}
|
||||
data = await resp.read()
|
||||
if max_asset_data_size is not None and len(data) > max_asset_data_size:
|
||||
_LOGGER.warning("Media size (%d bytes) exceeds max_asset_data_size limit (%d bytes), skipping", len(data), max_asset_data_size)
|
||||
continue
|
||||
filename = url.split("/")[-1].split("?")[0] or "file"
|
||||
result = await self._send_telegram_document(
|
||||
session, token, chat_id, data, filename, chunk_caption, chunk_reply_to, parse_mode, url, item_content_type
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
return {"success": False, "error": f"Failed to download media: {err}", "failed_at_chunk": chunk_idx + 1}
|
||||
|
||||
if not result.get("success"):
|
||||
result["failed_at_chunk"] = chunk_idx + 1
|
||||
@@ -1035,15 +1093,17 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
cache = self.coordinator.telegram_cache
|
||||
|
||||
# Collect media items - either from cache (file_id) or by downloading
|
||||
# Each item: (type, media_ref, filename, url, is_cached)
|
||||
# Each item: (type, media_ref, filename, url, is_cached, content_type)
|
||||
# media_ref is either file_id (str) or data (bytes)
|
||||
media_items: list[tuple[str, str | bytes, str, str, bool]] = []
|
||||
media_items: list[tuple[str, str | bytes, str, str, bool, str | None]] = []
|
||||
oversized_photos: list[tuple[bytes, str | None, str]] = [] # (data, caption, url)
|
||||
documents_to_send: list[tuple[bytes, str | None, str, str, str | None]] = [] # (data, caption, url, filename, content_type)
|
||||
skipped_count = 0
|
||||
|
||||
for i, item in enumerate(chunk):
|
||||
url = item.get("url")
|
||||
media_type = item.get("type", "photo")
|
||||
media_type = item.get("type", "document")
|
||||
item_content_type = item.get("content_type")
|
||||
|
||||
if not url:
|
||||
return {
|
||||
@@ -1051,19 +1111,48 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
"error": f"Missing 'url' in item {chunk_idx * max_group_size + i}",
|
||||
}
|
||||
|
||||
if media_type not in ("photo", "video"):
|
||||
if media_type not in ("photo", "video", "document"):
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Invalid type '{media_type}' in item {chunk_idx * max_group_size + i}. Must be 'photo' or 'video'.",
|
||||
"error": f"Invalid type '{media_type}' in item {chunk_idx * max_group_size + i}. Must be 'photo', 'video', or 'document'.",
|
||||
}
|
||||
|
||||
# Check cache first
|
||||
# Documents can't be in media groups - collect them for separate sending
|
||||
if media_type == "document":
|
||||
try:
|
||||
download_url = self.coordinator.get_internal_download_url(url)
|
||||
async with session.get(download_url) as resp:
|
||||
if resp.status != 200:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed to download media {chunk_idx * max_group_size + i}: HTTP {resp.status}",
|
||||
}
|
||||
data = await resp.read()
|
||||
if max_asset_data_size is not None and len(data) > max_asset_data_size:
|
||||
_LOGGER.warning(
|
||||
"Media %d size (%d bytes) exceeds max_asset_data_size limit (%d bytes), skipping",
|
||||
chunk_idx * max_group_size + i, len(data), max_asset_data_size
|
||||
)
|
||||
skipped_count += 1
|
||||
continue
|
||||
# Caption only on first item of first chunk if no media items yet
|
||||
doc_caption = caption if chunk_idx == 0 and i == 0 and len(media_items) == 0 and len(documents_to_send) == 0 else None
|
||||
filename = url.split("/")[-1].split("?")[0] or f"file_{i}"
|
||||
documents_to_send.append((data, doc_caption, url, filename, item_content_type))
|
||||
except aiohttp.ClientError as err:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed to download media {chunk_idx * max_group_size + i}: {err}",
|
||||
}
|
||||
continue
|
||||
|
||||
# Check cache first for photos/videos
|
||||
cached = cache.get(url) if cache else None
|
||||
if cached and cached.get("file_id"):
|
||||
# Use cached file_id
|
||||
ext = "jpg" if media_type == "photo" else "mp4"
|
||||
filename = f"media_{chunk_idx * max_group_size + i}.{ext}"
|
||||
media_items.append((media_type, cached["file_id"], filename, url, True))
|
||||
media_items.append((media_type, cached["file_id"], filename, url, True, item_content_type))
|
||||
_LOGGER.debug("Using cached file_id for media %d", chunk_idx * max_group_size + i)
|
||||
continue
|
||||
|
||||
@@ -1108,7 +1197,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
|
||||
ext = "jpg" if media_type == "photo" else "mp4"
|
||||
filename = f"media_{chunk_idx * max_group_size + i}.{ext}"
|
||||
media_items.append((media_type, data, filename, url, False))
|
||||
media_items.append((media_type, data, filename, url, False, item_content_type))
|
||||
except aiohttp.ClientError as err:
|
||||
return {
|
||||
"success": False,
|
||||
@@ -1124,13 +1213,13 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
# Send media group if we have normal-sized files
|
||||
if media_items:
|
||||
# Check if all items are cached (can use simple JSON payload)
|
||||
all_cached = all(is_cached for _, _, _, _, is_cached in media_items)
|
||||
all_cached = all(is_cached for _, _, _, _, is_cached, _ in media_items)
|
||||
|
||||
if all_cached:
|
||||
# All items cached - use simple JSON payload with file_ids
|
||||
_LOGGER.debug("All %d items cached, using file_ids", len(media_items))
|
||||
media_json = []
|
||||
for i, (media_type, file_id, _, _, _) in enumerate(media_items):
|
||||
for i, (media_type, file_id, _, _, _, _) in enumerate(media_items):
|
||||
media_item_json: dict[str, Any] = {
|
||||
"type": media_type,
|
||||
"media": file_id,
|
||||
@@ -1178,7 +1267,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
upload_idx = 0
|
||||
urls_to_cache: list[tuple[str, int, str]] = [] # (url, result_idx, type)
|
||||
|
||||
for i, (media_type, media_ref, filename, url, is_cached) in enumerate(media_items):
|
||||
for i, (media_type, media_ref, filename, url, is_cached, item_content_type) in enumerate(media_items):
|
||||
if is_cached:
|
||||
# Use file_id directly
|
||||
media_item_json: dict[str, Any] = {
|
||||
@@ -1192,7 +1281,8 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
"type": media_type,
|
||||
"media": f"attach://{attach_name}",
|
||||
}
|
||||
content_type = "image/jpeg" if media_type == "photo" else "video/mp4"
|
||||
# Use provided content_type or default based on media type
|
||||
content_type = item_content_type or ("image/jpeg" if media_type == "photo" else "video/mp4")
|
||||
form.add_field(attach_name, media_ref, filename=filename, content_type=content_type)
|
||||
urls_to_cache.append((url, i, media_type))
|
||||
upload_idx += 1
|
||||
@@ -1236,7 +1326,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
else:
|
||||
# Log detailed error for media group with total size info
|
||||
uploaded_data = [m for m in media_items if not m[4]]
|
||||
total_size = sum(len(d) for _, d, _, _, _ in uploaded_data if isinstance(d, bytes))
|
||||
total_size = sum(len(d) for _, d, _, _, _, _ in uploaded_data if isinstance(d, bytes))
|
||||
_LOGGER.error(
|
||||
"Telegram API error for chunk %d/%d: %s | Media count: %d | Uploaded size: %d bytes (%.2f MB)",
|
||||
chunk_idx + 1, len(chunks),
|
||||
@@ -1246,7 +1336,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
total_size / (1024 * 1024) if total_size else 0
|
||||
)
|
||||
# Log detailed diagnostics for the first photo in the group
|
||||
for media_type, media_ref, _, _, is_cached in media_items:
|
||||
for media_type, media_ref, _, _, is_cached, _ in media_items:
|
||||
if media_type == "photo" and not is_cached and isinstance(media_ref, bytes):
|
||||
self._log_telegram_error(
|
||||
error_code=result.get("error_code"),
|
||||
@@ -1282,6 +1372,19 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
_LOGGER.error("Failed to send oversized photo as document: %s", result.get("error"))
|
||||
# Continue with other photos even if one fails
|
||||
|
||||
# Send documents (can't be in media groups)
|
||||
for i, (data, doc_caption, doc_url, filename, doc_content_type) in enumerate(documents_to_send):
|
||||
_LOGGER.debug("Sending document %d/%d", i + 1, len(documents_to_send))
|
||||
result = await self._send_telegram_document(
|
||||
session, token, chat_id, data, filename,
|
||||
doc_caption, None, parse_mode, doc_url, doc_content_type
|
||||
)
|
||||
if result.get("success"):
|
||||
all_message_ids.append(result.get("message_id"))
|
||||
else:
|
||||
_LOGGER.error("Failed to send document: %s", result.get("error"))
|
||||
# Continue with other documents even if one fails
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message_ids": all_message_ids,
|
||||
|
||||
@@ -131,7 +131,7 @@ get_assets:
|
||||
|
||||
send_telegram_notification:
|
||||
name: Send Telegram Notification
|
||||
description: Send a notification to Telegram (text, photo, video, or media group).
|
||||
description: Send a notification to Telegram (text, photo, video, document, or media group).
|
||||
target:
|
||||
entity:
|
||||
integration: immich_album_watcher
|
||||
@@ -151,7 +151,7 @@ send_telegram_notification:
|
||||
text:
|
||||
urls:
|
||||
name: URLs
|
||||
description: List of media URLs to send. Each item should have 'url' and 'type' (photo/video). If empty, sends a text message. Large lists are automatically split into multiple media groups.
|
||||
description: "List of media URLs to send. Each item should have 'url', optional 'type' (document/photo/video, default: document), and optional 'content_type' (MIME type, e.g., 'image/jpeg'). If empty, sends a text message. Photos and videos can be grouped; documents are sent separately."
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
|
||||
@@ -195,7 +195,7 @@
|
||||
},
|
||||
"send_telegram_notification": {
|
||||
"name": "Send Telegram Notification",
|
||||
"description": "Send a notification to Telegram (text, photo, video, or media group).",
|
||||
"description": "Send a notification to Telegram (text, photo, video, document, or media group).",
|
||||
"fields": {
|
||||
"bot_token": {
|
||||
"name": "Bot Token",
|
||||
@@ -207,7 +207,7 @@
|
||||
},
|
||||
"urls": {
|
||||
"name": "URLs",
|
||||
"description": "List of media URLs with type (photo/video). If empty, sends a text message. Large lists are automatically split into multiple media groups."
|
||||
"description": "List of media URLs with optional type (document/photo/video, default: document) and optional content_type (MIME type). If empty, sends a text message. Photos and videos can be grouped; documents are sent separately."
|
||||
},
|
||||
"caption": {
|
||||
"name": "Caption",
|
||||
|
||||
@@ -195,7 +195,7 @@
|
||||
},
|
||||
"send_telegram_notification": {
|
||||
"name": "Отправить уведомление в Telegram",
|
||||
"description": "Отправить уведомление в Telegram (текст, фото, видео или медиа-группу).",
|
||||
"description": "Отправить уведомление в Telegram (текст, фото, видео, документ или медиа-группу).",
|
||||
"fields": {
|
||||
"bot_token": {
|
||||
"name": "Токен бота",
|
||||
@@ -207,7 +207,7 @@
|
||||
},
|
||||
"urls": {
|
||||
"name": "URL-адреса",
|
||||
"description": "Список URL медиа-файлов с типом (photo/video). Если пусто, отправляет текстовое сообщение. Большие списки автоматически разделяются на несколько медиа-групп."
|
||||
"description": "Список URL медиа-файлов с типом (document/photo/video, по умолчанию document) и опциональным content_type (MIME-тип). Если пусто, отправляет текстовое сообщение. Фото и видео группируются; документы отправляются отдельно."
|
||||
},
|
||||
"caption": {
|
||||
"name": "Подпись",
|
||||
|
||||
Reference in New Issue
Block a user