From 5cee3ccc79046c737b197a56354fd62fea863f26 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 3 Feb 2026 02:48:25 +0300 Subject: [PATCH] Add chat_action parameter to send_telegram_notification service Shows typing/upload indicator while processing media. Supports: typing, upload_photo, upload_video, upload_document actions. Set to empty string to disable. Default: typing. Co-Authored-By: Claude Opus 4.5 --- README.md | 1 + .../immich_album_watcher/sensor.py | 124 +++++++++++++++--- .../immich_album_watcher/services.yaml | 18 +++ .../immich_album_watcher/translations/en.json | 4 + .../immich_album_watcher/translations/ru.json | 4 + 5 files changed, 134 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index d3f5798..7595e29 100644 --- a/README.md +++ b/README.md @@ -432,6 +432,7 @@ data: | `wait_for_response` | Wait for Telegram to finish processing. Set to `false` for fire-and-forget (automation continues immediately). Default: `true` | No | | `max_asset_data_size` | Maximum asset size in bytes. Assets exceeding this limit will be skipped. Default: no limit | No | | `send_large_photos_as_documents` | Handle photos exceeding Telegram limits (10MB or 10000px dimension sum). If `true`, send as documents. If `false`, skip oversized photos. Default: `false` | No | +| `chat_action` | Chat action to display while processing media (`typing`, `upload_photo`, `upload_video`, `upload_document`). Set to empty string to disable. Default: `typing` | No | The service returns a response with `success` status and `message_id` (single message), `message_ids` (media group), or `groups_sent` (number of groups when split). When `wait_for_response` is `false`, the service returns immediately with `{"success": true, "status": "queued"}` while processing continues in the background. diff --git a/custom_components/immich_album_watcher/sensor.py b/custom_components/immich_album_watcher/sensor.py index 95ee066..a38bc98 100644 --- a/custom_components/immich_album_watcher/sensor.py +++ b/custom_components/immich_album_watcher/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import logging from datetime import datetime from typing import Any @@ -144,6 +145,9 @@ async def async_setup_entry( vol.Coerce(int), vol.Range(min=1, max=52428800) ), vol.Optional("send_large_photos_as_documents", default=False): bool, + vol.Optional("chat_action", default="typing"): vol.Any( + None, vol.In(["typing", "upload_photo", "upload_video", "upload_document"]) + ), }, "async_send_telegram_notification", supports_response=SupportsResponse.OPTIONAL, @@ -248,6 +252,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se wait_for_response: bool = True, max_asset_data_size: int | None = None, send_large_photos_as_documents: bool = False, + chat_action: str | None = "typing", ) -> ServiceResponse: """Send notification to Telegram. @@ -278,6 +283,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se chunk_delay=chunk_delay, max_asset_data_size=max_asset_data_size, send_large_photos_as_documents=send_large_photos_as_documents, + chat_action=chat_action, ) ) return {"success": True, "status": "queued", "message": "Notification queued for background processing"} @@ -295,6 +301,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se chunk_delay=chunk_delay, max_asset_data_size=max_asset_data_size, send_large_photos_as_documents=send_large_photos_as_documents, + chat_action=chat_action, ) async def _execute_telegram_notification( @@ -310,6 +317,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se chunk_delay: int = 0, max_asset_data_size: int | None = None, send_large_photos_as_documents: bool = False, + chat_action: str | None = "typing", ) -> ServiceResponse: """Execute the Telegram notification (internal method).""" import json @@ -327,30 +335,44 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se session = async_get_clientsession(self.hass) - # Handle empty URLs - send simple text message + # Handle empty URLs - send simple text message (no typing indicator needed) if not urls: return await self._send_telegram_message( session, token, chat_id, caption or "", reply_to_message_id, disable_web_page_preview, parse_mode ) - # Handle single photo - if len(urls) == 1 and urls[0].get("type", "photo") == "photo": - return await self._send_telegram_photo( - session, token, chat_id, urls[0].get("url"), caption, reply_to_message_id, parse_mode, + # Start chat action indicator for media notifications (before downloading assets) + typing_task = None + if chat_action: + typing_task = self._start_typing_indicator(session, token, chat_id, chat_action) + + try: + # Handle single photo + if len(urls) == 1 and urls[0].get("type", "photo") == "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 + ) + + # 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 + ) + + # 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, max_asset_data_size, send_large_photos_as_documents ) - - # 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 - ) - - # 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, - max_asset_data_size, send_large_photos_as_documents - ) + finally: + # Stop chat action indicator when done (success or error) + if typing_task: + typing_task.cancel() + try: + await typing_task + except asyncio.CancelledError: + pass async def _send_telegram_message( self, @@ -400,6 +422,74 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se _LOGGER.error("Telegram message send failed: %s", err) return {"success": False, "error": str(err)} + async def _send_telegram_chat_action( + self, + session: Any, + token: str, + chat_id: str, + action: str = "typing", + ) -> bool: + """Send a chat action to Telegram (e.g., typing indicator). + + Args: + session: aiohttp client session + token: Telegram bot token + chat_id: Target chat ID + action: Chat action type (typing, upload_photo, upload_video, etc.) + + Returns: + True if successful, False otherwise + """ + import aiohttp + + telegram_url = f"https://api.telegram.org/bot{token}/sendChatAction" + payload = {"chat_id": chat_id, "action": action} + + try: + async with session.post(telegram_url, json=payload) as response: + result = await response.json() + if response.status == 200 and result.get("ok"): + _LOGGER.debug("Sent chat action '%s' to chat %s", action, chat_id) + return True + else: + _LOGGER.debug("Failed to send chat action: %s", result.get("description")) + return False + except aiohttp.ClientError as err: + _LOGGER.debug("Chat action request failed: %s", err) + return False + + def _start_typing_indicator( + self, + session: Any, + token: str, + chat_id: str, + action: str = "typing", + ) -> asyncio.Task: + """Start a background task that sends chat action indicator periodically. + + The chat action indicator expires after ~5 seconds, so we refresh it every 4 seconds. + + Args: + session: aiohttp client session + token: Telegram bot token + chat_id: Target chat ID + action: Chat action type (typing, upload_photo, upload_video, etc.) + + Returns: + The background task (cancel it when done) + """ + + async def action_loop() -> None: + """Keep sending chat action until cancelled.""" + try: + while True: + await self._send_telegram_chat_action(session, token, chat_id, action) + await asyncio.sleep(4) + except asyncio.CancelledError: + _LOGGER.debug("Chat action indicator stopped for action '%s'", action) + + return asyncio.create_task(action_loop()) + def _log_telegram_error( self, error_code: int | None, diff --git a/custom_components/immich_album_watcher/services.yaml b/custom_components/immich_album_watcher/services.yaml index b96c6e3..989df74 100644 --- a/custom_components/immich_album_watcher/services.yaml +++ b/custom_components/immich_album_watcher/services.yaml @@ -238,3 +238,21 @@ send_telegram_notification: default: false selector: boolean: + chat_action: + name: Chat Action + description: Chat action to display while processing (typing, upload_photo, upload_video, upload_document). Set to empty to disable. + required: false + default: "typing" + selector: + select: + options: + - label: "Typing" + value: "typing" + - label: "Uploading Photo" + value: "upload_photo" + - label: "Uploading Video" + value: "upload_video" + - label: "Uploading Document" + value: "upload_document" + - label: "Disabled" + value: "" diff --git a/custom_components/immich_album_watcher/translations/en.json b/custom_components/immich_album_watcher/translations/en.json index 8b19746..13ece50 100644 --- a/custom_components/immich_album_watcher/translations/en.json +++ b/custom_components/immich_album_watcher/translations/en.json @@ -244,6 +244,10 @@ "send_large_photos_as_documents": { "name": "Send Large Photos As Documents", "description": "How to handle photos exceeding Telegram's limits (10MB or 10000px dimension sum). If true, send as documents. If false, downsize to fit limits." + }, + "chat_action": { + "name": "Chat Action", + "description": "Chat action to display while processing (typing, upload_photo, upload_video, upload_document). Set to empty to disable." } } } diff --git a/custom_components/immich_album_watcher/translations/ru.json b/custom_components/immich_album_watcher/translations/ru.json index bc943bb..cdd1285 100644 --- a/custom_components/immich_album_watcher/translations/ru.json +++ b/custom_components/immich_album_watcher/translations/ru.json @@ -244,6 +244,10 @@ "send_large_photos_as_documents": { "name": "Большие фото как документы", "description": "Как обрабатывать фото, превышающие лимиты Telegram (10МБ или сумма размеров 10000пкс). Если true, отправлять как документы. Если false, уменьшать для соответствия лимитам." + }, + "chat_action": { + "name": "Действие в чате", + "description": "Действие для отображения во время обработки (typing, upload_photo, upload_video, upload_document). Оставьте пустым для отключения." } } }