Home Assistant Unofficial Reference 2024.12.1
notify.py
Go to the documentation of this file.
1 """Slack platform for notify component."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 import logging
7 import os
8 from typing import Any, TypedDict
9 from urllib.parse import urlparse
10 
11 from aiohttp import BasicAuth, FormData
12 from aiohttp.client_exceptions import ClientError
13 from slack import WebClient
14 from slack.errors import SlackApiError
15 import voluptuous as vol
16 
18  ATTR_DATA,
19  ATTR_TARGET,
20  ATTR_TITLE,
21  BaseNotificationService,
22 )
23 from homeassistant.const import ATTR_ICON, CONF_PATH
24 from homeassistant.core import HomeAssistant, callback
25 from homeassistant.helpers import aiohttp_client, config_validation as cv, template
26 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
27 
28 from .const import (
29  ATTR_BLOCKS,
30  ATTR_BLOCKS_TEMPLATE,
31  ATTR_FILE,
32  ATTR_PASSWORD,
33  ATTR_PATH,
34  ATTR_THREAD_TS,
35  ATTR_URL,
36  ATTR_USERNAME,
37  CONF_DEFAULT_CHANNEL,
38  DATA_CLIENT,
39  SLACK_DATA,
40 )
41 
42 _LOGGER = logging.getLogger(__name__)
43 
44 FILE_PATH_SCHEMA = vol.Schema({vol.Required(CONF_PATH): cv.isfile})
45 
46 FILE_URL_SCHEMA = vol.Schema(
47  {
48  vol.Required(ATTR_URL): cv.url,
49  vol.Inclusive(ATTR_USERNAME, "credentials"): cv.string,
50  vol.Inclusive(ATTR_PASSWORD, "credentials"): cv.string,
51  }
52 )
53 
54 DATA_FILE_SCHEMA = vol.Schema(
55  {
56  vol.Required(ATTR_FILE): vol.Any(FILE_PATH_SCHEMA, FILE_URL_SCHEMA),
57  vol.Optional(ATTR_THREAD_TS): cv.string,
58  }
59 )
60 
61 DATA_TEXT_ONLY_SCHEMA = vol.Schema(
62  {
63  vol.Optional(ATTR_USERNAME): cv.string,
64  vol.Optional(ATTR_ICON): cv.string,
65  vol.Optional(ATTR_BLOCKS): list,
66  vol.Optional(ATTR_BLOCKS_TEMPLATE): list,
67  vol.Optional(ATTR_THREAD_TS): cv.string,
68  }
69 )
70 
71 DATA_SCHEMA = vol.All(
72  cv.ensure_list, [vol.Any(DATA_FILE_SCHEMA, DATA_TEXT_ONLY_SCHEMA)]
73 )
74 
75 
76 class AuthDictT(TypedDict, total=False):
77  """Type for auth request data."""
78 
79  auth: BasicAuth
80 
81 
82 class FormDataT(TypedDict, total=False):
83  """Type for form data, file upload."""
84 
85  channels: str
86  filename: str
87  initial_comment: str
88  title: str
89  token: str
90  thread_ts: str # Optional key
91 
92 
93 class MessageT(TypedDict, total=False):
94  """Type for message data."""
95 
96  link_names: bool
97  text: str
98  username: str # Optional key
99  icon_url: str # Optional key
100  icon_emoji: str # Optional key
101  blocks: list[Any] # Optional key
102  thread_ts: str # Optional key
103 
104 
106  hass: HomeAssistant,
107  config: ConfigType,
108  discovery_info: DiscoveryInfoType | None = None,
109 ) -> SlackNotificationService | None:
110  """Set up the Slack notification service."""
111  if discovery_info:
113  hass,
114  discovery_info[SLACK_DATA][DATA_CLIENT],
115  discovery_info,
116  )
117  return None
118 
119 
120 @callback
121 def _async_get_filename_from_url(url: str) -> str:
122  """Return the filename of a passed URL."""
123  parsed_url = urlparse(url)
124  return os.path.basename(parsed_url.path)
125 
126 
127 @callback
128 def _async_sanitize_channel_names(channel_list: list[str]) -> list[str]:
129  """Remove any # symbols from a channel list."""
130  return [channel.lstrip("#") for channel in channel_list]
131 
132 
133 class SlackNotificationService(BaseNotificationService):
134  """Define the Slack notification logic."""
135 
136  def __init__(
137  self,
138  hass: HomeAssistant,
139  client: WebClient,
140  config: dict[str, str],
141  ) -> None:
142  """Initialize."""
143  self._hass_hass = hass
144  self._client_client = client
145  self._config_config = config
146 
148  self,
149  path: str,
150  targets: list[str],
151  message: str,
152  title: str | None,
153  thread_ts: str | None,
154  ) -> None:
155  """Upload a local file (with message) to Slack."""
156  if not self._hass_hass.config.is_allowed_path(path):
157  _LOGGER.error("Path does not exist or is not allowed: %s", path)
158  return
159 
160  parsed_url = urlparse(path)
161  filename = os.path.basename(parsed_url.path)
162 
163  try:
164  await self._client_client.files_upload(
165  channels=",".join(targets),
166  file=path,
167  filename=filename,
168  initial_comment=message,
169  title=title or filename,
170  thread_ts=thread_ts or "",
171  )
172  except (SlackApiError, ClientError) as err:
173  _LOGGER.error("Error while uploading file-based message: %r", err)
174 
176  self,
177  url: str,
178  targets: list[str],
179  message: str,
180  title: str | None,
181  thread_ts: str | None,
182  *,
183  username: str | None = None,
184  password: str | None = None,
185  ) -> None:
186  """Upload a remote file (with message) to Slack.
187 
188  Note that we bypass the python-slackclient WebClient and use aiohttp directly,
189  as the former would require us to download the entire remote file into memory
190  first before uploading it to Slack.
191  """
192  if not self._hass_hass.config.is_allowed_external_url(url):
193  _LOGGER.error("URL is not allowed: %s", url)
194  return
195 
196  filename = _async_get_filename_from_url(url)
197  session = aiohttp_client.async_get_clientsession(self._hass_hass)
198 
199  kwargs: AuthDictT = {}
200  if username and password is not None:
201  kwargs = {"auth": BasicAuth(username, password=password)}
202 
203  resp = await session.request("get", url, **kwargs)
204 
205  try:
206  resp.raise_for_status()
207  except ClientError as err:
208  _LOGGER.error("Error while retrieving %s: %r", url, err)
209  return
210 
211  form_data: FormDataT = {
212  "channels": ",".join(targets),
213  "filename": filename,
214  "initial_comment": message,
215  "title": title or filename,
216  "token": self._client_client.token,
217  }
218 
219  if thread_ts:
220  form_data["thread_ts"] = thread_ts
221 
222  data = FormData(form_data, charset="utf-8")
223  data.add_field("file", resp.content, filename=filename)
224 
225  try:
226  await session.post("https://slack.com/api/files.upload", data=data)
227  except ClientError as err:
228  _LOGGER.error("Error while uploading file message: %r", err)
229 
231  self,
232  targets: list[str],
233  message: str,
234  title: str | None,
235  thread_ts: str | None,
236  *,
237  username: str | None = None,
238  icon: str | None = None,
239  blocks: Any | None = None,
240  ) -> None:
241  """Send a text-only message."""
242  message_dict: MessageT = {"link_names": True, "text": message}
243 
244  if username:
245  message_dict["username"] = username
246 
247  if icon:
248  if icon.lower().startswith(("http://", "https://")):
249  message_dict["icon_url"] = icon
250  else:
251  message_dict["icon_emoji"] = icon
252 
253  if blocks:
254  message_dict["blocks"] = blocks
255 
256  if thread_ts:
257  message_dict["thread_ts"] = thread_ts
258 
259  tasks = {
260  target: self._client_client.chat_postMessage(**message_dict, channel=target)
261  for target in targets
262  }
263 
264  results = await asyncio.gather(*tasks.values(), return_exceptions=True)
265  for target, result in zip(tasks, results, strict=False):
266  if isinstance(result, SlackApiError):
267  _LOGGER.error(
268  "There was a Slack API error while sending to %s: %r",
269  target,
270  result,
271  )
272  elif isinstance(result, ClientError):
273  _LOGGER.error("Error while sending message to %s: %r", target, result)
274 
275  async def async_send_message(self, message: str, **kwargs: Any) -> None:
276  """Send a message to Slack."""
277  data = kwargs.get(ATTR_DATA) or {}
278 
279  try:
280  DATA_SCHEMA(data)
281  except vol.Invalid as err:
282  _LOGGER.error("Invalid message data: %s", err)
283  data = {}
284 
285  title = kwargs.get(ATTR_TITLE)
287  kwargs.get(ATTR_TARGET, [self._config_config[CONF_DEFAULT_CHANNEL]])
288  )
289 
290  # Message Type 1: A text-only message
291  if ATTR_FILE not in data:
292  if ATTR_BLOCKS_TEMPLATE in data:
293  value = cv.template_complex(data[ATTR_BLOCKS_TEMPLATE])
294  blocks = template.render_complex(value)
295  elif ATTR_BLOCKS in data:
296  blocks = data[ATTR_BLOCKS]
297  else:
298  blocks = None
299 
300  return await self._async_send_text_only_message_async_send_text_only_message(
301  targets,
302  message,
303  title,
304  username=data.get(ATTR_USERNAME, self._config_config.get(ATTR_USERNAME)),
305  icon=data.get(ATTR_ICON, self._config_config.get(ATTR_ICON)),
306  thread_ts=data.get(ATTR_THREAD_TS),
307  blocks=blocks,
308  )
309 
310  # Message Type 2: A message that uploads a remote file
311  if ATTR_URL in data[ATTR_FILE]:
312  return await self._async_send_remote_file_message_async_send_remote_file_message(
313  data[ATTR_FILE][ATTR_URL],
314  targets,
315  message,
316  title,
317  thread_ts=data.get(ATTR_THREAD_TS),
318  username=data[ATTR_FILE].get(ATTR_USERNAME),
319  password=data[ATTR_FILE].get(ATTR_PASSWORD),
320  )
321 
322  # Message Type 3: A message that uploads a local file
323  return await self._async_send_local_file_message_async_send_local_file_message(
324  data[ATTR_FILE][ATTR_PATH],
325  targets,
326  message,
327  title,
328  thread_ts=data.get(ATTR_THREAD_TS),
329  )
None _async_send_text_only_message(self, list[str] targets, str message, str|None title, str|None thread_ts, *str|None username=None, str|None icon=None, Any|None blocks=None)
Definition: notify.py:240
None __init__(self, HomeAssistant hass, WebClient client, dict[str, str] config)
Definition: notify.py:141
None _async_send_remote_file_message(self, str url, list[str] targets, str message, str|None title, str|None thread_ts, *str|None username=None, str|None password=None)
Definition: notify.py:185
None async_send_message(self, str message, **Any kwargs)
Definition: notify.py:275
None _async_send_local_file_message(self, str path, list[str] targets, str message, str|None title, str|None thread_ts)
Definition: notify.py:154
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
str _async_get_filename_from_url(str url)
Definition: notify.py:121
SlackNotificationService|None async_get_service(HomeAssistant hass, ConfigType config, DiscoveryInfoType|None discovery_info=None)
Definition: notify.py:109
list[str] _async_sanitize_channel_names(list[str] channel_list)
Definition: notify.py:128