Add telegram media sender as service. Also fixed the via_device warnings that would break in HA 2025.12.0.
All checks were successful
Validate / Hassfest (push) Successful in 3s
All checks were successful
Validate / Hassfest (push) Successful in 3s
This commit is contained in:
@@ -137,7 +137,6 @@ class ImmichAlbumNewAssetsSensor(
|
||||
name=self._album_name,
|
||||
manufacturer="Immich",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
via_device=(DOMAIN, self._entry.entry_id),
|
||||
)
|
||||
|
||||
@callback
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -102,7 +102,6 @@ class ImmichAlbumThumbnailCamera(
|
||||
name=self._album_name,
|
||||
manufacturer="Immich",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
via_device=(DOMAIN, self._entry.entry_id),
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -105,7 +105,6 @@ class ImmichAlbumProtectedPasswordText(
|
||||
name=self._album_name,
|
||||
manufacturer="Immich",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
via_device=(DOMAIN, self._entry.entry_id),
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
Reference in New Issue
Block a user