mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 13:17:32 +00:00
Re-add ability to use remote files (by URL) in Slack messages (#37161)
* Re-add remote file support for Slack * More work * Ensure Slack can only upload files from whitelisted directories * Cleanup * Finish work * Code review * Messing around * Final cleanup * Add comment explaining why we use aiohttp for remote files * Typo
This commit is contained in:
parent
5255bf20d3
commit
e61da2fff3
@ -4,6 +4,8 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from aiohttp import BasicAuth, FormData
|
||||||
|
from aiohttp.client_exceptions import ClientError
|
||||||
from slack import WebClient
|
from slack import WebClient
|
||||||
from slack.errors import SlackApiError
|
from slack.errors import SlackApiError
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@ -26,11 +28,41 @@ ATTR_ATTACHMENTS = "attachments"
|
|||||||
ATTR_BLOCKS = "blocks"
|
ATTR_BLOCKS = "blocks"
|
||||||
ATTR_BLOCKS_TEMPLATE = "blocks_template"
|
ATTR_BLOCKS_TEMPLATE = "blocks_template"
|
||||||
ATTR_FILE = "file"
|
ATTR_FILE = "file"
|
||||||
|
ATTR_PASSWORD = "password"
|
||||||
|
ATTR_PATH = "path"
|
||||||
|
ATTR_URL = "url"
|
||||||
|
ATTR_USERNAME = "username"
|
||||||
|
|
||||||
CONF_DEFAULT_CHANNEL = "default_channel"
|
CONF_DEFAULT_CHANNEL = "default_channel"
|
||||||
|
|
||||||
DEFAULT_TIMEOUT_SECONDS = 15
|
DEFAULT_TIMEOUT_SECONDS = 15
|
||||||
|
|
||||||
|
FILE_PATH_SCHEMA = vol.Schema({vol.Required(ATTR_PATH): cv.isfile})
|
||||||
|
|
||||||
|
FILE_URL_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(ATTR_URL): cv.url,
|
||||||
|
vol.Inclusive(ATTR_USERNAME, "credentials"): cv.string,
|
||||||
|
vol.Inclusive(ATTR_PASSWORD, "credentials"): cv.string,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
DATA_FILE_SCHEMA = vol.Schema(
|
||||||
|
{vol.Required(ATTR_FILE): vol.Any(FILE_PATH_SCHEMA, FILE_URL_SCHEMA)}
|
||||||
|
)
|
||||||
|
|
||||||
|
DATA_TEXT_ONLY_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(ATTR_ATTACHMENTS): list,
|
||||||
|
vol.Optional(ATTR_BLOCKS): list,
|
||||||
|
vol.Optional(ATTR_BLOCKS_TEMPLATE): list,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
DATA_SCHEMA = vol.All(
|
||||||
|
cv.ensure_list, [vol.Any(DATA_FILE_SCHEMA, DATA_TEXT_ONLY_SCHEMA)]
|
||||||
|
)
|
||||||
|
|
||||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||||
{
|
{
|
||||||
vol.Required(CONF_API_KEY): cv.string,
|
vol.Required(CONF_API_KEY): cv.string,
|
||||||
@ -61,6 +93,13 @@ async def async_get_service(hass, config, discovery_info=None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_get_filename_from_url(url):
|
||||||
|
"""Return the filename of a passed URL."""
|
||||||
|
parsed_url = urlparse(url)
|
||||||
|
return os.path.basename(parsed_url.path)
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_sanitize_channel_names(channel_list):
|
def _async_sanitize_channel_names(channel_list):
|
||||||
"""Remove any # symbols from a channel list."""
|
"""Remove any # symbols from a channel list."""
|
||||||
@ -112,6 +151,51 @@ class SlackNotificationService(BaseNotificationService):
|
|||||||
except SlackApiError as err:
|
except SlackApiError as err:
|
||||||
_LOGGER.error("Error while uploading file-based message: %s", err)
|
_LOGGER.error("Error while uploading file-based message: %s", err)
|
||||||
|
|
||||||
|
async def _async_send_remote_file_message(
|
||||||
|
self, url, targets, message, title, *, username=None, password=None
|
||||||
|
):
|
||||||
|
"""Upload a remote file (with message) to Slack.
|
||||||
|
|
||||||
|
Note that we bypass the python-slackclient WebClient and use aiohttp directly,
|
||||||
|
as the former would require us to download the entire remote file into memory
|
||||||
|
first before uploading it to Slack.
|
||||||
|
"""
|
||||||
|
if not self._hass.config.is_allowed_external_url(url):
|
||||||
|
_LOGGER.error("URL is not allowed: %s", url)
|
||||||
|
return
|
||||||
|
|
||||||
|
filename = _async_get_filename_from_url(url)
|
||||||
|
session = aiohttp_client.async_get_clientsession(self.hass)
|
||||||
|
|
||||||
|
kwargs = {}
|
||||||
|
if username and password is not None:
|
||||||
|
kwargs = {"auth": BasicAuth(username, password=password)}
|
||||||
|
|
||||||
|
resp = await session.request("get", url, **kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp.raise_for_status()
|
||||||
|
except ClientError as err:
|
||||||
|
_LOGGER.error("Error while retrieving %s: %s", url, err)
|
||||||
|
return
|
||||||
|
|
||||||
|
data = FormData(
|
||||||
|
{
|
||||||
|
"channels": ",".join(targets),
|
||||||
|
"filename": filename,
|
||||||
|
"initial_comment": message,
|
||||||
|
"title": title or filename,
|
||||||
|
"token": self._client.token,
|
||||||
|
},
|
||||||
|
charset="utf-8",
|
||||||
|
)
|
||||||
|
data.add_field("file", resp.content, filename=filename)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await session.post("https://slack.com/api/files.upload", data=data)
|
||||||
|
except ClientError as err:
|
||||||
|
_LOGGER.error("Error while uploading file message: %s", err)
|
||||||
|
|
||||||
async def _async_send_text_only_message(
|
async def _async_send_text_only_message(
|
||||||
self, targets, message, title, attachments, blocks
|
self, targets, message, title, attachments, blocks
|
||||||
):
|
):
|
||||||
@ -140,32 +224,53 @@ class SlackNotificationService(BaseNotificationService):
|
|||||||
|
|
||||||
async def async_send_message(self, message, **kwargs):
|
async def async_send_message(self, message, **kwargs):
|
||||||
"""Send a message to Slack."""
|
"""Send a message to Slack."""
|
||||||
data = kwargs[ATTR_DATA] or {}
|
data = kwargs.get(ATTR_DATA, {})
|
||||||
|
|
||||||
|
try:
|
||||||
|
DATA_SCHEMA(data)
|
||||||
|
except vol.Invalid as err:
|
||||||
|
_LOGGER.error("Invalid message data: %s", err)
|
||||||
|
data = {}
|
||||||
|
|
||||||
title = kwargs.get(ATTR_TITLE)
|
title = kwargs.get(ATTR_TITLE)
|
||||||
targets = _async_sanitize_channel_names(
|
targets = _async_sanitize_channel_names(
|
||||||
kwargs.get(ATTR_TARGET, [self._default_channel])
|
kwargs.get(ATTR_TARGET, [self._default_channel])
|
||||||
)
|
)
|
||||||
|
|
||||||
if ATTR_FILE in data:
|
# Message Type 1: A text-only message
|
||||||
return await self._async_send_local_file_message(
|
if ATTR_FILE not in data:
|
||||||
data[ATTR_FILE], targets, message, title
|
attachments = data.get(ATTR_ATTACHMENTS, {})
|
||||||
|
if attachments:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Attachments are deprecated and part of Slack's legacy API; "
|
||||||
|
"support for them will be dropped in 0.114.0. In most cases, "
|
||||||
|
"Blocks should be used instead: "
|
||||||
|
"https://www.home-assistant.io/integrations/slack/"
|
||||||
|
)
|
||||||
|
|
||||||
|
if ATTR_BLOCKS_TEMPLATE in data:
|
||||||
|
blocks = _async_templatize_blocks(self.hass, data[ATTR_BLOCKS_TEMPLATE])
|
||||||
|
elif ATTR_BLOCKS in data:
|
||||||
|
blocks = data[ATTR_BLOCKS]
|
||||||
|
else:
|
||||||
|
blocks = {}
|
||||||
|
|
||||||
|
return await self._async_send_text_only_message(
|
||||||
|
targets, message, title, attachments, blocks
|
||||||
)
|
)
|
||||||
|
|
||||||
attachments = data.get(ATTR_ATTACHMENTS, {})
|
# Message Type 2: A message that uploads a remote file
|
||||||
if attachments:
|
if ATTR_URL in data[ATTR_FILE]:
|
||||||
_LOGGER.warning(
|
return await self._async_send_remote_file_message(
|
||||||
"Attachments are deprecated and part of Slack's legacy API; support "
|
data[ATTR_FILE][ATTR_URL],
|
||||||
"for them will be dropped in 0.114.0. In most cases, Blocks should be "
|
targets,
|
||||||
"used instead: https://www.home-assistant.io/integrations/slack/"
|
message,
|
||||||
|
title,
|
||||||
|
username=data[ATTR_FILE].get(ATTR_USERNAME),
|
||||||
|
password=data[ATTR_FILE].get(ATTR_PASSWORD),
|
||||||
)
|
)
|
||||||
|
|
||||||
if ATTR_BLOCKS_TEMPLATE in data:
|
# Message Type 3: A message that uploads a local file
|
||||||
blocks = _async_templatize_blocks(self.hass, data[ATTR_BLOCKS_TEMPLATE])
|
return await self._async_send_local_file_message(
|
||||||
elif ATTR_BLOCKS in data:
|
data[ATTR_FILE][ATTR_PATH], targets, message, title
|
||||||
blocks = data[ATTR_BLOCKS]
|
|
||||||
else:
|
|
||||||
blocks = {}
|
|
||||||
|
|
||||||
return await self._async_send_text_only_message(
|
|
||||||
targets, message, title, attachments, blocks
|
|
||||||
)
|
)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user