From 02c0535f50e0b0060969688f5d38f16eb72cd86a Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 31 Jan 2026 04:07:13 +0300 Subject: [PATCH] Add telegram media sender as service. Also fixed the via_device warnings that would break in HA 2025.12.0. --- .../immich_album_watcher/binary_sensor.py | 1 - .../immich_album_watcher/button.py | 12 +- .../immich_album_watcher/camera.py | 1 - .../immich_album_watcher/config_flow.py | 10 ++ .../immich_album_watcher/const.py | 2 + .../immich_album_watcher/manifest.json | 2 +- .../immich_album_watcher/sensor.py | 131 +++++++++++++++++- .../immich_album_watcher/services.yaml | 41 ++++++ .../immich_album_watcher/strings.json | 8 +- .../immich_album_watcher/text.py | 1 - 10 files changed, 193 insertions(+), 16 deletions(-) diff --git a/custom_components/immich_album_watcher/binary_sensor.py b/custom_components/immich_album_watcher/binary_sensor.py index 7e21316..130c298 100644 --- a/custom_components/immich_album_watcher/binary_sensor.py +++ b/custom_components/immich_album_watcher/binary_sensor.py @@ -137,7 +137,6 @@ class ImmichAlbumNewAssetsSensor( name=self._album_name, manufacturer="Immich", entry_type=DeviceEntryType.SERVICE, - via_device=(DOMAIN, self._entry.entry_id), ) @callback diff --git a/custom_components/immich_album_watcher/button.py b/custom_components/immich_album_watcher/button.py index dd64822..0fb222b 100644 --- a/custom_components/immich_album_watcher/button.py +++ b/custom_components/immich_album_watcher/button.py @@ -109,8 +109,7 @@ class ImmichCreateShareLinkButton( name=self._album_name, manufacturer="Immich", entry_type=DeviceEntryType.SERVICE, - via_device=(DOMAIN, self._entry.entry_id), - ) + ) @property def extra_state_attributes(self) -> dict[str, str]: @@ -200,8 +199,7 @@ class ImmichDeleteShareLinkButton( name=self._album_name, manufacturer="Immich", entry_type=DeviceEntryType.SERVICE, - via_device=(DOMAIN, self._entry.entry_id), - ) + ) @property def extra_state_attributes(self) -> dict[str, str]: @@ -298,8 +296,7 @@ class ImmichCreateProtectedLinkButton( name=self._album_name, manufacturer="Immich", entry_type=DeviceEntryType.SERVICE, - via_device=(DOMAIN, self._entry.entry_id), - ) + ) @property def extra_state_attributes(self) -> dict[str, str]: @@ -393,8 +390,7 @@ class ImmichDeleteProtectedLinkButton( name=self._album_name, manufacturer="Immich", entry_type=DeviceEntryType.SERVICE, - via_device=(DOMAIN, self._entry.entry_id), - ) + ) @property def extra_state_attributes(self) -> dict[str, str]: diff --git a/custom_components/immich_album_watcher/camera.py b/custom_components/immich_album_watcher/camera.py index 6f5947a..0dba72c 100644 --- a/custom_components/immich_album_watcher/camera.py +++ b/custom_components/immich_album_watcher/camera.py @@ -102,7 +102,6 @@ class ImmichAlbumThumbnailCamera( name=self._album_name, manufacturer="Immich", entry_type=DeviceEntryType.SERVICE, - via_device=(DOMAIN, self._entry.entry_id), ) @property diff --git a/custom_components/immich_album_watcher/config_flow.py b/custom_components/immich_album_watcher/config_flow.py index 5761c14..bc9217e 100644 --- a/custom_components/immich_album_watcher/config_flow.py +++ b/custom_components/immich_album_watcher/config_flow.py @@ -26,6 +26,7 @@ from .const import ( CONF_HUB_NAME, CONF_IMMICH_URL, CONF_SCAN_INTERVAL, + CONF_TELEGRAM_BOT_TOKEN, DEFAULT_SCAN_INTERVAL, DOMAIN, SUBENTRY_TYPE_ALBUM, @@ -248,12 +249,18 @@ class ImmichAlbumWatcherOptionsFlow(OptionsFlow): CONF_SCAN_INTERVAL: user_input.get( CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL ), + CONF_TELEGRAM_BOT_TOKEN: user_input.get( + CONF_TELEGRAM_BOT_TOKEN, "" + ), }, ) current_interval = self._config_entry.options.get( CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL ) + current_bot_token = self._config_entry.options.get( + CONF_TELEGRAM_BOT_TOKEN, "" + ) return self.async_show_form( step_id="init", @@ -262,6 +269,9 @@ class ImmichAlbumWatcherOptionsFlow(OptionsFlow): vol.Required( CONF_SCAN_INTERVAL, default=current_interval ): vol.All(vol.Coerce(int), vol.Range(min=10, max=3600)), + vol.Optional( + CONF_TELEGRAM_BOT_TOKEN, default=current_bot_token + ): str, } ), ) diff --git a/custom_components/immich_album_watcher/const.py b/custom_components/immich_album_watcher/const.py index 4919722..ca964c9 100644 --- a/custom_components/immich_album_watcher/const.py +++ b/custom_components/immich_album_watcher/const.py @@ -13,6 +13,7 @@ CONF_ALBUMS: Final = "albums" CONF_ALBUM_ID: Final = "album_id" CONF_ALBUM_NAME: Final = "album_name" CONF_SCAN_INTERVAL: Final = "scan_interval" +CONF_TELEGRAM_BOT_TOKEN: Final = "telegram_bot_token" # Subentry type SUBENTRY_TYPE_ALBUM: Final = "album" @@ -69,3 +70,4 @@ PLATFORMS: Final = ["sensor", "binary_sensor", "camera", "text", "button"] # Services SERVICE_REFRESH: Final = "refresh" SERVICE_GET_RECENT_ASSETS: Final = "get_recent_assets" +SERVICE_SEND_TELEGRAM_MEDIA_GROUP: Final = "send_telegram_media_group" diff --git a/custom_components/immich_album_watcher/manifest.json b/custom_components/immich_album_watcher/manifest.json index 6781010..2d23fd8 100644 --- a/custom_components/immich_album_watcher/manifest.json +++ b/custom_components/immich_album_watcher/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher/issues", "requirements": [], - "version": "1.3.0" + "version": "1.4.0" } diff --git a/custom_components/immich_album_watcher/sensor.py b/custom_components/immich_album_watcher/sensor.py index a5dff6a..191b74a 100644 --- a/custom_components/immich_album_watcher/sensor.py +++ b/custom_components/immich_album_watcher/sensor.py @@ -38,9 +38,11 @@ from .const import ( CONF_ALBUM_ID, CONF_ALBUM_NAME, CONF_HUB_NAME, + CONF_TELEGRAM_BOT_TOKEN, DOMAIN, SERVICE_GET_RECENT_ASSETS, SERVICE_REFRESH, + SERVICE_SEND_TELEGRAM_MEDIA_GROUP, ) from .coordinator import AlbumData, ImmichAlbumWatcherCoordinator @@ -96,6 +98,19 @@ async def async_setup_entry( supports_response=SupportsResponse.ONLY, ) + platform.async_register_entity_service( + SERVICE_SEND_TELEGRAM_MEDIA_GROUP, + { + vol.Optional("bot_token"): str, + vol.Required("chat_id"): vol.Coerce(str), + vol.Required("urls"): vol.All(list, vol.Length(min=1, max=10)), + vol.Optional("caption"): str, + vol.Optional("reply_to_message_id"): vol.Coerce(int), + }, + "async_send_telegram_media_group", + supports_response=SupportsResponse.ONLY, + ) + class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], SensorEntity): """Base sensor for Immich album.""" @@ -143,7 +158,6 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se name=self._album_name, manufacturer="Immich", entry_type=DeviceEntryType.SERVICE, - via_device=(DOMAIN, self._entry.entry_id), ) @callback @@ -160,6 +174,121 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se assets = await self.coordinator.async_get_recent_assets(count) return {"assets": assets} + async def async_send_telegram_media_group( + self, + chat_id: str, + urls: list[dict[str, str]], + bot_token: str | None = None, + caption: str | None = None, + reply_to_message_id: int | None = None, + ) -> ServiceResponse: + """Send media URLs to Telegram as a media group. + + Each item in urls should be a dict with 'url' and 'type' (photo/video). + Downloads media and uploads to Telegram to bypass CORS restrictions. + """ + import json + import aiohttp + from aiohttp import FormData + from homeassistant.helpers.aiohttp_client import async_get_clientsession + + # Get bot token from parameter or config + token = bot_token or self._entry.options.get(CONF_TELEGRAM_BOT_TOKEN) + if not token: + return { + "success": False, + "error": "No bot token provided. Set it in integration options or pass as parameter.", + } + + session = async_get_clientsession(self.hass) + + # Download all media files + media_files: list[tuple[str, bytes, str]] = [] + for i, item in enumerate(urls): + url = item.get("url") + media_type = item.get("type", "photo") + + if not url: + return { + "success": False, + "error": f"Missing 'url' in item {i}", + } + + if media_type not in ("photo", "video"): + return { + "success": False, + "error": f"Invalid type '{media_type}' in item {i}. Must be 'photo' or 'video'.", + } + + try: + _LOGGER.debug("Downloading media %d from %s", i, url[:80]) + async with session.get(url) as resp: + if resp.status != 200: + return { + "success": False, + "error": f"Failed to download media {i}: HTTP {resp.status}", + } + data = await resp.read() + ext = "jpg" if media_type == "photo" else "mp4" + filename = f"media_{i}.{ext}" + media_files.append((media_type, data, filename)) + _LOGGER.debug("Downloaded media %d: %d bytes", i, len(data)) + except aiohttp.ClientError as err: + return { + "success": False, + "error": f"Failed to download media {i}: {err}", + } + + # Build multipart form + form = FormData() + form.add_field("chat_id", chat_id) + + if reply_to_message_id: + form.add_field("reply_to_message_id", str(reply_to_message_id)) + + # Build media JSON with attach:// references + media_json = [] + for i, (media_type, data, filename) in enumerate(media_files): + attach_name = f"file{i}" + media_item: dict[str, Any] = { + "type": media_type, + "media": f"attach://{attach_name}", + } + if i == 0 and caption: + media_item["caption"] = caption + media_json.append(media_item) + + content_type = "image/jpeg" if media_type == "photo" else "video/mp4" + form.add_field(attach_name, data, filename=filename, content_type=content_type) + + form.add_field("media", json.dumps(media_json)) + + # Send to Telegram + telegram_url = f"https://api.telegram.org/bot{token}/sendMediaGroup" + + try: + _LOGGER.debug("Uploading %d files to Telegram", len(media_files)) + 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")) + if response.status == 200 and result.get("ok"): + return { + "success": True, + "message_ids": [ + msg.get("message_id") for msg in result.get("result", []) + ], + } + else: + _LOGGER.error("Telegram API error: %s", result) + return { + "success": False, + "error": result.get("description", "Unknown Telegram error"), + "error_code": result.get("error_code"), + } + except aiohttp.ClientError as err: + _LOGGER.error("Telegram upload failed: %s", err) + return {"success": False, "error": str(err)} + class ImmichAlbumIdSensor(ImmichAlbumBaseSensor): """Sensor exposing the Immich album ID.""" diff --git a/custom_components/immich_album_watcher/services.yaml b/custom_components/immich_album_watcher/services.yaml index 456b58a..20eaddc 100644 --- a/custom_components/immich_album_watcher/services.yaml +++ b/custom_components/immich_album_watcher/services.yaml @@ -24,3 +24,44 @@ get_recent_assets: min: 1 max: 100 mode: slider + +send_telegram_media_group: + name: Send Telegram Media Group + description: Send specified media URLs to a Telegram chat as a media group. + target: + entity: + integration: immich_album_watcher + domain: sensor + fields: + bot_token: + name: Bot Token + description: Telegram bot token. Uses configured token if not provided. + required: false + selector: + text: + chat_id: + name: Chat ID + description: Telegram chat ID to send to. + required: true + selector: + text: + urls: + name: URLs + description: List of media URLs to send (max 10). Each item should have 'url' and 'type' (photo/video). + required: true + selector: + object: + caption: + name: Caption + description: Optional caption for the media group (applied to first item). + required: false + selector: + text: + multiline: true + reply_to_message_id: + name: Reply To Message ID + description: Message ID to reply to. + required: false + selector: + number: + mode: box diff --git a/custom_components/immich_album_watcher/strings.json b/custom_components/immich_album_watcher/strings.json index 7615f51..1ed3170 100644 --- a/custom_components/immich_album_watcher/strings.json +++ b/custom_components/immich_album_watcher/strings.json @@ -71,13 +71,15 @@ "step": { "init": { "title": "Immich Album Watcher Options", - "description": "Configure which albums to monitor and how often to check for changes.", + "description": "Configure how often to check for changes and optional Telegram integration.", "data": { "albums": "Albums to watch", - "scan_interval": "Scan interval (seconds)" + "scan_interval": "Scan interval (seconds)", + "telegram_bot_token": "Telegram Bot Token" }, "data_description": { - "scan_interval": "How often to check for album changes (10-3600 seconds)" + "scan_interval": "How often to check for album changes (10-3600 seconds)", + "telegram_bot_token": "Bot token for sending media to Telegram (optional)" } } }, diff --git a/custom_components/immich_album_watcher/text.py b/custom_components/immich_album_watcher/text.py index ac127e0..c8d9044 100644 --- a/custom_components/immich_album_watcher/text.py +++ b/custom_components/immich_album_watcher/text.py @@ -105,7 +105,6 @@ class ImmichAlbumProtectedPasswordText( name=self._album_name, manufacturer="Immich", entry_type=DeviceEntryType.SERVICE, - via_device=(DOMAIN, self._entry.entry_id), ) @property