"""Signal Messenger for notify component.""" from __future__ import annotations import logging from typing import Any from pysignalclirestapi import SignalCliRestApi, SignalCliRestApiError import requests import voluptuous as vol from homeassistant.components.notify import ( ATTR_DATA, PLATFORM_SCHEMA, BaseNotificationService, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) CONF_SENDER_NR = "number" CONF_RECP_NR = "recipients" CONF_SIGNAL_CLI_REST_API = "url" CONF_MAX_ALLOWED_DOWNLOAD_SIZE_BYTES = 52428800 ATTR_FILENAMES = "attachments" ATTR_URLS = "urls" ATTR_VERIFY_SSL = "verify_ssl" DATA_FILENAMES_SCHEMA = vol.Schema({vol.Required(ATTR_FILENAMES): [cv.string]}) DATA_URLS_SCHEMA = vol.Schema( { vol.Required(ATTR_URLS): [cv.url], vol.Optional(ATTR_VERIFY_SSL, default=True): cv.boolean, } ) DATA_SCHEMA = vol.Any( None, DATA_FILENAMES_SCHEMA, DATA_URLS_SCHEMA, ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_SENDER_NR): cv.string, vol.Required(CONF_SIGNAL_CLI_REST_API): cv.string, vol.Required(CONF_RECP_NR): vol.All(cv.ensure_list, [cv.string]), } ) def get_service( hass: HomeAssistant, config: ConfigType, discovery_info: DiscoveryInfoType | None = None, ) -> SignalNotificationService: """Get the SignalMessenger notification service.""" sender_nr = config[CONF_SENDER_NR] recp_nrs = config[CONF_RECP_NR] signal_cli_rest_api_url = config[CONF_SIGNAL_CLI_REST_API] signal_cli_rest_api = SignalCliRestApi(signal_cli_rest_api_url, sender_nr) return SignalNotificationService(hass, recp_nrs, signal_cli_rest_api) class SignalNotificationService(BaseNotificationService): """Implement the notification service for SignalMessenger.""" def __init__( self, hass: HomeAssistant, recp_nrs: list[str], signal_cli_rest_api: SignalCliRestApi, ) -> None: """Initialize the service.""" self._hass = hass self._recp_nrs = recp_nrs self._signal_cli_rest_api = signal_cli_rest_api def send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a one or more recipients. Additionally a file can be attached.""" _LOGGER.debug("Sending signal message") data = kwargs.get(ATTR_DATA) try: data = DATA_SCHEMA(data) except vol.Invalid as ex: _LOGGER.error("Invalid message data: %s", ex) raise ex filenames = self.get_filenames(data) attachments_as_bytes = self.get_attachments_as_bytes( data, CONF_MAX_ALLOWED_DOWNLOAD_SIZE_BYTES, self._hass ) try: self._signal_cli_rest_api.send_message( message, self._recp_nrs, filenames, attachments_as_bytes ) except SignalCliRestApiError as ex: _LOGGER.error("%s", ex) raise ex @staticmethod def get_filenames(data: Any) -> list[str] | None: """Extract attachment filenames from data.""" try: data = DATA_FILENAMES_SCHEMA(data) except vol.Invalid: return None return data[ATTR_FILENAMES] @staticmethod def get_attachments_as_bytes( data: Any, attachment_size_limit: int, hass: HomeAssistant, ) -> list[bytearray] | None: """Retrieve attachments from URLs defined in data.""" try: data = DATA_URLS_SCHEMA(data) except vol.Invalid: return None urls = data[ATTR_URLS] attachments_as_bytes: list[bytearray] = [] for url in urls: try: if not hass.config.is_allowed_external_url(url): _LOGGER.error("URL '%s' not in allow list", url) continue resp = requests.get( url, verify=data[ATTR_VERIFY_SSL], timeout=10, stream=True ) resp.raise_for_status() if ( resp.headers.get("Content-Length") is not None and int(str(resp.headers.get("Content-Length"))) > attachment_size_limit ): raise ValueError( "Attachment too large (Content-Length reports {}). Max size: {} bytes".format( int(str(resp.headers.get("Content-Length"))), CONF_MAX_ALLOWED_DOWNLOAD_SIZE_BYTES, ) ) size = 0 chunks = bytearray() for chunk in resp.iter_content(1024): size += len(chunk) if size > attachment_size_limit: raise ValueError( "Attachment too large (Stream reports {}). Max size: {} bytes".format( size, CONF_MAX_ALLOWED_DOWNLOAD_SIZE_BYTES ) ) chunks.extend(chunk) attachments_as_bytes.append(chunks) except Exception as ex: _LOGGER.error("%s", ex) raise ex if not attachments_as_bytes: return None return attachments_as_bytes