Home Assistant Unofficial Reference 2024.12.1
notify.py
Go to the documentation of this file.
1 """Discord platform for notify component."""
2 
3 from __future__ import annotations
4 
5 from io import BytesIO
6 import logging
7 import os.path
8 from typing import Any, cast
9 
10 import aiohttp
11 import nextcord
12 from nextcord.abc import Messageable
13 
15  ATTR_DATA,
16  ATTR_TARGET,
17  BaseNotificationService,
18 )
19 from homeassistant.const import CONF_API_TOKEN
20 from homeassistant.core import HomeAssistant
21 from homeassistant.helpers.aiohttp_client import async_get_clientsession
22 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
23 
24 _LOGGER = logging.getLogger(__name__)
25 
26 ATTR_EMBED = "embed"
27 ATTR_EMBED_AUTHOR = "author"
28 ATTR_EMBED_COLOR = "color"
29 ATTR_EMBED_DESCRIPTION = "description"
30 ATTR_EMBED_FIELDS = "fields"
31 ATTR_EMBED_FOOTER = "footer"
32 ATTR_EMBED_TITLE = "title"
33 ATTR_EMBED_THUMBNAIL = "thumbnail"
34 ATTR_EMBED_IMAGE = "image"
35 ATTR_EMBED_URL = "url"
36 ATTR_IMAGES = "images"
37 ATTR_URLS = "urls"
38 ATTR_VERIFY_SSL = "verify_ssl"
39 
40 MAX_ALLOWED_DOWNLOAD_SIZE_BYTES = 8000000
41 
42 
44  hass: HomeAssistant,
45  config: ConfigType,
46  discovery_info: DiscoveryInfoType | None = None,
47 ) -> DiscordNotificationService | None:
48  """Get the Discord notification service."""
49  if discovery_info is None:
50  return None
51  return DiscordNotificationService(hass, discovery_info[CONF_API_TOKEN])
52 
53 
54 class DiscordNotificationService(BaseNotificationService):
55  """Implement the notification service for Discord."""
56 
57  def __init__(self, hass: HomeAssistant, token: str) -> None:
58  """Initialize the service."""
59  self.tokentoken = token
60  self.hasshass = hass
61 
62  def file_exists(self, filename: str) -> bool:
63  """Check if a file exists on disk and is in authorized path."""
64  if not self.hasshass.config.is_allowed_path(filename):
65  _LOGGER.warning("Path not allowed: %s", filename)
66  return False
67  if not os.path.isfile(filename):
68  _LOGGER.warning("Not a file: %s", filename)
69  return False
70  return True
71 
73  self, url: str, verify_ssl: bool, max_file_size: int
74  ) -> bytearray | None:
75  """Retrieve file bytes from URL."""
76  if not self.hasshass.config.is_allowed_external_url(url):
77  _LOGGER.error("URL not allowed: %s", url)
78  return None
79 
80  session = async_get_clientsession(self.hasshass)
81 
82  async with session.get(
83  url,
84  ssl=verify_ssl,
85  timeout=aiohttp.ClientTimeout(total=30),
86  raise_for_status=True,
87  ) as resp:
88  content_length = resp.headers.get("Content-Length")
89 
90  if content_length is not None and int(content_length) > max_file_size:
91  _LOGGER.error(
92  (
93  "Attachment too large (Content-Length reports %s). Max size: %s"
94  " bytes"
95  ),
96  int(content_length),
97  max_file_size,
98  )
99  return None
100 
101  file_size = 0
102  byte_chunks = bytearray()
103 
104  async for byte_chunk, _ in resp.content.iter_chunks():
105  file_size += len(byte_chunk)
106  if file_size > max_file_size:
107  _LOGGER.error(
108  "Attachment too large (Stream reports %s). Max size: %s bytes",
109  file_size,
110  max_file_size,
111  )
112  return None
113 
114  byte_chunks.extend(byte_chunk)
115 
116  return byte_chunks
117 
118  async def async_send_message(self, message: str, **kwargs: Any) -> None:
119  """Login to Discord, send message to channel(s) and log out."""
120  nextcord.VoiceClient.warn_nacl = False
121  discord_bot = nextcord.Client()
122  images = []
123  embedding = None
124 
125  if ATTR_TARGET not in kwargs:
126  _LOGGER.error("No target specified")
127  return
128 
129  data = kwargs.get(ATTR_DATA) or {}
130 
131  embeds: list[nextcord.Embed] = []
132  if ATTR_EMBED in data:
133  embedding = data[ATTR_EMBED]
134  title = embedding.get(ATTR_EMBED_TITLE)
135  description = embedding.get(ATTR_EMBED_DESCRIPTION)
136  color = embedding.get(ATTR_EMBED_COLOR)
137  url = embedding.get(ATTR_EMBED_URL)
138  fields = embedding.get(ATTR_EMBED_FIELDS) or []
139 
140  if embedding:
141  embed = nextcord.Embed(
142  title=title, description=description, color=color, url=url
143  )
144  for field in fields:
145  embed.add_field(**field)
146  if ATTR_EMBED_FOOTER in embedding:
147  embed.set_footer(**embedding[ATTR_EMBED_FOOTER])
148  if ATTR_EMBED_AUTHOR in embedding:
149  embed.set_author(**embedding[ATTR_EMBED_AUTHOR])
150  if ATTR_EMBED_THUMBNAIL in embedding:
151  embed.set_thumbnail(**embedding[ATTR_EMBED_THUMBNAIL])
152  if ATTR_EMBED_IMAGE in embedding:
153  embed.set_image(**embedding[ATTR_EMBED_IMAGE])
154  embeds.append(embed)
155 
156  if ATTR_IMAGES in data:
157  for image in data.get(ATTR_IMAGES, []):
158  image_exists = await self.hasshass.async_add_executor_job(
159  self.file_existsfile_exists, image
160  )
161 
162  filename = os.path.basename(image)
163 
164  if image_exists:
165  images.append((image, filename))
166 
167  if ATTR_URLS in data:
168  for url in data.get(ATTR_URLS, []):
169  file = await self.async_get_file_from_urlasync_get_file_from_url(
170  url,
171  data.get(ATTR_VERIFY_SSL, True),
172  MAX_ALLOWED_DOWNLOAD_SIZE_BYTES,
173  )
174 
175  if file is not None:
176  filename = os.path.basename(url)
177 
178  images.append((BytesIO(file), filename))
179 
180  await discord_bot.login(self.tokentoken)
181 
182  try:
183  for channelid in kwargs[ATTR_TARGET]:
184  channelid = int(channelid)
185  # Must create new instances of File for each channel.
186  files = [nextcord.File(image, filename) for image, filename in images]
187  try:
188  channel = cast(
189  Messageable, await discord_bot.fetch_channel(channelid)
190  )
191  except nextcord.NotFound:
192  try:
193  channel = await discord_bot.fetch_user(channelid)
194  except nextcord.NotFound:
195  _LOGGER.warning("Channel not found for ID: %s", channelid)
196  continue
197  await channel.send(message, files=files, embeds=embeds)
198  except (nextcord.HTTPException, nextcord.NotFound) as error:
199  _LOGGER.warning("Communication error: %s", error)
200  await discord_bot.close()
None __init__(self, HomeAssistant hass, str token)
Definition: notify.py:57
bytearray|None async_get_file_from_url(self, str url, bool verify_ssl, int max_file_size)
Definition: notify.py:74
None async_send_message(self, str message, **Any kwargs)
Definition: notify.py:118
DiscordNotificationService|None async_get_service(HomeAssistant hass, ConfigType config, DiscoveryInfoType|None discovery_info=None)
Definition: notify.py:47
aiohttp.ClientSession async_get_clientsession(HomeAssistant hass, bool verify_ssl=True, socket.AddressFamily family=socket.AF_UNSPEC, ssl_util.SSLCipherList ssl_cipher=ssl_util.SSLCipherList.PYTHON_DEFAULT)