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:
parent
6208d8c911
commit
23668f3c5e
3 changed files with 108 additions and 126 deletions
|
@ -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": []
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue