diff --git a/homeassistant/components/slack/manifest.json b/homeassistant/components/slack/manifest.json index 72e2b0267d2..86785868170 100644 --- a/homeassistant/components/slack/manifest.json +++ b/homeassistant/components/slack/manifest.json @@ -2,7 +2,7 @@ "domain": "slack", "name": "Slack", "documentation": "https://www.home-assistant.io/integrations/slack", - "requirements": ["slacker==0.14.0"], + "requirements": ["slackclient==2.5.0"], "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py index 20daa261b8f..fe6f7ab0d26 100644 --- a/homeassistant/components/slack/notify.py +++ b/homeassistant/components/slack/notify.py @@ -1,10 +1,11 @@ """Slack platform for notify component.""" +import asyncio import logging +import os +from urllib.parse import urlparse -import requests -from requests.auth import HTTPBasicAuth, HTTPDigestAuth -import slacker -from slacker import Slacker +from slack import WebClient +from slack.errors import SlackApiError import voluptuous as vol from homeassistant.components.notify import ( @@ -15,157 +16,138 @@ from homeassistant.components.notify import ( BaseNotificationService, ) from homeassistant.const import CONF_API_KEY, CONF_ICON, CONF_USERNAME -import homeassistant.helpers.config_validation as cv +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client, config_validation as cv _LOGGER = logging.getLogger(__name__) -CONF_CHANNEL = "default_channel" -CONF_TIMEOUT = 15 - -# Top level attributes in 'data' ATTR_ATTACHMENTS = "attachments" +ATTR_BLOCKS = "blocks" ATTR_FILE = "file" -# Attributes contained in file -ATTR_FILE_URL = "url" -ATTR_FILE_PATH = "path" -ATTR_FILE_USERNAME = "username" -ATTR_FILE_PASSWORD = "password" -ATTR_FILE_AUTH = "auth" -# Any other value or absence of 'auth' lead to basic authentication being used -ATTR_FILE_AUTH_DIGEST = "digest" + +CONF_DEFAULT_CHANNEL = "default_channel" + +DEFAULT_TIMEOUT_SECONDS = 15 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_CHANNEL): cv.string, + vol.Required(CONF_DEFAULT_CHANNEL): cv.string, vol.Optional(CONF_ICON): cv.string, vol.Optional(CONF_USERNAME): cv.string, } ) -def get_service(hass, config, discovery_info=None): - """Get the Slack notification service.""" - - channel = config.get(CONF_CHANNEL) - api_key = config.get(CONF_API_KEY) - username = config.get(CONF_USERNAME) - icon = config.get(CONF_ICON) +async def async_get_service(hass, config, discovery_info=None): + """Set up the Slack notification service.""" + session = aiohttp_client.async_get_clientsession(hass) + client = WebClient(token=config[CONF_API_KEY], run_async=True, session=session) try: - return SlackNotificationService( - channel, api_key, username, icon, hass.config.is_allowed_path - ) + await client.auth_test() + except SlackApiError as err: + _LOGGER.error("Error while setting up integration: %s", err) + return - except slacker.Error: - _LOGGER.exception("Authentication failed") - return None + return SlackNotificationService( + hass, + client, + config[CONF_DEFAULT_CHANNEL], + username=config.get(CONF_USERNAME), + icon=config.get(CONF_ICON), + ) + + +@callback +def _async_sanitize_channel_names(channel_list): + """Remove any # symbols from a channel list.""" + return [channel.lstrip("#") for channel in channel_list] class SlackNotificationService(BaseNotificationService): - """Implement the notification service for Slack.""" - - def __init__(self, default_channel, api_token, username, icon, is_allowed_path): - """Initialize the service.""" + """Define the Slack notification logic.""" + def __init__(self, hass, client, default_channel, username, icon): + """Initialize.""" + self._client = client self._default_channel = default_channel - self._api_token = api_token - self._username = username + self._hass = hass self._icon = icon - if self._username or self._icon: + + if username or self._icon: self._as_user = False else: self._as_user = True - self.is_allowed_path = is_allowed_path - self.slack = Slacker(self._api_token) - self.slack.auth.test() + async def _async_send_local_file_message(self, path, targets, message, title): + """Upload a local file (with message) to Slack.""" + if not self._hass.config.is_allowed_path(path): + _LOGGER.error("Path does not exist or is not allowed: %s", path) + return - def send_message(self, message="", **kwargs): - """Send a message to a user.""" + parsed_url = urlparse(path) + filename = os.path.basename(parsed_url.path) - if kwargs.get(ATTR_TARGET) is None: - targets = [self._default_channel] - else: - targets = kwargs.get(ATTR_TARGET) - - data = kwargs.get(ATTR_DATA) - attachments = data.get(ATTR_ATTACHMENTS) if data else None - file = data.get(ATTR_FILE) if data else None - title = kwargs.get(ATTR_TITLE) - - for target in targets: - try: - if file is not None: - # Load from file or URL - file_as_bytes = self.load_file( - url=file.get(ATTR_FILE_URL), - local_path=file.get(ATTR_FILE_PATH), - username=file.get(ATTR_FILE_USERNAME), - password=file.get(ATTR_FILE_PASSWORD), - auth=file.get(ATTR_FILE_AUTH), - ) - # Choose filename - if file.get(ATTR_FILE_URL): - filename = file.get(ATTR_FILE_URL) - else: - filename = file.get(ATTR_FILE_PATH) - # Prepare structure for Slack API - data = { - "content": None, - "filetype": None, - "filename": filename, - # If optional title is none use the filename - "title": title if title else filename, - "initial_comment": message, - "channels": target, - } - # Post to slack - self.slack.files.post( - "files.upload", data=data, files={"file": file_as_bytes} - ) - else: - self.slack.chat.post_message( - target, - message, - as_user=self._as_user, - username=self._username, - icon_emoji=self._icon, - attachments=attachments, - link_names=True, - ) - except slacker.Error as err: - _LOGGER.error("Could not send notification. Error: %s", err) - - def load_file( - self, url=None, local_path=None, username=None, password=None, auth=None - ): - """Load image/document/etc from a local path or URL.""" try: - if url: - # Check whether authentication parameters are provided - if username: - # Use digest or basic authentication - if ATTR_FILE_AUTH_DIGEST == auth: - auth_ = HTTPDigestAuth(username, password) - else: - auth_ = HTTPBasicAuth(username, password) - # Load file from URL with authentication - req = requests.get(url, auth=auth_, timeout=CONF_TIMEOUT) - else: - # Load file from URL without authentication - req = requests.get(url, timeout=CONF_TIMEOUT) - return req.content + await self._client.files_upload( + channels=",".join(targets), + file=path, + filename=filename, + initial_comment=message, + title=title or filename, + ) + except SlackApiError as err: + _LOGGER.error("Error while uploading file-based message: %s", err) - if local_path: - # Check whether path is whitelisted in configuration.yaml - if self.is_allowed_path(local_path): - return open(local_path, "rb") - _LOGGER.warning("'%s' is not secure to load data from!", local_path) - else: - _LOGGER.warning("Neither URL nor local path found in parameters!") + async def _async_send_text_only_message( + self, targets, message, title, attachments, blocks + ): + """Send a text-only message.""" + tasks = { + target: self._client.chat_postMessage( + channel=target, + text=message, + as_user=self._as_user, + attachments=attachments, + blocks=blocks, + icon_emoji=self._icon, + link_names=True, + ) + for target in targets + } - except OSError as error: - _LOGGER.error("Can't load from URL or local path: %s", error) + results = await asyncio.gather(*tasks.values(), return_exceptions=True) + for target, result in zip(tasks, results): + if isinstance(result, SlackApiError): + _LOGGER.error( + "There was a Slack API error while sending to %s: %s", + target, + result, + ) - return None + async def async_send_message(self, message, **kwargs): + """Send a message to Slack.""" + data = kwargs[ATTR_DATA] or {} + title = kwargs.get(ATTR_TITLE) + targets = _async_sanitize_channel_names( + kwargs.get(ATTR_TARGET, [self._default_channel]) + ) + + if ATTR_FILE in data: + return await self._async_send_local_file_message( + 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/" + ) + blocks = data.get(ATTR_BLOCKS, {}) + + return await self._async_send_text_only_message( + targets, message, title, attachments, blocks + ) diff --git a/requirements_all.txt b/requirements_all.txt index 3063b5d1030..46fd27fa1fd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1879,7 +1879,7 @@ sisyphus-control==2.2.1 skybellpy==0.4.0 # homeassistant.components.slack -slacker==0.14.0 +slackclient==2.5.0 # homeassistant.components.sleepiq sleepyq==0.7