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:
return SlackNotificationService( await client.auth_test()
channel, api_key, username, icon, hass.config.is_allowed_path except SlackApiError as err:
) _LOGGER.error("Error while setting up integration: %s", err)
return
except slacker.Error: return SlackNotificationService(
_LOGGER.exception("Authentication failed") hass,
return None 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): 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:
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: try:
if url: await self._client.files_upload(
# Check whether authentication parameters are provided channels=",".join(targets),
if username: file=path,
# Use digest or basic authentication filename=filename,
if ATTR_FILE_AUTH_DIGEST == auth: initial_comment=message,
auth_ = HTTPDigestAuth(username, password) title=title or filename,
else: )
auth_ = HTTPBasicAuth(username, password) except SlackApiError as err:
# Load file from URL with authentication _LOGGER.error("Error while uploading file-based message: %s", err)
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_text_only_message(
# Check whether path is whitelisted in configuration.yaml self, targets, message, title, attachments, blocks
if self.is_allowed_path(local_path): ):
return open(local_path, "rb") """Send a text-only message."""
_LOGGER.warning("'%s' is not secure to load data from!", local_path) tasks = {
else: target: self._client.chat_postMessage(
_LOGGER.warning("Neither URL nor local path found in parameters!") 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: results = await asyncio.gather(*tasks.values(), return_exceptions=True)
_LOGGER.error("Can't load from URL or local path: %s", error) 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
)

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