From 554c7f0b10f6e389e914fa0d2b6f42893511e515 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Tue, 21 May 2019 21:50:50 -0400 Subject: [PATCH] Rocket.Chat Refactored for Webhook Support; refs #107 --- README.md | 2 +- apprise/plugins/NotifyRocketChat.py | 305 ++++++++++++++++++++++++---- test/test_rest_plugins.py | 67 +++++- 3 files changed, 323 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 4be81b7a..0eca4449 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ The table below identifies the services this tool supports and some example serv | [Pushjet](https://github.com/caronc/apprise/wiki/Notify_pushjet) | pjet:// or pjets:// | (TCP) 80 or 443 | pjet://secret@hostname
pjet://secret@hostname:port
pjets://secret@hostname
pjets://secret@hostname:port | [Pushed](https://github.com/caronc/apprise/wiki/Notify_pushed) | pushed:// | (TCP) 443 | pushed://appkey/appsecret/
pushed://appkey/appsecret/#ChannelAlias
pushed://appkey/appsecret/#ChannelAlias1/#ChannelAlias2/#ChannelAliasN
pushed://appkey/appsecret/@UserPushedID
pushed://appkey/appsecret/@UserPushedID1/@UserPushedID2/@UserPushedIDN | [Pushover](https://github.com/caronc/apprise/wiki/Notify_pushover) | pover:// | (TCP) 443 | pover://user@token
pover://user@token/DEVICE
pover://user@token/DEVICE1/DEVICE2/DEVICEN
**Note**: you must specify both your user_id and token -| [Rocket.Chat](https://github.com/caronc/apprise/wiki/Notify_rocketchat) | rocket:// or rockets:// | (TCP) 80 or 443 | rocket://user:password@hostname/RoomID/Channel
rockets://user:password@hostname:443/Channel1/Channel1/RoomID
rocket://user:password@hostname/Channel +| [Rocket.Chat](https://github.com/caronc/apprise/wiki/Notify_rocketchat) | rocket:// or rockets:// | (TCP) 80 or 443 | rocket://user:password@hostname/RoomID/Channel
rockets://user:password@hostname:443/#Channel1/#Channel1/RoomID
rocket://user:password@hostname/#Channel
rocket://webhook@hostname
rockets://webhook@hostname/@User/#Channel | [Ryver](https://github.com/caronc/apprise/wiki/Notify_ryver) | ryver:// | (TCP) 443 | ryver://Organization/Token
ryver://botname@Organization/Token | [Slack](https://github.com/caronc/apprise/wiki/Notify_slack) | slack:// | (TCP) 443 | slack://TokenA/TokenB/TokenC/
slack://TokenA/TokenB/TokenC/Channel
slack://botname@TokenA/TokenB/TokenC/Channel
slack://user@TokenA/TokenB/TokenC/Channel1/Channel2/ChannelN | [Telegram](https://github.com/caronc/apprise/wiki/Notify_telegram) | tgram:// | (TCP) 443 | tgram://bottoken/ChatID
tgram://bottoken/ChatID1/ChatID2/ChatIDN diff --git a/apprise/plugins/NotifyRocketChat.py b/apprise/plugins/NotifyRocketChat.py index 96b3891f..fbd8b23d 100644 --- a/apprise/plugins/NotifyRocketChat.py +++ b/apprise/plugins/NotifyRocketChat.py @@ -24,15 +24,21 @@ # THE SOFTWARE. import re +import six import requests from json import loads +from json import dumps from itertools import chain from .NotifyBase import NotifyBase +from ..common import NotifyImageSize +from ..common import NotifyFormat from ..common import NotifyType from ..utils import parse_list +from ..utils import parse_bool -IS_CHANNEL = re.compile(r'^#(?P[A-Za-z0-9]+)$') +IS_CHANNEL = re.compile(r'^#(?P[A-Za-z0-9_-]+)$') +IS_USER = re.compile(r'^@(?P[A-Za-z0-9._-]+)$') IS_ROOM_ID = re.compile(r'^(?P[A-Za-z0-9]+)$') # Extend HTTP Error Messages @@ -46,6 +52,24 @@ RC_HTTP_ERROR_MAP = { LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') +class RocketChatAuthMode(object): + """ + The Chat Authentication mode is detected + """ + # providing a webhook + WEBHOOK = "webhook" + + # Providing a username and password (default) + BASIC = "basic" + + +# Define our authentication modes +ROCKETCHAT_AUTH_MODES = ( + RocketChatAuthMode.WEBHOOK, + RocketChatAuthMode.BASIC, +) + + class NotifyRocketChat(NotifyBase): """ A wrapper for Notify Rocket.Chat Notifications @@ -66,13 +90,21 @@ class NotifyRocketChat(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_rocketchat' + # Allows the user to specify the NotifyImageSize object; this is supported + # through the webhook + image_size = NotifyImageSize.XY_128 + # The title is not used title_maxlen = 0 # The maximum size of the message - body_maxlen = 200 + body_maxlen = 1000 - def __init__(self, targets=None, **kwargs): + # Default to markdown + notify_format = NotifyFormat.MARKDOWN + + def __init__(self, webhook=None, targets=None, mode=None, + include_avatar=True, **kwargs): """ Initialize Notify Rocket.Chat Object """ @@ -87,19 +119,55 @@ class NotifyRocketChat(NotifyBase): if isinstance(self.port, int): self.api_url += ':%d' % self.port - self.api_url += '/api/v1/' - # Initialize channels list self.channels = list() # Initialize room list self.rooms = list() - if not (self.user and self.password): + # Initialize user list (webhook only) + self.users = list() + + # Assign our webhook (if defined) + self.webhook = webhook + + # Place an avatar image to associate with our content + self.include_avatar = include_avatar + + # Used to track token headers upon authentication (if successful) + # This is only used if not on webhook mode + self.headers = {} + + # Authentication mode + self.mode = None \ + if not isinstance(mode, six.string_types) \ + else mode.lower() + + if self.mode and self.mode not in ROCKETCHAT_AUTH_MODES: + msg = 'The authentication mode specified ({}) is invalid.'.format( + mode) + self.logger.warning(msg) + raise TypeError(msg) + + # Detect our mode if it wasn't specified + if not self.mode: + if self.webhook is not None: + # Just a username was specified, we treat this as a webhook + self.mode = RocketChatAuthMode.WEBHOOK + else: + self.mode = RocketChatAuthMode.BASIC + + if self.mode == RocketChatAuthMode.BASIC \ + and not (self.user and self.password): # Username & Password is required for Rocket Chat to work - raise TypeError( - 'No Rocket.Chat user/pass combo specified.' - ) + msg = 'No Rocket.Chat user/pass combo was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + elif self.mode == RocketChatAuthMode.WEBHOOK and not self.webhook: + msg = 'No Rocket.Chat Incoming Webhook was specified.' + self.logger.warning(msg) + raise TypeError(msg) # Validate recipients and drop bad ones: for recipient in parse_list(targets): @@ -115,18 +183,24 @@ class NotifyRocketChat(NotifyBase): self.rooms.append(result.group('name')) continue + result = IS_USER.match(recipient) + if result: + # store valid room + self.users.append(result.group('name')) + continue + self.logger.warning( - 'Dropped invalid channel/room ' - '(%s) specified.' % recipient, + 'Dropped invalid channel/room/user ' + '({}) specified.'.format(recipient), ) - if len(self.rooms) == 0 and len(self.channels) == 0: + if self.mode == RocketChatAuthMode.BASIC and \ + len(self.rooms) == 0 and len(self.channels) == 0: msg = 'No Rocket.Chat room and/or channels specified to notify.' self.logger.warning(msg) raise TypeError(msg) - # Used to track token headers upon authentication (if successful) - self.headers = {} + return def url(self): """ @@ -138,13 +212,22 @@ class NotifyRocketChat(NotifyBase): 'format': self.notify_format, 'overflow': self.overflow_mode, 'verify': 'yes' if self.verify_certificate else 'no', + 'avatar': 'yes' if self.include_avatar else 'no', + 'mode': self.mode, } # Determine Authentication - auth = '{user}:{password}@'.format( - user=NotifyRocketChat.quote(self.user, safe=''), - password=NotifyRocketChat.quote(self.password, safe=''), - ) + if self.mode == RocketChatAuthMode.BASIC: + auth = '{user}:{password}@'.format( + user=NotifyRocketChat.quote(self.user, safe=''), + password=NotifyRocketChat.quote(self.password, safe=''), + ) + else: + auth = '{user}{webhook}@'.format( + user='{}:'.format(NotifyRocketChat.quote(self.user, safe='')) + if self.user else '', + webhook=NotifyRocketChat.quote(self.webhook, safe=''), + ) default_port = 443 if self.secure else 80 @@ -160,6 +243,8 @@ class NotifyRocketChat(NotifyBase): ['#{}'.format(x) for x in self.channels], # Rooms are as is self.rooms, + # Users + ['@{}'.format(x) for x in self.users], )]), args=NotifyRocketChat.urlencode(args), ) @@ -169,44 +254,103 @@ class NotifyRocketChat(NotifyBase): wrapper to _send since we can alert more then one channel """ + # Call the _send_ function applicable to whatever mode we're in + # - calls _send_webhook_notification if the mode variable is set + # - calls _send_basic_notification if the mode variable is not set + return getattr(self, '_send_{}_notification'.format(self.mode))( + body=body, title=title, notify_type=notify_type, **kwargs) + + def _send_webhook_notification(self, body, title='', + notify_type=NotifyType.INFO, **kwargs): + """ + Sends a webhook notification + """ + + # Our payload object + payload = self._payload(body, title, notify_type) + + # Assemble our webhook URL + path = 'hooks/{}'.format(self.webhook) + + # Build our list of channels/rooms/users (if any identified) + targets = ['@{}'.format(u) for u in self.users] + targets.extend(['#{}'.format(c) for c in self.channels]) + targets.extend(['{}'.format(r) for r in self.rooms]) + + if len(targets) == 0: + # We can take an early exit + return self._send( + payload, notify_type=notify_type, path=path, **kwargs) + + # Otherwise we want to iterate over each of the targets + + # Initiaize our error tracking + has_error = False + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + } + + while len(targets): + # Retrieve our target + target = targets.pop(0) + + # Assign our channel/room/user + payload['channel'] = target + + if not self._send( + dumps(payload), notify_type=notify_type, path=path, + headers=headers, **kwargs): + + # toggle flag + has_error = True + + return not has_error + + def _send_basic_notification(self, body, title='', + notify_type=NotifyType.INFO, **kwargs): + """ + Authenticates with the server using a user/pass combo for + notifications. + """ # Track whether we authenticated okay if not self.login(): return False - # Prepare our message using the body only - text = body + # prepare JSON Object + payload = self._payload(body, title, notify_type) # Initiaize our error tracking has_error = False - # Create a copy of our rooms and channels to notify against + # Create a copy of our channels to notify against channels = list(self.channels) - rooms = list(self.rooms) - + _payload = payload.copy() while len(channels) > 0: # Get Channel channel = channels.pop(0) + _payload['channel'] = channel if not self._send( - { - 'text': text, - 'channel': channel, - }, notify_type=notify_type, **kwargs): + _payload, notify_type=notify_type, headers=self.headers, + **kwargs): # toggle flag has_error = True - # Send all our defined room id's + # Create a copy of our room id's to notify against + rooms = list(self.rooms) + _payload = payload.copy() while len(rooms): # Get Room room = rooms.pop(0) + _payload['roomId'] = room if not self._send( - { - 'text': text, - 'roomId': room, - }, notify_type=notify_type, **kwargs): + payload, notify_type=notify_type, headers=self.headers, + **kwargs): # toggle flag has_error = True @@ -216,14 +360,32 @@ class NotifyRocketChat(NotifyBase): return not has_error - def _send(self, payload, notify_type, **kwargs): + def _payload(self, body, title='', notify_type=NotifyType.INFO): + """ + Prepares a payload object + """ + # prepare JSON Object + payload = { + "text": body, + } + + # apply our images if they're set to be displayed + image_url = self.image_url(notify_type) + if self.include_avatar: + payload['avatar'] = image_url + + return payload + + def _send(self, payload, notify_type, path='api/v1/chat.postMessage', + headers=None, **kwargs): """ Perform Notify Rocket.Chat Notification """ + api_url = '{}/{}'.format(self.api_url, path) + self.logger.debug('Rocket.Chat POST URL: %s (cert_verify=%r)' % ( - self.api_url + 'chat.postMessage', self.verify_certificate, - )) + api_url, self.verify_certificate)) self.logger.debug('Rocket.Chat Payload: %s' % str(payload)) # Always call throttle before any remote server i/o is made @@ -231,9 +393,9 @@ class NotifyRocketChat(NotifyBase): try: r = requests.post( - self.api_url + 'chat.postMessage', + api_url, data=payload, - headers=self.headers, + headers=headers, verify=self.verify_certificate, ) if r.status_code != requests.codes.ok: @@ -243,8 +405,9 @@ class NotifyRocketChat(NotifyBase): r.status_code, RC_HTTP_ERROR_MAP) self.logger.warning( - 'Failed to send Rocket.Chat notification: ' + 'Failed to send Rocket.Chat {}:notification: ' '{}{}error={}.'.format( + self.mode, status_str, ', ' if status_str else '', r.status_code)) @@ -255,12 +418,13 @@ class NotifyRocketChat(NotifyBase): return False else: - self.logger.info('Sent Rocket.Chat notification.') + self.logger.info( + 'Sent Rocket.Chat {}:notification.'.format(self.mode)) except requests.RequestException as e: self.logger.warning( 'A Connection error occured sending Rocket.Chat ' - 'notification.') + '{}:notification.'.format(self.mode)) self.logger.debug('Socket Exception: %s' % str(e)) # Return; we're done @@ -273,14 +437,17 @@ class NotifyRocketChat(NotifyBase): login to our server """ + payload = { 'username': self.user, 'password': self.password, } + api_url = '{}/{}'.format(self.api_url, 'api/v1/login') + try: r = requests.post( - self.api_url + 'login', + api_url, data=payload, verify=self.verify_certificate, ) @@ -331,9 +498,12 @@ class NotifyRocketChat(NotifyBase): """ logout of our server """ + + api_url = '{}/{}'.format(self.api_url, 'api/v1/logout') + try: r = requests.post( - self.api_url + 'logout', + api_url, headers=self.headers, verify=self.verify_certificate, ) @@ -377,18 +547,69 @@ class NotifyRocketChat(NotifyBase): us to substantiate this object. """ + + try: + # Attempt to detect the webhook (if specified in the URL) + # If no webhook is specified, then we just pass along as if nothing + # happened. However if we do find a webhook, we want to rebuild our + # URL without it since it conflicts with standard URLs. Support + # %2F since that is a forward slash escaped + + # rocket://webhook@host + # rocket://user:webhook@host + match = re.match( + r'^\s*(?P[^:]+://)((?P[^:]+):)?' + r'(?P[a-z0-9]+(/|%2F)' + r'[a-z0-9]+)\@(?P.+)$', url, re.I) + + except TypeError: + # Not a string + return None + + if match: + # Re-assemble our URL without the webhook + url = '{schema}{user}{url}'.format( + schema=match.group('schema'), + user='{}@'.format(match.group('user')) + if match.group('user') else '', + url=match.group('url'), + ) + results = NotifyBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results + if match: + # store our webhook + results['webhook'] = \ + NotifyRocketChat.unquote(match.group('webhook')) + + # Take on the password too in the event we're in basic mode + # We do not unquote() as this is done at a later state + results['password'] = match.group('webhook') + # Apply our targets results['targets'] = NotifyRocketChat.split_path(results['fullpath']) + # The user may have forced the mode + if 'mode' in results['qsd'] and len(results['qsd']['mode']): + results['mode'] = \ + NotifyRocketChat.unquote(results['qsd']['mode']) + + # avatar icon + results['include_avatar'] = \ + parse_bool(results['qsd'].get('avatar', True)) + # The 'to' makes it easier to use yaml configuration if 'to' in results['qsd'] and len(results['qsd']['to']): results['targets'] += \ NotifyRocketChat.parse_list(results['qsd']['to']) + # The 'webhook' over-ride (if specified) + if 'webhook' in results['qsd'] and len(results['qsd']['webhook']): + results['webhook'] = \ + NotifyRocketChat.unquote(results['qsd']['webhook']) + return results diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index ef1c77a1..93d03a28 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -1423,6 +1423,9 @@ TEST_URLS = ( ('rockets://', { 'instance': None, }), + ('rocket://:@/', { + 'instance': None, + }), # No username or pass ('rocket://localhost', { 'instance': TypeError, @@ -1480,7 +1483,7 @@ TEST_URLS = ( }, }), # Several channels - ('rocket://user:pass@localhost/#channel1/#channel2/', { + ('rocket://user:pass@localhost/#channel1/#channel2/?avatar=No', { 'instance': plugins.NotifyRocketChat, # The response text is expected to be the following on a success 'requests_response_text': { @@ -1504,7 +1507,7 @@ TEST_URLS = ( }, }), # A room and channel - ('rocket://user:pass@localhost/room/#channel', { + ('rocket://user:pass@localhost/room/#channel?mode=basic', { 'instance': plugins.NotifyRocketChat, # The response text is expected to be the following on a success 'requests_response_text': { @@ -1515,8 +1518,19 @@ TEST_URLS = ( }, }, }), - ('rocket://:@/', { - 'instance': None, + # A user/pass where the pass matches a webtoken + # to ensure we get the right mode, we enforce basic mode + # so that web/token gets interpreted as a password + ('rockets://user:pass%2Fwithslash@localhost/#channel/?mode=basic', { + 'instance': plugins.NotifyRocketChat, + # The response text is expected to be the following on a success + 'requests_response_text': { + 'status': 'success', + 'data': { + 'authToken': 'abcd', + 'userId': 'user', + }, + }, }), # A room and channel ('rockets://user:pass@localhost/rooma/#channela', { @@ -1531,9 +1545,36 @@ TEST_URLS = ( # Notifications will fail in this event 'response': False, }), + # A web token + ('rockets://web/token@localhost/@user/#channel/roomid', { + 'instance': plugins.NotifyRocketChat, + }), + ('rockets://user:web/token@localhost/@user/?mode=webhook', { + 'instance': plugins.NotifyRocketChat, + }), + ('rockets://user:web/token@localhost?to=@user2,#channel2', { + 'instance': plugins.NotifyRocketChat, + }), + ('rockets://web/token@localhost/?avatar=No', { + # a simple webhook token with default values + 'instance': plugins.NotifyRocketChat, + }), + ('rockets://localhost/@user/?mode=webhook&webhook=web/token', { + 'instance': plugins.NotifyRocketChat, + }), + ('rockets://user:web/token@localhost/@user/?mode=invalid', { + # invalid mode + 'instance': TypeError, + }), ('rocket://user:pass@localhost:8081/room1/room2', { 'instance': plugins.NotifyRocketChat, - # force a failure + # force a failure using basic mode + 'response': False, + 'requests_response_code': requests.codes.internal_server_error, + }), + ('rockets://user:web/token@localhost?to=@user3,#channel3', { + 'instance': plugins.NotifyRocketChat, + # force a failure using webhook mode 'response': False, 'requests_response_code': requests.codes.internal_server_error, }), @@ -2370,7 +2411,6 @@ def test_rest_plugins(mock_post, mock_get): except AssertionError: # Don't mess with these entries - print('%s AssertionError' % url) raise except Exception as e: @@ -3367,7 +3407,7 @@ def test_notify_rocketchat_plugin(mock_post, mock_get): plugins.NotifyBase.NotifyBase.request_rate_per_sec = 0 # Chat ID - recipients = 'l2g, lead2gold, #channel, #channel2' + recipients = 'AbcD1245, @l2g, @lead2gold, #channel, #channel2' # Authentication user = 'myuser' @@ -3385,7 +3425,18 @@ def test_notify_rocketchat_plugin(mock_post, mock_get): user=user, password=password, targets=recipients) assert isinstance(obj, plugins.NotifyRocketChat) is True assert len(obj.channels) == 2 - assert len(obj.rooms) == 2 + assert len(obj.users) == 2 + assert len(obj.rooms) == 1 + + # No Webhook specified + try: + obj = plugins.NotifyRocketChat(webhook=None, mode='webhook') + # We should have thrown an exception before we get to the next + # assert line: + assert False + except TypeError: + # We're in good shape if we reach here as we got the expected error + assert True # # Logout