1 """Jabber (XMPP) notification service."""
3 from __future__
import annotations
5 from concurrent.futures
import TimeoutError
as FutTimeoutError
6 from http
import HTTPStatus
15 from slixmpp.exceptions
import IqError, IqTimeout, XMPPError
16 from slixmpp.plugins.xep_0363.http_upload
import (
19 UploadServiceNotFound,
21 from slixmpp.xmlstream.xmlstream
import NotConnectedError
22 import voluptuous
as vol
27 PLATFORM_SCHEMA
as NOTIFY_PLATFORM_SCHEMA,
28 BaseNotificationService,
42 _LOGGER = logging.getLogger(__name__)
46 ATTR_PATH_TEMPLATE =
"path_template"
47 ATTR_TIMEOUT =
"timeout"
49 ATTR_URL_TEMPLATE =
"url_template"
50 ATTR_VERIFY =
"verify"
53 CONF_VERIFY =
"verify"
55 DEFAULT_CONTENT_TYPE =
"application/octet-stream"
56 DEFAULT_RESOURCE =
"home-assistant"
59 PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend(
61 vol.Required(CONF_SENDER): cv.string,
62 vol.Required(CONF_PASSWORD): cv.string,
63 vol.Required(CONF_RECIPIENT): vol.All(cv.ensure_list, [cv.string]),
64 vol.Optional(CONF_RESOURCE, default=DEFAULT_RESOURCE): cv.string,
65 vol.Optional(CONF_ROOM, default=
""): cv.string,
66 vol.Optional(CONF_TLS, default=
True): cv.boolean,
67 vol.Optional(CONF_VERIFY, default=
True): cv.boolean,
75 discovery_info: DiscoveryInfoType |
None =
None,
76 ) -> XmppNotificationService:
77 """Get the Jabber (XMPP) notification service."""
79 config.get(CONF_SENDER),
80 config.get(CONF_RESOURCE),
81 config.get(CONF_PASSWORD),
82 config.get(CONF_RECIPIENT),
84 config.get(CONF_VERIFY),
85 config.get(CONF_ROOM),
91 """Implement the notification service for Jabber (XMPP)."""
93 def __init__(self, sender, resource, password, recipient, tls, verify, room, hass):
94 """Initialize the service."""
105 """Send a message to a user."""
106 title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
107 text = f
"{title}: {message}" if title
else message
108 data = kwargs.get(ATTR_DATA)
109 timeout = data.get(ATTR_TIMEOUT, XEP_0363_TIMEOUT)
if data
else None
112 f
"{self._sender}/{self._resource}",
137 """Send a message over XMPP."""
139 class SendNotificationBot(slixmpp.ClientXMPP):
140 """Service for sending Jabber (XMPP) messages."""
143 """Initialize the Jabber Bot."""
146 self.loop = hass.loop
148 self.force_starttls = use_tls
149 self.use_ipv6 =
False
150 self.add_event_handler(
"failed_all_auth", self.disconnect_on_login_fail)
151 self.add_event_handler(
"session_start", self.start)
154 self.register_plugin(
"xep_0045")
155 if not verify_certificate:
156 self.add_event_handler(
157 "ssl_invalid_cert", self.discard_ssl_invalid_cert
161 self.register_plugin(
"xep_0030")
162 self.register_plugin(
"xep_0066")
163 self.register_plugin(
"xep_0071")
164 self.register_plugin(
"xep_0128")
165 self.register_plugin(
"xep_0363")
167 self.connect(force_starttls=self.force_starttls, use_ssl=
False)
169 async
def start(self, event):
170 """Start the communication and sends the message."""
172 _LOGGER.debug(
"Joining room %s", room)
173 await self.plugin[
"xep_0045"].join_muc_wait(room, sender, seconds=0)
176 await self.send_file(timeout=timeout)
178 self.send_text_message()
182 async
def send_file(self, timeout=None):
183 """Send file via XMPP.
185 Send XMPP file message using OOB (XEP_0066) and
186 HTTP Upload (XEP_0363)
190 _LOGGER.debug(
"Timeout set to %ss", timeout)
191 url = await self.upload_file(timeout=timeout)
193 _LOGGER.debug(
"Upload success")
194 for recipient
in recipients:
196 _LOGGER.debug(
"Sending file to %s", room)
197 message = self.Message(sto=room, stype=
"groupchat")
199 _LOGGER.debug(
"Sending file to %s", recipient)
200 message = self.Message(sto=recipient, stype=
"chat")
201 message[
"body"] = url
202 message[
"oob"][
"url"] = url
205 except (IqError, IqTimeout, XMPPError)
as ex:
206 _LOGGER.error(
"Could not send image message %s", ex)
209 except (IqError, IqTimeout, XMPPError)
as ex:
210 _LOGGER.error(
"Upload error, could not send message %s", ex)
211 except NotConnectedError
as ex:
212 _LOGGER.error(
"Connection error %s", ex)
213 except FileTooBig
as ex:
214 _LOGGER.error(
"File too big for server, could not upload file %s", ex)
215 except UploadServiceNotFound
as ex:
216 _LOGGER.error(
"UploadServiceNotFound, could not upload file %s", ex)
217 except FileUploadError
as ex:
218 _LOGGER.error(
"FileUploadError, could not upload file %s", ex)
219 except requests.exceptions.SSLError
as ex:
220 _LOGGER.error(
"Cannot establish SSL connection %s", ex)
221 except requests.exceptions.ConnectionError
as ex:
222 _LOGGER.error(
"Cannot connect to server %s", ex)
229 _LOGGER.error(
"Error reading file %s", ex)
230 except FutTimeoutError
as ex:
231 _LOGGER.error(
"The server did not respond in time, %s", ex)
233 async
def upload_file(self, timeout=None):
234 """Upload file to Jabber server and return new URL.
236 upload a file with Jabber XEP_0363 from a remote URL or a local
237 file path and return a URL of that file.
239 if data.get(ATTR_URL_TEMPLATE):
240 _LOGGER.debug(
"Got url template: %s", data[ATTR_URL_TEMPLATE])
241 templ = template_helper.Template(data[ATTR_URL_TEMPLATE], hass)
242 get_url = template_helper.render_complex(templ,
None)
243 url = await self.upload_file_from_url(get_url, timeout=timeout)
244 elif data.get(ATTR_URL):
245 url = await self.upload_file_from_url(data[ATTR_URL], timeout=timeout)
246 elif data.get(ATTR_PATH_TEMPLATE):
247 _LOGGER.debug(
"Got path template: %s", data[ATTR_PATH_TEMPLATE])
248 templ = template_helper.Template(data[ATTR_PATH_TEMPLATE], hass)
249 get_path = template_helper.render_complex(templ,
None)
250 url = await self.upload_file_from_path(get_path, timeout=timeout)
251 elif data.get(ATTR_PATH):
252 url = await self.upload_file_from_path(data[ATTR_PATH], timeout=timeout)
257 _LOGGER.error(
"No path or URL found for file")
258 raise FileUploadError(
"Could not upload file")
262 async
def upload_file_from_url(self, url, timeout=None):
263 """Upload a file from a URL. Returns a URL.
265 uploaded via XEP_0363 and HTTP and returns the resulting URL
267 _LOGGER.debug(
"Getting file from %s", url)
270 """Return result for GET request to url."""
272 url, verify=data.get(ATTR_VERIFY,
True), timeout=timeout
275 result = await hass.async_add_executor_job(get_url, url)
277 if result.status_code >= HTTPStatus.BAD_REQUEST:
278 _LOGGER.error(
"Could not load file from %s", url)
281 filesize = len(result.content)
287 if data.get(ATTR_PATH):
289 filename = self.get_random_filename(data.get(ATTR_PATH))
292 mimetypes.guess_extension(result.headers[
"Content-Type"])
295 _LOGGER.debug(
"Got %s extension", extension)
296 filename = self.get_random_filename(
None, extension=extension)
298 _LOGGER.debug(
"Uploading file from URL, %s", filename)
300 return await self[
"xep_0363"].upload_file(
303 input_file=result.content,
304 content_type=result.headers[
"Content-Type"],
308 def _read_upload_file(self, path: str) -> bytes:
309 """Read file from path."""
310 with open(path,
"rb")
as upfile:
311 _LOGGER.debug(
"Reading file %s", path)
314 async
def upload_file_from_path(self, path: str, timeout=
None):
315 """Upload a file from a local file path via XEP_0363."""
316 _LOGGER.debug(
"Uploading file from path, %s", path)
318 if not hass.config.is_allowed_path(path):
319 raise PermissionError(
"Could not access file. Path not allowed")
321 input_file = await hass.async_add_executor_job(self._read_upload_file, path)
322 filesize = len(input_file)
323 _LOGGER.debug(
"Filesize is %s bytes", filesize)
325 if (content_type := mimetypes.guess_type(path)[0])
is None:
326 content_type = DEFAULT_CONTENT_TYPE
327 _LOGGER.debug(
"Content type is %s", content_type)
330 filename = self.get_random_filename(data.get(ATTR_PATH))
331 _LOGGER.debug(
"Uploading file with random filename %s", filename)
333 return await self[
"xep_0363"].upload_file(
336 input_file=input_file,
337 content_type=content_type,
341 def send_text_message(self):
342 """Send a text only message to a room or a recipient."""
345 _LOGGER.debug(
"Sending message to room %s", room)
346 self.send_message(mto=room, mbody=message, mtype=
"groupchat")
348 for recipient
in recipients:
349 _LOGGER.debug(
"Sending message to %s", recipient)
350 self.send_message(mto=recipient, mbody=message, mtype=
"chat")
351 except (IqError, IqTimeout, XMPPError)
as ex:
352 _LOGGER.error(
"Could not send text message %s", ex)
353 except NotConnectedError
as ex:
354 _LOGGER.error(
"Connection error %s", ex)
356 def get_random_filename(self, filename, extension=None):
357 """Return a random filename, leaving the extension intact."""
358 if extension
is None:
359 path = pathlib.Path(filename)
361 extension =
"".join(path.suffixes)
365 "".join(random.choice(string.ascii_letters)
for i
in range(10))
369 def disconnect_on_login_fail(self, event):
370 """Disconnect from the server if credentials are invalid."""
371 _LOGGER.warning(
"Login failed")
375 def discard_ssl_invalid_cert(event):
376 """Do nothing if ssl certificate is invalid."""
377 _LOGGER.debug(
"Ignoring invalid SSL certificate as requested")
379 SendNotificationBot()
def async_send_message(self, message="", **kwargs)
def __init__(self, sender, resource, password, recipient, tls, verify, room, hass)
None __init__(self, _AOSmithCoordinatorT coordinator, str junction_id)
None open(self, **Any kwargs)
def async_send_message(sender, password, recipients, use_tls, verify_certificate, room, hass, message, timeout=None, data=None)
XmppNotificationService async_get_service(HomeAssistant hass, ConfigType config, DiscoveryInfoType|None discovery_info=None)
str get_url(HomeAssistant hass, *bool require_current_request=False, bool require_ssl=False, bool require_standard_port=False, bool require_cloud=False, bool allow_internal=True, bool allow_external=True, bool allow_cloud=True, bool|None allow_ip=None, bool|None prefer_external=None, bool prefer_cloud=False)