Overhaul the Slack integration (async and Block Kit support) (#33287)

* Overhaul the Slack integration

* Docstring

* Empty commit to re-trigger build

* Remove remote file option

* Remove unused function

* Adjust log message

* Update homeassistant/components/slack/notify.py

Co-Authored-By: Paulus Schoutsen <paulus@home-assistant.io>

* Code review

* Add deprecation warning

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
Aaron Bach 2020-03-30 21:32:29 -06:00 committed by GitHub
parent 6208d8c911
commit 23668f3c5e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 108 additions and 126 deletions

View File

@ -2,7 +2,7 @@
"domain": "slack", "domain": "slack",
"name": "Slack", "name": "Slack",
"documentation": "https://www.home-assistant.io/integrations/slack", "documentation": "https://www.home-assistant.io/integrations/slack",
"requirements": ["slacker==0.14.0"], "requirements": ["slackclient==2.5.0"],
"dependencies": [], "dependencies": [],
"codeowners": [] "codeowners": []
} }

View File

@ -1,10 +1,11 @@
"""Slack platform for notify component.""" """Slack platform for notify component."""
import asyncio
import logging import logging
import os
from urllib.parse import urlparse
import requests from slack import WebClient
from requests.auth import HTTPBasicAuth, HTTPDigestAuth from slack.errors import SlackApiError
import slacker
from slacker import Slacker
import voluptuous as vol import voluptuous as vol
from homeassistant.components.notify import ( from homeassistant.components.notify import (
@ -15,157 +16,138 @@ from homeassistant.components.notify import (
BaseNotificationService, BaseNotificationService,
) )
from homeassistant.const import CONF_API_KEY, CONF_ICON, CONF_USERNAME 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__) _LOGGER = logging.getLogger(__name__)
CONF_CHANNEL = "default_channel"
CONF_TIMEOUT = 15
# Top level attributes in 'data'
ATTR_ATTACHMENTS = "attachments" ATTR_ATTACHMENTS = "attachments"
ATTR_BLOCKS = "blocks"
ATTR_FILE = "file" ATTR_FILE = "file"
# Attributes contained in file
ATTR_FILE_URL = "url" CONF_DEFAULT_CHANNEL = "default_channel"
ATTR_FILE_PATH = "path"
ATTR_FILE_USERNAME = "username" DEFAULT_TIMEOUT_SECONDS = 15
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"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{ {
vol.Required(CONF_API_KEY): cv.string, 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_ICON): cv.string,
vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_USERNAME): cv.string,
} }
) )
def get_service(hass, config, discovery_info=None): async def async_get_service(hass, config, discovery_info=None):
"""Get the Slack notification service.""" """Set up the Slack notification service."""
session = aiohttp_client.async_get_clientsession(hass)
channel = config.get(CONF_CHANNEL) client = WebClient(token=config[CONF_API_KEY], run_async=True, session=session)
api_key = config.get(CONF_API_KEY)
username = config.get(CONF_USERNAME)
icon = config.get(CONF_ICON)
try: try:
await client.auth_test()
except SlackApiError as err:
_LOGGER.error("Error while setting up integration: %s", err)
return
return SlackNotificationService( return SlackNotificationService(
channel, api_key, username, icon, hass.config.is_allowed_path hass,
client,
config[CONF_DEFAULT_CHANNEL],
username=config.get(CONF_USERNAME),
icon=config.get(CONF_ICON),
) )
except slacker.Error:
_LOGGER.exception("Authentication failed") @callback
return None 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): class SlackNotificationService(BaseNotificationService):
"""Implement the notification service for Slack.""" """Define the Slack notification logic."""
def __init__(self, default_channel, api_token, username, icon, is_allowed_path):
"""Initialize the service."""
def __init__(self, hass, client, default_channel, username, icon):
"""Initialize."""
self._client = client
self._default_channel = default_channel self._default_channel = default_channel
self._api_token = api_token self._hass = hass
self._username = username
self._icon = icon self._icon = icon
if self._username or self._icon:
if username or self._icon:
self._as_user = False self._as_user = False
else: else:
self._as_user = True self._as_user = True
self.is_allowed_path = is_allowed_path async def _async_send_local_file_message(self, path, targets, message, title):
self.slack = Slacker(self._api_token) """Upload a local file (with message) to Slack."""
self.slack.auth.test() 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): parsed_url = urlparse(path)
"""Send a message to a user.""" 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: try:
if file is not None: await self._client.files_upload(
# Load from file or URL channels=",".join(targets),
file_as_bytes = self.load_file( file=path,
url=file.get(ATTR_FILE_URL), filename=filename,
local_path=file.get(ATTR_FILE_PATH), initial_comment=message,
username=file.get(ATTR_FILE_USERNAME), title=title or filename,
password=file.get(ATTR_FILE_PASSWORD),
auth=file.get(ATTR_FILE_AUTH),
) )
# Choose filename except SlackApiError as err:
if file.get(ATTR_FILE_URL): _LOGGER.error("Error while uploading file-based message: %s", err)
filename = file.get(ATTR_FILE_URL)
else: async def _async_send_text_only_message(
filename = file.get(ATTR_FILE_PATH) self, targets, message, title, attachments, blocks
# Prepare structure for Slack API ):
data = { """Send a text-only message."""
"content": None, tasks = {
"filetype": None, target: self._client.chat_postMessage(
"filename": filename, channel=target,
# If optional title is none use the filename text=message,
"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, as_user=self._as_user,
username=self._username,
icon_emoji=self._icon,
attachments=attachments, attachments=attachments,
blocks=blocks,
icon_emoji=self._icon,
link_names=True, link_names=True,
) )
except slacker.Error as err: for target in targets
_LOGGER.error("Could not send notification. Error: %s", err) }
def load_file( results = await asyncio.gather(*tasks.values(), return_exceptions=True)
self, url=None, local_path=None, username=None, password=None, auth=None for target, result in zip(tasks, results):
): if isinstance(result, SlackApiError):
"""Load image/document/etc from a local path or URL.""" _LOGGER.error(
try: "There was a Slack API error while sending to %s: %s",
if url: target,
# Check whether authentication parameters are provided result,
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
if local_path: async def async_send_message(self, message, **kwargs):
# Check whether path is whitelisted in configuration.yaml """Send a message to Slack."""
if self.is_allowed_path(local_path): data = kwargs[ATTR_DATA] or {}
return open(local_path, "rb") title = kwargs.get(ATTR_TITLE)
_LOGGER.warning("'%s' is not secure to load data from!", local_path) targets = _async_sanitize_channel_names(
else: kwargs.get(ATTR_TARGET, [self._default_channel])
_LOGGER.warning("Neither URL nor local path found in parameters!") )
except OSError as error: if ATTR_FILE in data:
_LOGGER.error("Can't load from URL or local path: %s", error) return await self._async_send_local_file_message(
data[ATTR_FILE], targets, message, title
)
return None 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
)

View File

@ -1879,7 +1879,7 @@ sisyphus-control==2.2.1
skybellpy==0.4.0 skybellpy==0.4.0
# homeassistant.components.slack # homeassistant.components.slack
slacker==0.14.0 slackclient==2.5.0
# homeassistant.components.sleepiq # homeassistant.components.sleepiq
sleepyq==0.7 sleepyq==0.7