# -*- coding: utf-8 -*- # # Slack Notify Wrapper # # Copyright (C) 2017-2018 Chris Caron # # This file is part of apprise. # # This program is free software; you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by # the Free Software Foundation; either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Lesser General Public License for more details. # To use this plugin, you need to first access https://api.slack.com # Specifically https://my.slack.com/services/new/incoming-webhook/ # to create a new incoming webhook for your account. You'll need to # follow the wizard to pre-determine the channel(s) you want your # message to broadcast to, and when you're complete, you will # recieve a URL that looks something like this: # https://hooks.slack.com/services/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ # ^ ^ ^ # | | | # These are important <--------------^---------^---------------^ # # import re import requests from json import dumps from time import time from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP from ..common import NotifyImageSize from ..utils import compat_is_basestring # Token required as part of the API request # /AAAAAAAAA/........./........................ VALIDATE_TOKEN_A = re.compile(r'[A-Z0-9]{9}') # Token required as part of the API request # /........./BBBBBBBBB/........................ VALIDATE_TOKEN_B = re.compile(r'[A-Z0-9]{9}') # Token required as part of the API request # /........./........./CCCCCCCCCCCCCCCCCCCCCCCC VALIDATE_TOKEN_C = re.compile(r'[A-Za-z0-9]{24}') # Default User SLACK_DEFAULT_USER = 'apprise' # Extend HTTP Error Messages SLACK_HTTP_ERROR_MAP = HTTP_ERROR_MAP.copy() SLACK_HTTP_ERROR_MAP.update({ 401: 'Unauthorized - Invalid Token.', }) # Used to break path apart into list of channels CHANNEL_LIST_DELIM = re.compile(r'[ \t\r\n,#\\/]+') # Used to detect a channel IS_CHANNEL_RE = re.compile(r'[+#@]?([A-Z0-9_]{1,32})', re.I) class NotifySlack(NotifyBase): """ A wrapper for Slack Notifications """ # The default descriptive name associated with the Notification service_name = 'Slack' # The services URL service_url = 'https://slack.com/' # The default secure protocol secure_protocol = 'slack' # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_slack' # Slack uses the http protocol with JSON requests notify_url = 'https://hooks.slack.com/services' # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_72 # The maximum allowable characters allowed in the body per message body_maxlen = 1000 def __init__(self, token_a, token_b, token_c, channels, **kwargs): """ Initialize Slack Object """ super(NotifySlack, self).__init__(**kwargs) if not VALIDATE_TOKEN_A.match(token_a.strip()): self.logger.warning( 'The first API Token specified (%s) is invalid.' % token_a, ) raise TypeError( 'The first API Token specified (%s) is invalid.' % token_a, ) # The token associated with the account self.token_a = token_a.strip() if not VALIDATE_TOKEN_B.match(token_b.strip()): self.logger.warning( 'The second API Token specified (%s) is invalid.' % token_b, ) raise TypeError( 'The second API Token specified (%s) is invalid.' % token_b, ) # The token associated with the account self.token_b = token_b.strip() if not VALIDATE_TOKEN_C.match(token_c.strip()): self.logger.warning( 'The third API Token specified (%s) is invalid.' % token_c, ) raise TypeError( 'The third API Token specified (%s) is invalid.' % token_c, ) # The token associated with the account self.token_c = token_c.strip() if not self.user: self.logger.warning( 'No user was specified; using %s.' % SLACK_DEFAULT_USER) self.user = SLACK_DEFAULT_USER if compat_is_basestring(channels): self.channels = [x for x in filter(bool, CHANNEL_LIST_DELIM.split( channels, ))] elif isinstance(channels, (set, tuple, list)): self.channels = channels else: self.channels = list() if len(self.channels) == 0: self.logger.warning('No channel(s) were specified.') raise TypeError('No channel(s) were specified.') # Formatting requirements are defined here: # https://api.slack.com/docs/message-formatting self._re_formatting_map = { # New lines must become the string version r'\r\*\n': '\\n', # Escape other special characters r'&': '&', r'<': '<', r'>': '>', } # Iterate over above list and store content accordingly self._re_formatting_rules = re.compile( r'(' + '|'.join(self._re_formatting_map.keys()) + r')', re.IGNORECASE, ) def notify(self, title, body, notify_type, **kwargs): """ Perform Slack Notification """ headers = { 'User-Agent': self.app_id, 'Content-Type': 'application/json', } # error tracking (used for function return) notify_okay = True # Perform Formatting title = self._re_formatting_rules.sub( # pragma: no branch lambda x: self._re_formatting_map[x.group()], title, ) body = self._re_formatting_rules.sub( # pragma: no branch lambda x: self._re_formatting_map[x.group()], body, ) url = '%s/%s/%s/%s' % ( self.notify_url, self.token_a, self.token_b, self.token_c, ) image_url = self.image_url(notify_type) # Create a copy of the channel list channels = list(self.channels) while len(channels): channel = channels.pop(0) if not IS_CHANNEL_RE.match(channel): self.logger.warning( "The specified channel '%s' is invalid; skipping." % ( channel, ) ) continue if len(channel) > 1 and channel[0] == '+': # Treat as encoded id if prefixed with a + _channel = channel[1:] elif len(channel) > 1 and channel[0] == '@': # Treat @ value 'as is' _channel = channel else: # Prefix with channel hash tag _channel = '#%s' % channel # prepare JSON Object payload = { 'channel': _channel, 'username': self.user, # Use Markdown language 'mrkdwn': True, 'attachments': [{ 'title': title, 'text': body, 'color': self.color(notify_type), # Time 'ts': time(), 'footer': self.app_id, }], } if image_url: payload['attachments'][0]['footer_icon'] = image_url self.logger.debug('Slack POST URL: %s (cert_verify=%r)' % ( url, self.verify_certificate, )) self.logger.debug('Slack Payload: %s' % str(payload)) try: r = requests.post( url, data=dumps(payload), headers=headers, verify=self.verify_certificate, ) if r.status_code != requests.codes.ok: # We had a problem try: self.logger.warning( 'Failed to send Slack:%s ' 'notification: %s (error=%s).' % ( channel, SLACK_HTTP_ERROR_MAP[r.status_code], r.status_code)) except KeyError: self.logger.warning( 'Failed to send Slack:%s ' 'notification (error=%s).' % ( channel, r.status_code)) # self.logger.debug('Response Details: %s' % r.raw.read()) # Return; we're done notify_okay = False else: self.logger.info('Sent Slack notification.') except requests.RequestException as e: self.logger.warning( 'A Connection error occured sending Slack:%s ' % ( channel) + 'notification.' ) self.logger.debug('Socket Exception: %s' % str(e)) notify_okay = False if len(channels): # Prevent thrashing requests self.throttle() return notify_okay @staticmethod def parse_url(url): """ Parses the URL and returns enough arguments that can allow us to substantiate this object. """ results = NotifyBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results # Apply our settings now # The first token is stored in the hostname token_a = results['host'] # Now fetch the remaining tokens try: token_b, token_c = [x for x in filter( bool, NotifyBase.split_path(results['fullpath']))][0:2] except (ValueError, AttributeError, IndexError): # We're done return None channels = [x for x in filter( bool, NotifyBase.split_path(results['fullpath']))][2:] results['token_a'] = token_a results['token_b'] = token_b results['token_c'] = token_c results['channels'] = channels return results