core/homeassistant/components/signal_messenger/notify.py

198 lines
5.9 KiB
Python

"""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 as NOTIFY_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"
ATTR_TEXTMODE = "text_mode"
TEXTMODE_OPTIONS = ["normal", "styled"]
DATA_FILENAMES_SCHEMA = vol.Schema(
{
vol.Required(ATTR_FILENAMES): [cv.string],
vol.Optional(ATTR_TEXTMODE, default="normal"): vol.In(TEXTMODE_OPTIONS),
}
)
DATA_URLS_SCHEMA = vol.Schema(
{
vol.Required(ATTR_URLS): [cv.url],
vol.Optional(ATTR_VERIFY_SSL, default=True): cv.boolean,
vol.Optional(ATTR_TEXTMODE, default="normal"): vol.In(TEXTMODE_OPTIONS),
}
)
DATA_SCHEMA = vol.Any(
None,
vol.Schema(
{
vol.Optional(ATTR_TEXTMODE, default="normal"): vol.In(TEXTMODE_OPTIONS),
}
),
DATA_FILENAMES_SCHEMA,
DATA_URLS_SCHEMA,
)
PLATFORM_SCHEMA = NOTIFY_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
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,
text_mode="normal" if data is None else data.get(ATTR_TEXTMODE),
)
except SignalCliRestApiError as ex:
_LOGGER.error("%s", ex)
raise
@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(
f"Attachment too large (Stream reports {size}). "
f"Max size: {CONF_MAX_ALLOWED_DOWNLOAD_SIZE_BYTES} bytes"
)
chunks.extend(chunk)
attachments_as_bytes.append(chunks)
except Exception as ex:
_LOGGER.error("%s", ex)
raise
if not attachments_as_bytes:
return None
return attachments_as_bytes