From 5d6f453d3fa5ffe7b9c5d5cf6fc1d2c0dc3d002e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Louis=20V=C3=A9zina?= <5130500+morpheus65535@users.noreply.github.com> Date: Mon, 14 Sep 2020 08:24:45 -0400 Subject: [PATCH] Updated Apprise to 0.8.8 --- libs/apprise/plugins/NotifyGrowl.py | 434 ++++++++++ libs/apprise/plugins/NotifyLametric.py | 869 ++++++++++++++++++++ libs/apprise/plugins/NotifyMacOSX.py | 219 +++++ libs/apprise/plugins/NotifyOffice365.py | 704 ++++++++++++++++ libs/apprise/plugins/NotifyPopcornNotify.py | 304 +++++++ libs/apprise/plugins/NotifySpontit.py | 376 +++++++++ libs/apprise/py3compat/__init__.py | 0 libs/apprise/py3compat/asyncio.py | 115 +++ 8 files changed, 3021 insertions(+) create mode 100644 libs/apprise/plugins/NotifyGrowl.py create mode 100644 libs/apprise/plugins/NotifyLametric.py create mode 100644 libs/apprise/plugins/NotifyMacOSX.py create mode 100644 libs/apprise/plugins/NotifyOffice365.py create mode 100644 libs/apprise/plugins/NotifyPopcornNotify.py create mode 100644 libs/apprise/plugins/NotifySpontit.py create mode 100644 libs/apprise/py3compat/__init__.py create mode 100644 libs/apprise/py3compat/asyncio.py diff --git a/libs/apprise/plugins/NotifyGrowl.py b/libs/apprise/plugins/NotifyGrowl.py new file mode 100644 index 000000000..e9df69dc5 --- /dev/null +++ b/libs/apprise/plugins/NotifyGrowl.py @@ -0,0 +1,434 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2020 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyImageSize +from ..common import NotifyType +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + +# Default our global support flag +NOTIFY_GROWL_SUPPORT_ENABLED = False + +try: + import gntp.notifier + + # We're good to go! + NOTIFY_GROWL_SUPPORT_ENABLED = True + +except ImportError: + # No problem; we just simply can't support this plugin until + # gntp is installed + pass + + +# Priorities +class GrowlPriority(object): + LOW = -2 + MODERATE = -1 + NORMAL = 0 + HIGH = 1 + EMERGENCY = 2 + + +GROWL_PRIORITIES = ( + GrowlPriority.LOW, + GrowlPriority.MODERATE, + GrowlPriority.NORMAL, + GrowlPriority.HIGH, + GrowlPriority.EMERGENCY, +) + + +class NotifyGrowl(NotifyBase): + """ + A wrapper to Growl Notifications + + """ + + # The default descriptive name associated with the Notification + service_name = 'Growl' + + # The services URL + service_url = 'http://growl.info/' + + # The default protocol + protocol = 'growl' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_growl' + + # Allows the user to specify the NotifyImageSize object + image_size = NotifyImageSize.XY_72 + + # This entry is a bit hacky, but it allows us to unit-test this library + # in an environment that simply doesn't have the windows packages + # available to us. It also allows us to handle situations where the + # packages actually are present but we need to test that they aren't. + # If anyone is seeing this had knows a better way of testing this + # outside of what is defined in test/test_growl_plugin.py, please + # let me know! :) + _enabled = NOTIFY_GROWL_SUPPORT_ENABLED + + # Disable throttle rate for Growl requests since they are normally + # local anyway + request_rate_per_sec = 0 + + # Limit results to just the first 10 line otherwise there is just to much + # content to display + body_max_line_count = 2 + + # Default Growl Port + default_port = 23053 + + # The Growl notification type used + growl_notification_type = "New Messages" + + # Define object templates + templates = ( + '{schema}://{host}', + '{schema}://{host}:{port}', + '{schema}://{password}@{host}', + '{schema}://{password}@{host}:{port}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'priority': { + 'name': _('Priority'), + 'type': 'choice:int', + 'values': GROWL_PRIORITIES, + 'default': GrowlPriority.NORMAL, + }, + 'version': { + 'name': _('Version'), + 'type': 'choice:int', + 'values': (1, 2), + 'default': 2, + }, + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': True, + 'map_to': 'include_image', + }, + 'sticky': { + 'name': _('Sticky'), + 'type': 'bool', + 'default': True, + 'map_to': 'sticky', + }, + }) + + def __init__(self, priority=None, version=2, include_image=True, + sticky=False, **kwargs): + """ + Initialize Growl Object + """ + super(NotifyGrowl, self).__init__(**kwargs) + + if not self.port: + self.port = self.default_port + + # The Priority of the message + if priority not in GROWL_PRIORITIES: + self.priority = GrowlPriority.NORMAL + + else: + self.priority = priority + + # Our Registered object + self.growl = None + + # Sticky flag + self.sticky = sticky + + # Store Version + self.version = version + + # Track whether or not we want to send an image with our notification + # or not. + self.include_image = include_image + + return + + def register(self): + """ + Registers with the Growl server + """ + payload = { + 'applicationName': self.app_id, + 'notifications': [self.growl_notification_type, ], + 'defaultNotifications': [self.growl_notification_type, ], + 'hostname': self.host, + 'port': self.port, + } + + if self.password is not None: + payload['password'] = self.password + + self.logger.debug('Growl Registration Payload: %s' % str(payload)) + self.growl = gntp.notifier.GrowlNotifier(**payload) + + try: + self.growl.register() + + except gntp.errors.NetworkError: + msg = 'A network error error occurred registering ' \ + 'with Growl at {}.'.format(self.host) + self.logger.warning(msg) + return False + + except gntp.errors.ParseError: + msg = 'A parsing error error occurred registering ' \ + 'with Growl at {}.'.format(self.host) + self.logger.warning(msg) + return False + + except gntp.errors.AuthError: + msg = 'An authentication error error occurred registering ' \ + 'with Growl at {}.'.format(self.host) + self.logger.warning(msg) + return False + + except gntp.errors.UnsupportedError: + msg = 'An unsupported error occurred registering with ' \ + 'Growl at {}.'.format(self.host) + self.logger.warning(msg) + return False + + self.logger.debug( + 'Growl server registration completed successfully.' + ) + + # Return our state + return True + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Growl Notification + """ + + if not self._enabled: + self.logger.warning( + "Growl Notifications are not supported by this system; " + "`pip install gntp`.") + return False + + # Register ourselves with the server if we haven't done so already + if not self.growl and not self.register(): + # We failed to register + return False + + icon = None + if self.version >= 2: + # URL Based + icon = None if not self.include_image \ + else self.image_url(notify_type) + + else: + # Raw + icon = None if not self.include_image \ + else self.image_raw(notify_type) + + payload = { + 'noteType': self.growl_notification_type, + 'title': title, + 'description': body, + 'icon': icon is not None, + 'sticky': self.sticky, + 'priority': self.priority, + } + self.logger.debug('Growl Payload: %s' % str(payload)) + + # Update icon of payload to be raw data; this is intentionally done + # here after we spit the debug message above (so we don't try to + # print the binary contents of an image + payload['icon'] = icon + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + # Perform notification + response = self.growl.notify(**payload) + if not isinstance(response, bool): + self.logger.warning( + 'Growl notification failed to send with response: %s' % + str(response), + ) + + else: + self.logger.info('Sent Growl notification.') + + except gntp.errors.BaseError as e: + # Since Growl servers listen for UDP broadcasts, it's possible + # that you will never get to this part of the code since there is + # no acknowledgement as to whether it accepted what was sent to it + # or not. + + # However, if the host/server is unavailable, you will get to this + # point of the code. + self.logger.warning( + 'A Connection error occurred sending Growl ' + 'notification to %s.' % self.host) + self.logger.debug('Growl Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + _map = { + GrowlPriority.LOW: 'low', + GrowlPriority.MODERATE: 'moderate', + GrowlPriority.NORMAL: 'normal', + GrowlPriority.HIGH: 'high', + GrowlPriority.EMERGENCY: 'emergency', + } + + # Define any URL parameters + params = { + 'image': 'yes' if self.include_image else 'no', + 'sticky': 'yes' if self.sticky else 'no', + 'priority': + _map[GrowlPriority.NORMAL] if self.priority not in _map + else _map[self.priority], + 'version': self.version, + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + auth = '' + if self.user: + # The growl password is stored in the user field + auth = '{password}@'.format( + password=self.pprint( + self.user, privacy, mode=PrivacyMode.Secret, safe=''), + ) + + return '{schema}://{auth}{hostname}{port}/?{params}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + auth=auth, + # never encode hostname since we're expecting it to be a valid one + hostname=self.host, + port='' if self.port is None or self.port == self.default_port + else ':{}'.format(self.port), + params=NotifyGrowl.urlencode(params), + ) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + results = NotifyBase.parse_url(url) + if not results: + # We're done early as we couldn't load the results + return results + + version = None + if 'version' in results['qsd'] and len(results['qsd']['version']): + # Allow the user to specify the version of the protocol to use. + try: + version = int( + NotifyGrowl.unquote( + results['qsd']['version']).strip().split('.')[0]) + + except (AttributeError, IndexError, TypeError, ValueError): + NotifyGrowl.logger.warning( + 'An invalid Growl version of "%s" was specified and will ' + 'be ignored.' % results['qsd']['version'] + ) + pass + + if 'priority' in results['qsd'] and len(results['qsd']['priority']): + _map = { + 'l': GrowlPriority.LOW, + 'm': GrowlPriority.MODERATE, + 'n': GrowlPriority.NORMAL, + 'h': GrowlPriority.HIGH, + 'e': GrowlPriority.EMERGENCY, + } + try: + results['priority'] = \ + _map[results['qsd']['priority'][0].lower()] + + except KeyError: + # No priority was set + pass + + # Because of the URL formatting, the password is actually where the + # username field is. For this reason, we just preform this small hack + # to make it (the URL) conform correctly. The following strips out the + # existing password entry (if exists) so that it can be swapped with + # the new one we specify. + if results.get('password', None) is None: + results['password'] = results.get('user', None) + + # Include images with our message + results['include_image'] = \ + parse_bool(results['qsd'].get('image', + NotifyGrowl.template_args['image']['default'])) + + # Include images with our message + results['sticky'] = \ + parse_bool(results['qsd'].get('sticky', + NotifyGrowl.template_args['sticky']['default'])) + + # Set our version + if version: + results['version'] = version + + return results diff --git a/libs/apprise/plugins/NotifyLametric.py b/libs/apprise/plugins/NotifyLametric.py new file mode 100644 index 000000000..a8938e651 --- /dev/null +++ b/libs/apprise/plugins/NotifyLametric.py @@ -0,0 +1,869 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2020 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +# For LaMetric to work, you need to first setup a custom application on their +# website. it can be done as follows: + +# Cloud Mode: +# 1. Sign Up and login to the developer webpage https://developer.lametric.com +# 2. Create a **Notification App** if you haven't already done so from: +# https://developer.lametric.com/applications/sources +# 3. Provide it an app name, a description and privacy URL (which can point to +# anywhere; I set mine to `http://localhost`). No permissions are +# required. +# 4. Access your newly created app so that you can acquire both the +# **Client ID** and the **Client Secret** here: +# https://developer.lametric.com/applications/sources + +# Device Mode: +# - Sign Up and login to the developer webpage https://developer.lametric.com +# - Locate your Device API Key; you can find it here: +# https://developer.lametric.com/user/devices +# - From here you can get your your API Key for the device you plan to notify. +# - Your devices IP Address can be found in LaMetric Time app at: +# Settings -> Wi-Fi -> IP Address + +# A great source for API examples (Device Mode): +# - https://lametric-documentation.readthedocs.io/en/latest/reference-docs\ +# /device-notifications.html + +# A great source for API examples (Cloud Mode): +# - https://lametric-documentation.readthedocs.io/en/latest/reference-docs\ +# /lametric-cloud-reference.html + +# A great source for the icon reference: +# - https://developer.lametric.com/icons + +import re +import six +import requests +from json import dumps +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ +from ..utils import is_hostname +from ..utils import is_ipaddr + + +class LametricMode(object): + """ + Define Lametric Notification Modes + """ + # App posts upstream to the developer API on Lametric's website + CLOUD = "cloud" + + # Device mode posts directly to the device that you identify + DEVICE = "device" + + +LAMETRIC_MODES = ( + LametricMode.CLOUD, + LametricMode.DEVICE, +) + + +class LametricPriority(object): + """ + Priority of the message + """ + + # info: this priority means that notification will be displayed on the + # same “level” as all other notifications on the device that come + # from apps (for example facebook app). This notification will not + # be shown when screensaver is active. By default message is sent + # with "info" priority. This level of notification should be used + # for notifications like news, weather, temperature, etc. + INFO = 'info' + + # warning: notifications with this priority will interrupt ones sent with + # lower priority (“info”). Should be used to notify the user + # about something important but not critical. For example, + # events like “someone is coming home” should use this priority + # when sending notifications from smart home. + WARNING = 'warning' + + # critical: the most important notifications. Interrupts notification + # with priority info or warning and is displayed even if + # screensaver is active. Use with care as these notifications + # can pop in the middle of the night. Must be used only for + # really important notifications like notifications from smoke + # detectors, water leak sensors, etc. Use it for events that + # require human interaction immediately. + CRITICAL = 'critical' + + +LAMETRIC_PRIORITIES = ( + LametricPriority.INFO, + LametricPriority.WARNING, + LametricPriority.CRITICAL, +) + + +class LametricIconType(object): + """ + Represents the nature of notification. + """ + + # info - "i" icon will be displayed prior to the notification. Means that + # notification contains information, no need to take actions on it. + INFO = 'info' + + # alert: "!!!" icon will be displayed prior to the notification. Use it + # when you want the user to pay attention to that notification as + # it indicates that something bad happened and user must take + # immediate action. + ALERT = 'alert' + + # none: no notification icon will be shown. + NONE = 'none' + + +LAMETRIC_ICON_TYPES = ( + LametricIconType.INFO, + LametricIconType.ALERT, + LametricIconType.NONE, +) + + +class LametricSoundCategory(object): + """ + Define Sound Categories + """ + NOTIFICATIONS = "notifications" + ALARMS = "alarms" + + +class LametricSound(object): + """ + There are 2 categories of sounds, to make things simple we just lump them + all togther in one class object. + + Syntax is (Category, (AlarmID, Alias1, Alias2, ...)) + """ + + # Alarm Category Sounds + ALARM01 = (LametricSoundCategory.ALARMS, ('alarm1', 'a1', 'a01')) + ALARM02 = (LametricSoundCategory.ALARMS, ('alarm2', 'a2', 'a02')) + ALARM03 = (LametricSoundCategory.ALARMS, ('alarm3', 'a3', 'a03')) + ALARM04 = (LametricSoundCategory.ALARMS, ('alarm4', 'a4', 'a04')) + ALARM05 = (LametricSoundCategory.ALARMS, ('alarm5', 'a5', 'a05')) + ALARM06 = (LametricSoundCategory.ALARMS, ('alarm6', 'a6', 'a06')) + ALARM07 = (LametricSoundCategory.ALARMS, ('alarm7', 'a7', 'a07')) + ALARM08 = (LametricSoundCategory.ALARMS, ('alarm8', 'a8', 'a08')) + ALARM09 = (LametricSoundCategory.ALARMS, ('alarm9', 'a9', 'a09')) + ALARM10 = (LametricSoundCategory.ALARMS, ('alarm10', 'a10')) + ALARM11 = (LametricSoundCategory.ALARMS, ('alarm11', 'a11')) + ALARM12 = (LametricSoundCategory.ALARMS, ('alarm12', 'a12')) + ALARM13 = (LametricSoundCategory.ALARMS, ('alarm13', 'a13')) + + # Notification Category Sounds + BICYCLE = (LametricSoundCategory.NOTIFICATIONS, ('bicycle', 'bike')) + CAR = (LametricSoundCategory.NOTIFICATIONS, ('car', )) + CASH = (LametricSoundCategory.NOTIFICATIONS, ('cash', )) + CAT = (LametricSoundCategory.NOTIFICATIONS, ('cat', )) + DOG01 = (LametricSoundCategory.NOTIFICATIONS, ('dog', 'dog1', 'dog01')) + DOG02 = (LametricSoundCategory.NOTIFICATIONS, ('dog2', 'dog02')) + ENERGY = (LametricSoundCategory.NOTIFICATIONS, ('energy', )) + KNOCK = (LametricSoundCategory.NOTIFICATIONS, ('knock-knock', 'knock')) + EMAIL = (LametricSoundCategory.NOTIFICATIONS, ( + 'letter_email', 'letter', 'email')) + LOSE01 = (LametricSoundCategory.NOTIFICATIONS, ('lose1', 'lose01', 'lose')) + LOSE02 = (LametricSoundCategory.NOTIFICATIONS, ('lose2', 'lose02')) + NEGATIVE01 = (LametricSoundCategory.NOTIFICATIONS, ( + 'negative1', 'negative01', 'neg01', 'neg1', '-')) + NEGATIVE02 = (LametricSoundCategory.NOTIFICATIONS, ( + 'negative2', 'negative02', 'neg02', 'neg2', '--')) + NEGATIVE03 = (LametricSoundCategory.NOTIFICATIONS, ( + 'negative3', 'negative03', 'neg03', 'neg3', '---')) + NEGATIVE04 = (LametricSoundCategory.NOTIFICATIONS, ( + 'negative4', 'negative04', 'neg04', 'neg4', '----')) + NEGATIVE05 = (LametricSoundCategory.NOTIFICATIONS, ( + 'negative5', 'negative05', 'neg05', 'neg5', '-----')) + NOTIFICATION01 = (LametricSoundCategory.NOTIFICATIONS, ( + 'notification', 'notification1', 'notification01', 'not01', 'not1')) + NOTIFICATION02 = (LametricSoundCategory.NOTIFICATIONS, ( + 'notification2', 'notification02', 'not02', 'not2')) + NOTIFICATION03 = (LametricSoundCategory.NOTIFICATIONS, ( + 'notification3', 'notification03', 'not03', 'not3')) + NOTIFICATION04 = (LametricSoundCategory.NOTIFICATIONS, ( + 'notification4', 'notification04', 'not04', 'not4')) + OPEN_DOOR = (LametricSoundCategory.NOTIFICATIONS, ( + 'open_door', 'open', 'door')) + POSITIVE01 = (LametricSoundCategory.NOTIFICATIONS, ( + 'positive1', 'positive01', 'pos01', 'p1', '+')) + POSITIVE02 = (LametricSoundCategory.NOTIFICATIONS, ( + 'positive2', 'positive02', 'pos02', 'p2', '++')) + POSITIVE03 = (LametricSoundCategory.NOTIFICATIONS, ( + 'positive3', 'positive03', 'pos03', 'p3', '+++')) + POSITIVE04 = (LametricSoundCategory.NOTIFICATIONS, ( + 'positive4', 'positive04', 'pos04', 'p4', '++++')) + POSITIVE05 = (LametricSoundCategory.NOTIFICATIONS, ( + 'positive5', 'positive05', 'pos05', 'p5', '+++++')) + POSITIVE06 = (LametricSoundCategory.NOTIFICATIONS, ( + 'positive6', 'positive06', 'pos06', 'p6', '++++++')) + STATISTIC = (LametricSoundCategory.NOTIFICATIONS, ('statistic', 'stat')) + THUNDER = (LametricSoundCategory.NOTIFICATIONS, ('thunder')) + WATER01 = (LametricSoundCategory.NOTIFICATIONS, ('water1', 'water01')) + WATER02 = (LametricSoundCategory.NOTIFICATIONS, ('water2', 'water02')) + WIN01 = (LametricSoundCategory.NOTIFICATIONS, ('win', 'win01', 'win1')) + WIN02 = (LametricSoundCategory.NOTIFICATIONS, ('win2', 'win02')) + WIND = (LametricSoundCategory.NOTIFICATIONS, ('wind', )) + WIND_SHORT = (LametricSoundCategory.NOTIFICATIONS, ('wind_short', )) + + +# A listing of all the sounds; the order DOES matter, content is read from +# top down and then right to left (over aliases). Longer similar sounding +# elements should be placed higher in the list over others. for example +# ALARM10 should come before ALARM01 (because ALARM01 can match on 'alarm1' +# which is very close to 'alarm10' +LAMETRIC_SOUNDS = ( + # Alarm Category Entries + LametricSound.ALARM13, LametricSound.ALARM12, LametricSound.ALARM11, + LametricSound.ALARM10, LametricSound.ALARM09, LametricSound.ALARM08, + LametricSound.ALARM07, LametricSound.ALARM06, LametricSound.ALARM05, + LametricSound.ALARM04, LametricSound.ALARM03, LametricSound.ALARM02, + LametricSound.ALARM01, + + # Notification Category Entries + LametricSound.BICYCLE, LametricSound.CAR, LametricSound.CASH, + LametricSound.CAT, LametricSound.DOG02, LametricSound.DOG01, + LametricSound.ENERGY, LametricSound.KNOCK, LametricSound.EMAIL, + LametricSound.LOSE02, LametricSound.LOSE01, LametricSound.NEGATIVE01, + LametricSound.NEGATIVE02, LametricSound.NEGATIVE03, + LametricSound.NEGATIVE04, LametricSound.NEGATIVE05, + LametricSound.NOTIFICATION04, LametricSound.NOTIFICATION03, + LametricSound.NOTIFICATION02, LametricSound.NOTIFICATION01, + LametricSound.OPEN_DOOR, LametricSound.POSITIVE01, + LametricSound.POSITIVE02, LametricSound.POSITIVE03, + LametricSound.POSITIVE04, LametricSound.POSITIVE05, + LametricSound.POSITIVE01, LametricSound.STATISTIC, LametricSound.THUNDER, + LametricSound.WATER02, LametricSound.WATER01, LametricSound.WIND, + LametricSound.WIND_SHORT, LametricSound.WIN01, LametricSound.WIN02, +) + + +class NotifyLametric(NotifyBase): + """ + A wrapper for LaMetric Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'LaMetric' + + # The services URL + service_url = 'https://lametric.com' + + # The default protocol + protocol = 'lametric' + + # The default secure protocol + secure_protocol = 'lametrics' + + # Allow 300 requests per minute. + # 60/300 = 0.2 + request_rate_per_sec = 0.20 + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_lametric' + + # Lametric does have titles when creating a message + title_maxlen = 0 + + # URL used for notifying Lametric App's created in the Dev Portal + cloud_notify_url = 'https://developer.lametric.com/api/v1' \ + '/dev/widget/update/com.lametric.{client_id}' + + # URL used for local notifications directly to the device + device_notify_url = '{schema}://{host}{port}/api/v2/device/notifications' + + # The Device User ID + default_device_user = 'dev' + + # Track all icon mappings back to Apprise Icon NotifyType's + # See: https://developer.lametric.com/icons + # Icon ID looks like XXX, where is: + # - "i" (for static icon) + # - "a" (for animation) + # - XXX - is the number of the icon and can be found at: + # https://developer.lametric.com/icons + lametric_icon_id_mapping = { + # 620/Info + NotifyType.INFO: 'i620', + # 9182/info_good + NotifyType.SUCCESS: 'i9182', + # 9183/info_caution + NotifyType.WARNING: 'i9183', + # 9184/info_error + NotifyType.FAILURE: 'i9184', + } + + # Define object templates + templates = ( + # App Mode + '{schema}://{client_id}@{secret}', + + # Device Mode + '{schema}://{apikey}@{host}', + '{schema}://{apikey}@{host}:{port}', + '{schema}://{user}:{apikey}@{host}:{port}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('Device API Key'), + 'type': 'string', + 'private': True, + }, + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + 'default': 8080, + }, + 'user': { + 'name': _('Username'), + 'type': 'string', + }, + 'client_id': { + 'name': _('Client ID'), + 'type': 'string', + 'private': True, + 'regex': (r'^[a-z0-9-]+$', 'i'), + }, + 'secret': { + 'name': _('Client Secret'), + 'type': 'string', + 'private': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'oauth_id': { + 'alias_of': 'client_id', + }, + 'oauth_secret': { + 'alias_of': 'secret', + }, + 'apikey': { + 'alias_of': 'apikey', + }, + 'priority': { + 'name': _('Priority'), + 'type': 'choice:string', + 'values': LAMETRIC_PRIORITIES, + 'default': LametricPriority.INFO, + }, + 'icon': { + 'name': _('Custom Icon'), + 'type': 'string', + }, + 'icon_type': { + 'name': _('Icon Type'), + 'type': 'choice:string', + 'values': LAMETRIC_ICON_TYPES, + 'default': LametricIconType.NONE, + }, + 'mode': { + 'name': _('Mode'), + 'type': 'choice:string', + 'values': LAMETRIC_MODES, + 'default': LametricMode.DEVICE, + }, + 'sound': { + 'name': _('Sound'), + 'type': 'string', + }, + # Lifetime is in seconds + 'cycles': { + 'name': _('Cycles'), + 'type': 'int', + 'min': 0, + 'default': 1, + }, + }) + + def __init__(self, apikey=None, client_id=None, secret=None, priority=None, + icon=None, icon_type=None, sound=None, mode=None, + cycles=None, **kwargs): + """ + Initialize LaMetric Object + """ + super(NotifyLametric, self).__init__(**kwargs) + + self.mode = mode.strip().lower() \ + if isinstance(mode, six.string_types) \ + else self.template_args['mode']['default'] + + if self.mode not in LAMETRIC_MODES: + msg = 'An invalid LaMetric Mode ({}) was specified.'.format(mode) + self.logger.warning(msg) + raise TypeError(msg) + + # Default Cloud Arguments + self.secret = None + self.client_id = None + + # Default Device Arguments + self.apikey = None + + if self.mode == LametricMode.CLOUD: + # Client ID + self.client_id = validate_regex( + client_id, *self.template_tokens['client_id']['regex']) + if not self.client_id: + msg = 'An invalid LaMetric Client OAuth2 ID ' \ + '({}) was specified.'.format(client_id) + self.logger.warning(msg) + raise TypeError(msg) + + # Client Secret + self.secret = validate_regex(secret) + if not self.secret: + msg = 'An invalid LaMetric Client OAuth2 Secret ' \ + '({}) was specified.'.format(secret) + self.logger.warning(msg) + raise TypeError(msg) + + else: # LametricMode.DEVICE + + # API Key + self.apikey = validate_regex(apikey) + if not self.apikey: + msg = 'An invalid LaMetric Device API Key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + if priority not in LAMETRIC_PRIORITIES: + self.priority = self.template_args['priority']['default'] + + else: + self.priority = priority + + # assign our icon (if it was defined); we also eliminate + # any hashtag (#) entries that might be present + self.icon = re.search(r'[#\s]*(?P.+?)\s*$', icon) \ + .group('value') if isinstance(icon, six.string_types) else None + + if icon_type not in LAMETRIC_ICON_TYPES: + self.icon_type = self.template_args['icon_type']['default'] + + else: + self.icon_type = icon_type + + # The number of times the message should be displayed + self.cycles = self.template_args['cycles']['default'] \ + if not (isinstance(cycles, int) and + cycles > self.template_args['cycles']['min']) else cycles + + self.sound = None + if isinstance(sound, six.string_types): + # If sound is set, get it's match + self.sound = self.sound_lookup(sound.strip().lower()) + if self.sound is None: + self.logger.warning( + 'An invalid LaMetric sound ({}) was specified.'.format( + sound)) + return + + @staticmethod + def sound_lookup(lookup): + """ + A simple match function that takes string and returns the + LametricSound object it was found in. + + """ + + for x in LAMETRIC_SOUNDS: + match = next((f for f in x[1] if f.startswith(lookup)), None) + if match: + # We're done + return x + + # No match was found + return None + + def _cloud_notification_payload(self, body, notify_type, headers): + """ + Return URL and payload for cloud directed requests + """ + + # Update header entries + headers.update({ + 'X-Access-Token': self.secret, + 'Cache-Control': 'no-cache', + }) + + if self.sound: + self.logger.warning( + 'LaMetric sound setting is unavailable in Cloud mode') + + if self.priority != self.template_args['priority']['default']: + self.logger.warning( + 'LaMetric priority setting is unavailable in Cloud mode') + + if self.icon_type != self.template_args['icon_type']['default']: + self.logger.warning( + 'LaMetric icon_type setting is unavailable in Cloud mode') + + if self.cycles != self.template_args['cycles']['default']: + self.logger.warning( + 'LaMetric cycle settings is unavailable in Cloud mode') + + # Assign our icon if the user specified a custom one, otherwise + # choose from our pre-set list (based on notify_type) + icon = self.icon if self.icon \ + else self.lametric_icon_id_mapping[notify_type] + + # Our Payload + # Cloud Notifications don't have as much functionality + # You can not set priority and/or sound + payload = { + "frames": [ + { + "icon": icon, + "text": body, + } + ] + } + + # Prepare our Cloud Notify URL + notify_url = self.cloud_notify_url.format(client_id=self.client_id) + + # Return request parameters + return (notify_url, None, payload) + + def _device_notification_payload(self, body, notify_type, headers): + """ + Return URL and Payload for Device directed requests + """ + + # Assign our icon if the user specified a custom one, otherwise + # choose from our pre-set list (based on notify_type) + icon = self.icon if self.icon \ + else self.lametric_icon_id_mapping[notify_type] + + # Our Payload + payload = { + # Priority of the message + "priority": self.priority, + + # Icon Type: Represents the nature of notification + "icon_type": self.icon_type, + + # The time notification lives in queue to be displayed in + # milliseconds (ms). The default lifetime is 2 minutes (120000ms). + # If notification stayed in queue for longer than lifetime + # milliseconds - it will not be displayed. + "lifetime": 120000, + + "model": { + # cycles - the number of times message should be displayed. If + # cycles is set to 0, notification will stay on the screen + # until user dismisses it manually. By default it is set to 1. + "cycles": self.cycles, + "frames": [ + { + "icon": icon, + "text": body, + } + ] + } + } + + if self.sound: + # Sound was set, so add it to the payload + payload["model"]["sound"] = { + # The sound category + "category": self.sound[0], + + # The first element of our tuple is always the id + "id": self.sound[1][0], + + # repeat - defines the number of times sound must be played. + # If set to 0 sound will be played until notification is + # dismissed. By default the value is set to 1. + "repeat": 1, + } + + if not self.user: + # Use default user if there wasn't one otherwise specified + self.user = self.default_device_user + + # Prepare our authentication + auth = (self.user, self.password) + + # Prepare our Direct Access Notify URL + notify_url = self.device_notify_url.format( + schema="https" if self.secure else "http", + host=self.host, + port=':{}'.format( + self.port if self.port + else self.template_tokens['port']['default'])) + + # Return request parameters + return (notify_url, auth, payload) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform LaMetric Notification + """ + + # Prepare our headers: + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + } + + # Depending on the mode, the payload is gathered by + # - _device_notification_payload() + # - _cloud_notification_payload() + (notify_url, auth, payload) = getattr( + self, '_{}_notification_payload'.format(self.mode))( + body=body, notify_type=notify_type, headers=headers) + + self.logger.debug('LaMetric POST URL: %s (cert_verify=%r)' % ( + notify_url, self.verify_certificate, + )) + self.logger.debug('LaMetric Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + notify_url, + data=dumps(payload), + headers=headers, + auth=auth, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + # An ideal response would be: + # { + # "success": { + # "id": "" + # } + # } + + if r.status_code not in ( + requests.codes.created, requests.codes.ok): + # We had a problem + status_str = \ + NotifyLametric.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send LaMetric notification: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info('Sent LaMetric notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending LaMetric ' + 'notification to %s.' % self.host) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'mode': self.mode, + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + if self.icon: + # Assign our icon IF one was specified + params['icon'] = self.icon + + if self.mode == LametricMode.CLOUD: + # Upstream/LaMetric App Return + return '{schema}://{client_id}@{secret}/?{params}'.format( + schema=self.protocol, + client_id=self.pprint(self.client_id, privacy, safe=''), + secret=self.pprint( + self.secret, privacy, mode=PrivacyMode.Secret, safe=''), + params=NotifyLametric.urlencode(params)) + + # + # If we reach here then we're dealing with LametricMode.DEVICE + # + if self.priority != self.template_args['priority']['default']: + params['priority'] = self.priority + + if self.icon_type != self.template_args['icon_type']['default']: + params['icon_type'] = self.icon_type + + if self.cycles != self.template_args['cycles']['default']: + params['cycles'] = self.cycles + + if self.sound: + # Store our sound entry + # The first element of our tuple is always the id + params['sound'] = self.sound[1][0] + + auth = '' + if self.user and self.password: + auth = '{user}:{apikey}@'.format( + user=NotifyLametric.quote(self.user, safe=''), + apikey=self.pprint(self.apikey, privacy, safe=''), + ) + else: # self.apikey is set + auth = '{apikey}@'.format( + apikey=self.pprint(self.apikey, privacy, safe=''), + ) + + # Local Return + return '{schema}://{auth}{hostname}{port}/?{params}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + auth=auth, + # never encode hostname since we're expecting it to be a valid one + hostname=self.host, + port='' if self.port is None + or self.port == self.template_tokens['port']['default'] + else ':{}'.format(self.port), + params=NotifyLametric.urlencode(params), + ) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + if results.get('user') and not results.get('password'): + # Handle URL like: + # schema://user@host + + # This becomes the password + results['password'] = results['user'] + results['user'] = None + + # Priority Handling + if 'priority' in results['qsd'] and len(results['qsd']['priority']): + results['priority'] = results['qsd']['priority'].strip().lower() + + # Icon Type + if 'icon' in results['qsd'] and len(results['qsd']['icon']): + results['icon'] = results['qsd']['icon'].strip().lower() + + # Icon Type + if 'icon_type' in results['qsd'] and len(results['qsd']['icon_type']): + results['icon_type'] = results['qsd']['icon_type'].strip().lower() + + # Sound + if 'sound' in results['qsd'] and len(results['qsd']['sound']): + results['sound'] = results['qsd']['sound'].strip().lower() + + # We can detect the mode based on the validity of the hostname + results['mode'] = LametricMode.DEVICE \ + if (is_hostname(results['host']) or + is_ipaddr(results['host'])) else LametricMode.CLOUD + + # Mode override + if 'mode' in results['qsd'] and len(results['qsd']['mode']): + results['mode'] = NotifyLametric.unquote(results['qsd']['mode']) + + # API Key (Device Mode) + if results['mode'] == LametricMode.DEVICE: + if 'apikey' in results['qsd'] and len(results['qsd']['apikey']): + # Extract API Key from an argument + results['apikey'] = \ + NotifyLametric.unquote(results['qsd']['apikey']) + + else: + results['apikey'] = \ + NotifyLametric.unquote(results['password']) + + elif results['mode'] == LametricMode.CLOUD: + # OAuth2 ID (Cloud Mode) + if 'oauth_id' in results['qsd'] \ + and len(results['qsd']['oauth_id']): + + # Extract the OAuth2 Key from an argument + results['client_id'] = \ + NotifyLametric.unquote(results['qsd']['oauth_id']) + + else: + results['client_id'] = \ + NotifyLametric.unquote(results['password']) + + # OAuth2 Secret (Cloud Mode) + if 'oauth_secret' in results['qsd'] and \ + len(results['qsd']['oauth_secret']): + # Extract the API Secret from an argument + results['secret'] = \ + NotifyLametric.unquote(results['qsd']['oauth_secret']) + + else: + results['secret'] = \ + NotifyLametric.unquote(results['host']) + + # Set cycles + try: + results['cycles'] = abs(int(results['qsd'].get('cycles'))) + + except (TypeError, ValueError): + # Not a valid integer; ignore entry + pass + + return results diff --git a/libs/apprise/plugins/NotifyMacOSX.py b/libs/apprise/plugins/NotifyMacOSX.py new file mode 100644 index 000000000..d1160c37e --- /dev/null +++ b/libs/apprise/plugins/NotifyMacOSX.py @@ -0,0 +1,219 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2020 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from __future__ import absolute_import +from __future__ import print_function + +import platform +import subprocess +import os + +from .NotifyBase import NotifyBase +from ..common import NotifyImageSize +from ..common import NotifyType +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + + +class NotifyMacOSX(NotifyBase): + """ + A wrapper for the MacOS X terminal-notifier tool + + Source: https://github.com/julienXX/terminal-notifier + """ + + # The default descriptive name associated with the Notification + service_name = 'MacOSX Notification' + + # The default protocol + protocol = 'macosx' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_macosx' + + # Allows the user to specify the NotifyImageSize object + image_size = NotifyImageSize.XY_128 + + # Disable throttle rate for MacOSX requests since they are normally + # local anyway + request_rate_per_sec = 0 + + # Limit results to just the first 10 line otherwise there is just to much + # content to display + body_max_line_count = 10 + + # The path to the terminal-notifier + notify_path = '/usr/local/bin/terminal-notifier' + + # Define object templates + templates = ( + '{schema}://', + ) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': True, + 'map_to': 'include_image', + }, + # Play the NAME sound when the notification appears. + # Sound names are listed in Sound Preferences. + # Use 'default' for the default sound. + 'sound': { + 'name': _('Sound'), + 'type': 'string', + }, + }) + + def __init__(self, sound=None, include_image=True, **kwargs): + """ + Initialize MacOSX Object + """ + + super(NotifyMacOSX, self).__init__(**kwargs) + + # Track whether or not we want to send an image with our notification + # or not. + self.include_image = include_image + + self._enabled = False + if platform.system() == 'Darwin': + # Check this is Mac OS X 10.8, or higher + major, minor = platform.mac_ver()[0].split('.')[:2] + + # Toggle our _enabled flag if verion is correct and executable + # found. This is done in such a way to provide verbosity to the + # end user so they know why it may or may not work for them. + if not (int(major) > 10 or (int(major) == 10 and int(minor) >= 8)): + self.logger.warning( + "MacOSX Notifications require your OS to be at least " + "v10.8 (detected {}.{})".format(major, minor)) + + elif not os.access(self.notify_path, os.X_OK): + self.logger.warning( + "MacOSX Notifications require '{}' to be in place." + .format(self.notify_path)) + + else: + # We're good to go + self._enabled = True + + # Set sound object (no q/a for now) + self.sound = sound + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform MacOSX Notification + """ + + if not self._enabled: + self.logger.warning( + "MacOSX Notifications are not supported by this system.") + return False + + # Start with our notification path + cmd = [ + self.notify_path, + '-message', body, + ] + + # Title is an optional switch + if title: + cmd.extend(['-title', title]) + + # The sound to play + if self.sound: + cmd.extend(['-sound', self.sound]) + + # Support any defined images if set + image_path = None if not self.include_image \ + else self.image_url(notify_type) + if image_path: + cmd.extend(['-appIcon', image_path]) + + # Always call throttle before any remote server i/o is made + self.throttle() + + # Send our notification + output = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + + # Wait for process to complete + output.wait() + + if output.returncode: + self.logger.warning('Failed to send MacOSX notification.') + self.logger.exception('MacOSX Exception') + return False + + self.logger.info('Sent MacOSX notification.') + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parametrs + params = { + 'image': 'yes' if self.include_image else 'no', + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + if self.sound: + # Store our sound + params['sound'] = self.sound + + return '{schema}://_/?{params}'.format( + schema=self.protocol, + params=NotifyMacOSX.urlencode(params), + ) + + @staticmethod + def parse_url(url): + """ + There are no parameters nessisary for this protocol; simply having + gnome:// is all you need. This function just makes sure that + is in place. + + """ + + results = NotifyBase.parse_url(url, verify_host=False) + + # Include images with our message + results['include_image'] = \ + parse_bool(results['qsd'].get('image', True)) + + # Support 'sound' + if 'sound' in results['qsd'] and len(results['qsd']['sound']): + results['sound'] = NotifyMacOSX.unquote(results['qsd']['sound']) + + return results diff --git a/libs/apprise/plugins/NotifyOffice365.py b/libs/apprise/plugins/NotifyOffice365.py new file mode 100644 index 000000000..5c8ea934e --- /dev/null +++ b/libs/apprise/plugins/NotifyOffice365.py @@ -0,0 +1,704 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2020 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +# API Details: +# https://docs.microsoft.com/en-us/previous-versions/office/\ +# office-365-api/?redirectedfrom=MSDN + +# Information on sending an email: +# https://docs.microsoft.com/en-us/graph/api/user-sendmail\ +# ?view=graph-rest-1.0&tabs=http + +# Steps to get your Microsoft Client ID, Client Secret, and Tenant ID: +# 1. You should have valid Microsoft personal account. Go to Azure Portal +# 2. Go to -> Microsoft Active Directory --> App Registrations +# 3. Click new -> give any name (your choice) in Name field -> select +# personal Microsoft accounts only --> Register +# 4. Now you have your client_id & Tenant id. +# 5. To create client_secret , go to active directory -> +# Certificate & Tokens -> New client secret +# **This is auto-generated string which may have '@' and '?' +# characters in it. You should encode these to prevent +# from having any issues.** +# 6. Now need to set permission Active directory -> API permissions -> +# Add permission (search mail) , add relevant permission. +# 7. Set the redirect uri (Web) to: +# https://login.microsoftonline.com/common/oauth2/nativeclient +# +# ...and click register. +# +# This needs to be inserted into the "Redirect URI" text box as simply +# checking the check box next to this link seems to be insufficient. +# This is the default redirect uri used by this library, but you can use +# any other if you want. +# +# 8. Now you're good to go + +import requests +from datetime import datetime +from datetime import timedelta +from json import loads +from json import dumps +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyFormat +from ..common import NotifyType +from ..utils import is_email +from ..utils import parse_emails +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +class NotifyOffice365(NotifyBase): + """ + A wrapper for Office 365 Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Office 365' + + # The services URL + service_url = 'https://office.com/' + + # The default protocol + secure_protocol = 'o365' + + # Allow 300 requests per minute. + # 60/300 = 0.2 + request_rate_per_sec = 0.20 + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_office365' + + # URL to Microsoft Graph Server + graph_url = 'https://graph.microsoft.com' + + # Authentication URL + auth_url = 'https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token' + + # Use all the direct application permissions you have configured for your + # app. The endpoint should issue a token for the ones associated with the + # resource you want to use. + # see https://docs.microsoft.com/en-us/azure/active-directory/develop/\ + # v2-permissions-and-consent#the-default-scope + scope = '.default' + + # Default Notify Format + notify_format = NotifyFormat.HTML + + # Define object templates + templates = ( + '{schema}://{tenant}:{email}/{client_id}/{secret}', + '{schema}://{tenant}:{email}/{client_id}/{secret}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'tenant': { + 'name': _('Tenant Domain'), + 'type': 'string', + 'required': True, + 'private': True, + 'regex': (r'^[a-z0-9-]+$', 'i'), + }, + 'email': { + 'name': _('Account Email'), + 'type': 'string', + 'required': True, + }, + 'client_id': { + 'name': _('Client ID'), + 'type': 'string', + 'required': True, + 'private': True, + 'regex': (r'^[a-z0-9-]+$', 'i'), + }, + 'secret': { + 'name': _('Client Secret'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'targets': { + 'name': _('Target Emails'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'cc': { + 'name': _('Carbon Copy'), + 'type': 'list:string', + }, + 'bcc': { + 'name': _('Blind Carbon Copy'), + 'type': 'list:string', + }, + 'oauth_id': { + 'alias_of': 'client_id', + }, + 'oauth_secret': { + 'alias_of': 'secret', + }, + }) + + def __init__(self, tenant, email, client_id, secret, + targets=None, cc=None, bcc=None, **kwargs): + """ + Initialize Office 365 Object + """ + super(NotifyOffice365, self).__init__(**kwargs) + + # Tenant identifier + self.tenant = validate_regex( + tenant, *self.template_tokens['tenant']['regex']) + if not self.tenant: + msg = 'An invalid Office 365 Tenant' \ + '({}) was specified.'.format(tenant) + self.logger.warning(msg) + raise TypeError(msg) + + result = is_email(email) + if not result: + msg = 'An invalid Office 365 Email Account ID' \ + '({}) was specified.'.format(email) + self.logger.warning(msg) + raise TypeError(msg) + + # Otherwise store our the email address + self.email = result['full_email'] + + # Client Key (associated with generated OAuth2 Login) + self.client_id = validate_regex( + client_id, *self.template_tokens['client_id']['regex']) + if not self.client_id: + msg = 'An invalid Office 365 Client OAuth2 ID ' \ + '({}) was specified.'.format(client_id) + self.logger.warning(msg) + raise TypeError(msg) + + # Client Secret (associated with generated OAuth2 Login) + self.secret = validate_regex(secret) + if not self.secret: + msg = 'An invalid Office 365 Client OAuth2 Secret ' \ + '({}) was specified.'.format(secret) + self.logger.warning(msg) + raise TypeError(msg) + + # For tracking our email -> name lookups + self.names = {} + + # Acquire Carbon Copies + self.cc = set() + + # Acquire Blind Carbon Copies + self.bcc = set() + + # Parse our targets + self.targets = list() + + if targets: + for recipient in parse_emails(targets): + # Validate recipients (to:) and drop bad ones: + result = is_email(recipient) + if result: + # Add our email to our target list + self.targets.append( + (result['name'] if result['name'] else False, + result['full_email'])) + continue + + self.logger.warning( + 'Dropped invalid To email ({}) specified.' + .format(recipient)) + + else: + # If our target email list is empty we want to add ourselves to it + self.targets.append((False, self.email)) + + # Validate recipients (cc:) and drop bad ones: + for recipient in parse_emails(cc): + email = is_email(recipient) + if email: + self.cc.add(email['full_email']) + + # Index our name (if one exists) + self.names[email['full_email']] = \ + email['name'] if email['name'] else False + continue + + self.logger.warning( + 'Dropped invalid Carbon Copy email ' + '({}) specified.'.format(recipient), + ) + + # Validate recipients (bcc:) and drop bad ones: + for recipient in parse_emails(bcc): + email = is_email(recipient) + if email: + self.bcc.add(email['full_email']) + + # Index our name (if one exists) + self.names[email['full_email']] = \ + email['name'] if email['name'] else False + continue + + self.logger.warning( + 'Dropped invalid Blind Carbon Copy email ' + '({}) specified.'.format(recipient), + ) + + # Our token is acquired upon a successful login + self.token = None + + # Presume that our token has expired 'now' + self.token_expiry = datetime.now() + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Office 365 Notification + """ + + # error tracking (used for function return) + has_error = False + + if not self.targets: + # There is no one to email; we're done + self.logger.warning( + 'There are no Email recipients to notify') + return False + + # Setup our Content Type + content_type = \ + 'HTML' if self.notify_format == NotifyFormat.HTML else 'Text' + + # Prepare our payload + payload = { + 'Message': { + 'Subject': title, + 'Body': { + 'ContentType': content_type, + 'Content': body, + }, + }, + 'SaveToSentItems': 'false' + } + + # Create a copy of the email list + emails = list(self.targets) + + # Define our URL to post to + url = '{graph_url}/v1.0/users/{email}/sendmail'.format( + email=self.email, + graph_url=self.graph_url, + ) + + while len(emails): + # authenticate ourselves if we aren't already; but this function + # also tracks if our token we have is still valid and will + # re-authenticate ourselves if nessisary. + if not self.authenticate(): + # We could not authenticate ourselves; we're done + return False + + # Get our email to notify + to_name, to_addr = emails.pop(0) + + # Strip target out of cc list if in To or Bcc + cc = (self.cc - self.bcc - set([to_addr])) + + # Strip target out of bcc list if in To + bcc = (self.bcc - set([to_addr])) + + # Prepare our email + payload['Message']['ToRecipients'] = [{ + 'EmailAddress': { + 'Address': to_addr + } + }] + if to_name: + # Apply our To Name + payload['Message']['ToRecipients'][0]['EmailAddress']['Name'] \ + = to_name + + self.logger.debug('Email To: {}'.format(to_addr)) + + if cc: + # Prepare our CC list + payload['Message']['CcRecipients'] = [] + for addr in cc: + _payload = {'Address': addr} + if self.names.get(addr): + _payload['Name'] = self.names[addr] + + # Store our address in our payload + payload['Message']['CcRecipients']\ + .append({'EmailAddress': _payload}) + + self.logger.debug('Email Cc: {}'.format(', '.join( + ['{}{}'.format( + '' if self.names.get(e) + else '{}: '.format(self.names[e]), e) for e in cc]))) + + if bcc: + # Prepare our CC list + payload['Message']['BccRecipients'] = [] + for addr in bcc: + _payload = {'Address': addr} + if self.names.get(addr): + _payload['Name'] = self.names[addr] + + # Store our address in our payload + payload['Message']['BccRecipients']\ + .append({'EmailAddress': _payload}) + + self.logger.debug('Email Bcc: {}'.format(', '.join( + ['{}{}'.format( + '' if self.names.get(e) + else '{}: '.format(self.names[e]), e) for e in bcc]))) + + # Perform upstream fetch + postokay, response = self._fetch( + url=url, payload=dumps(payload), + content_type='application/json') + + # Test if we were okay + if not postokay: + has_error = True + + return not has_error + + def authenticate(self): + """ + Logs into and acquires us an authentication token to work with + """ + + if self.token and self.token_expiry > datetime.now(): + # If we're already authenticated and our token is still valid + self.logger.debug( + 'Already authenticate with token {}'.format(self.token)) + return True + + # If we reach here, we've either expired, or we need to authenticate + # for the first time. + + # Prepare our payload + payload = { + 'client_id': self.client_id, + 'client_secret': self.secret, + 'scope': '{graph_url}/{scope}'.format( + graph_url=self.graph_url, + scope=self.scope), + 'grant_type': 'client_credentials', + } + + # Prepare our URL + url = self.auth_url.format(tenant=self.tenant) + + # A response looks like the following: + # { + # "token_type": "Bearer", + # "expires_in": 3599, + # "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSzI1NiIsInNBXBP..." + # } + # + # Where expires_in defines the number of seconds the key is valid for + # before it must be renewed. + + # Alternatively, this could happen too... + # { + # "error": "invalid_scope", + # "error_description": "AADSTS70011: Blah... Blah Blah... Blah", + # "error_codes": [ + # 70011 + # ], + # "timestamp": "2020-01-09 02:02:12Z", + # "trace_id": "255d1aef-8c98-452f-ac51-23d051240864", + # "correlation_id": "fb3d2015-bc17-4bb9-bb85-30c5cf1aaaa7" + # } + + postokay, response = self._fetch(url=url, payload=payload) + if not postokay: + return False + + # Reset our token + self.token = None + + try: + # Extract our time from our response and subtrace 10 seconds from + # it to give us some wiggle/grace people to re-authenticate if we + # need to + self.token_expiry = datetime.now() + \ + timedelta(seconds=int(response.get('expires_in')) - 10) + + except (ValueError, AttributeError, TypeError): + # ValueError: expires_in wasn't an integer + # TypeError: expires_in was None + # AttributeError: we could not extract anything from our response + # object. + return False + + # Go ahead and store our token if it's available + self.token = response.get('access_token') + + # We're authenticated + return True if self.token else False + + def _fetch(self, url, payload, + content_type='application/x-www-form-urlencoded'): + """ + Wrapper to request object + + """ + + # Prepare our headers: + headers = { + 'User-Agent': self.app_id, + 'Content-Type': content_type, + } + + if self.token: + # Are we authenticated? + headers['Authorization'] = 'Bearer ' + self.token + + # Default content response object + content = {} + + # Some Debug Logging + self.logger.debug('Office 365 POST URL: {} (cert_verify={})'.format( + url, self.verify_certificate)) + self.logger.debug('Office 365 Payload: {}' .format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + # fetch function + try: + r = requests.post( + url, + data=payload, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + if r.status_code not in ( + requests.codes.ok, requests.codes.accepted): + + # We had a problem + status_str = \ + NotifyOffice365.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send Office 365 POST to {}: ' + '{}error={}.'.format( + url, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + return (False, content) + + try: + content = loads(r.content) + + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + content = {} + + except requests.RequestException as e: + self.logger.warning( + 'Exception received when sending Office 365 POST to {}: '. + format(url)) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + return (False, content) + + return (True, content) + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Our URL parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + if self.cc: + # Handle our Carbon Copy Addresses + params['cc'] = ','.join( + ['{}{}'.format( + '' if not self.names.get(e) + else '{}:'.format(self.names[e]), e) for e in self.cc]) + + if self.bcc: + # Handle our Blind Carbon Copy Addresses + params['bcc'] = ','.join( + ['{}{}'.format( + '' if not self.names.get(e) + else '{}:'.format(self.names[e]), e) for e in self.bcc]) + + return '{schema}://{tenant}:{email}/{client_id}/{secret}' \ + '/{targets}/?{params}'.format( + schema=self.secure_protocol, + tenant=self.pprint(self.tenant, privacy, safe=''), + # email does not need to be escaped because it should + # already be a valid host and username at this point + email=self.email, + client_id=self.pprint(self.client_id, privacy, safe=''), + secret=self.pprint( + self.secret, privacy, mode=PrivacyMode.Secret, + safe=''), + targets='/'.join( + [NotifyOffice365.quote('{}{}'.format( + '' if not e[0] else '{}:'.format(e[0]), e[1]), + safe='') for e in self.targets]), + params=NotifyOffice365.urlencode(params)) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + # Now make a list of all our path entries + # We need to read each entry back one at a time in reverse order + # where each email found we mark as a target. Once we run out + # of targets, the presume the remainder of the entries are part + # of the secret key (since it can contain slashes in it) + entries = NotifyOffice365.split_path(results['fullpath']) + + try: + # Get our client_id is the first entry on the path + results['client_id'] = NotifyOffice365.unquote(entries.pop(0)) + + except IndexError: + # no problem, we may get the client_id another way through + # arguments... + pass + + # Prepare our target listing + results['targets'] = list() + while entries: + # Pop the last entry + entry = NotifyOffice365.unquote(entries.pop(-1)) + + if is_email(entry): + # Store our email and move on + results['targets'].append(entry) + continue + + # If we reach here, the entry we just popped is part of the secret + # key, so put it back + entries.append(NotifyOffice365.quote(entry, safe='')) + + # We're done + break + + # Initialize our tenant + results['tenant'] = None + + # Assemble our secret key which is a combination of the host followed + # by all entries in the full path that follow up until the first email + results['secret'] = '/'.join( + [NotifyOffice365.unquote(x) for x in entries]) + + # Assemble our client id from the user@hostname + if results['password']: + results['email'] = '{}@{}'.format( + NotifyOffice365.unquote(results['password']), + NotifyOffice365.unquote(results['host']), + ) + # Update our tenant + results['tenant'] = NotifyOffice365.unquote(results['user']) + + else: + # No tenant specified.. + results['email'] = '{}@{}'.format( + NotifyOffice365.unquote(results['user']), + NotifyOffice365.unquote(results['host']), + ) + + # OAuth2 ID + if 'oauth_id' in results['qsd'] and len(results['qsd']['oauth_id']): + # Extract the API Key from an argument + results['client_id'] = \ + NotifyOffice365.unquote(results['qsd']['oauth_id']) + + # OAuth2 Secret + if 'oauth_secret' in results['qsd'] and \ + len(results['qsd']['oauth_secret']): + # Extract the API Secret from an argument + results['secret'] = \ + NotifyOffice365.unquote(results['qsd']['oauth_secret']) + + # Tenant + if 'from' in results['qsd'] and \ + len(results['qsd']['from']): + # Extract the sending account's information + results['email'] = \ + NotifyOffice365.unquote(results['qsd']['from']) + + # Tenant + if 'tenant' in results['qsd'] and \ + len(results['qsd']['tenant']): + # Extract the Tenant from the argument + results['tenant'] = \ + NotifyOffice365.unquote(results['qsd']['tenant']) + + # Support the 'to' variable so that we can support targets this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyOffice365.parse_list(results['qsd']['to']) + + # Handle Carbon Copy Addresses + if 'cc' in results['qsd'] and len(results['qsd']['cc']): + results['cc'] = results['qsd']['cc'] + + # Handle Blind Carbon Copy Addresses + if 'bcc' in results['qsd'] and len(results['qsd']['bcc']): + results['bcc'] = results['qsd']['bcc'] + + return results diff --git a/libs/apprise/plugins/NotifyPopcornNotify.py b/libs/apprise/plugins/NotifyPopcornNotify.py new file mode 100644 index 000000000..817915186 --- /dev/null +++ b/libs/apprise/plugins/NotifyPopcornNotify.py @@ -0,0 +1,304 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2020 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import re +import requests + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import is_email +from ..utils import parse_list +from ..utils import parse_bool +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + +# Some Phone Number Detection +IS_PHONE_NO = re.compile(r'^\+?(?P[0-9\s)(+-]+)\s*$') + + +class NotifyPopcornNotify(NotifyBase): + """ + A wrapper for PopcornNotify Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'PopcornNotify' + + # The services URL + service_url = 'https://popcornnotify.com/' + + # The default protocol + secure_protocol = 'popcorn' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_popcornnotify' + + # PopcornNotify uses the http protocol + notify_url = 'https://popcornnotify.com/notify' + + # The maximum targets to include when doing batch transfers + default_batch_size = 10 + + # Define object templates + templates = ( + '{schema}://{apikey}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'regex': (r'^[a-z0-9]+$', 'i'), + 'required': True, + }, + 'target_phone': { + 'name': _('Target Phone No'), + 'type': 'string', + 'prefix': '+', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + 'map_to': 'targets', + }, + 'target_email': { + 'name': _('Target Email'), + 'type': 'string', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + } + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + }) + + def __init__(self, apikey, targets=None, batch=False, **kwargs): + """ + Initialize PopcornNotify Object + """ + super(NotifyPopcornNotify, self).__init__(**kwargs) + + # Access Token (associated with project) + self.apikey = validate_regex( + apikey, *self.template_tokens['apikey']['regex']) + if not self.apikey: + msg = 'An invalid PopcornNotify API Key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # Prepare Batch Mode Flag + self.batch = batch + + # Parse our targets + self.targets = list() + + for target in parse_list(targets): + # Validate targets and drop bad ones: + result = IS_PHONE_NO.match(target) + if result: + # Further check our phone # for it's digit count + result = ''.join(re.findall(r'\d+', result.group('phone'))) + if len(result) < 11 or len(result) > 14: + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) + continue + + # store valid phone number + self.targets.append(result) + continue + + result = is_email(target) + if result: + # store valid email + self.targets.append(result['full_email']) + continue + + self.logger.warning( + 'Dropped invalid target ' + '({}) specified.'.format(target), + ) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform PopcornNotify Notification + """ + + if len(self.targets) == 0: + # There were no services to notify + self.logger.warning( + 'There were no PopcornNotify targets to notify.') + return False + + # error tracking (used for function return) + has_error = False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/x-www-form-urlencoded', + } + + # Prepare our payload + payload = { + 'message': body, + 'subject': title, + } + + auth = (self.apikey, None) + + # Send in batches if identified to do so + batch_size = 1 if not self.batch else self.default_batch_size + + for index in range(0, len(self.targets), batch_size): + # Prepare our recipients + payload['recipients'] = \ + ','.join(self.targets[index:index + batch_size]) + + self.logger.debug('PopcornNotify POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('PopcornNotify Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + self.notify_url, + auth=auth, + data=payload, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyPopcornNotify.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send {} PopcornNotify notification{}: ' + '{}{}error={}.'.format( + len(self.targets[index:index + batch_size]), + ' to {}'.format(self.targets[index]) + if batch_size == 1 else '(s)', + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent {} PopcornNotify notification{}.' + .format( + len(self.targets[index:index + batch_size]), + ' to {}'.format(self.targets[index]) + if batch_size == 1 else '(s)', + )) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending {} PopcornNotify ' + 'notification(s).'.format( + len(self.targets[index:index + batch_size]))) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + return not has_error + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'batch': 'yes' if self.batch else 'no', + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{apikey}/{targets}/?{params}'.format( + schema=self.secure_protocol, + apikey=self.pprint(self.apikey, privacy, safe=''), + targets='/'.join( + [NotifyPopcornNotify.quote(x, safe='') for x in self.targets]), + params=NotifyPopcornNotify.urlencode(params)) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'] = \ + NotifyPopcornNotify.split_path(results['fullpath']) + + # The hostname is our authentication key + results['apikey'] = NotifyPopcornNotify.unquote(results['host']) + + # Support the 'to' variable so that we can support targets this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyPopcornNotify.parse_list(results['qsd']['to']) + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get('batch', False)) + + return results diff --git a/libs/apprise/plugins/NotifySpontit.py b/libs/apprise/plugins/NotifySpontit.py new file mode 100644 index 000000000..91388ea18 --- /dev/null +++ b/libs/apprise/plugins/NotifySpontit.py @@ -0,0 +1,376 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2020 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +# To use this service you will need a Spontit account from their website +# at https://spontit.com/ +# +# After you have an account created: +# - Visit your profile at https://spontit.com/profile and take note of your +# {username}. It might look something like: user12345678901 +# - Next generate an API key at https://spontit.com/secret_keys. This will +# generate a very long alpha-numeric string we'll refer to as the +# {apikey} + +# The Spontit Syntax is as follows: +# spontit://{username}@{apikey} + +import re +import requests +from json import loads + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import parse_list +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + +# Syntax suggests you use a hashtag '#' to help distinguish we're dealing +# with a channel. +# Secondly we extract the user information only if it's +# specified. If not, we use the user of the person sending the notification +# Finally the channel identifier is detected +CHANNEL_REGEX = re.compile( + r'^\s*(#|%23)?((@|%40)?(?P[a-z0-9_]+)([/\\]|%2F))?' + r'(?P[a-z0-9_-]+)\s*$', re.I) + + +class NotifySpontit(NotifyBase): + """ + A wrapper for Spontit Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Spontit' + + # The services URL + service_url = 'https://spontit.com/' + + # All notification requests are secure + secure_protocol = 'spontit' + + # Allow 300 requests per minute. + # 60/300 = 0.2 + request_rate_per_sec = 0.20 + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_spontit' + + # Spontit single notification URL + notify_url = 'https://api.spontit.com/v3/push' + + # The maximum length of the body + body_maxlen = 5000 + + # The maximum length of the title + title_maxlen = 100 + + # If we don't have the specified min length, then we don't bother using + # the body directive + spontit_body_minlen = 100 + + # Subtitle support; this is the maximum allowed characters defined by + # the API page + spontit_subtitle_maxlen = 20 + + # Define object templates + templates = ( + '{schema}://{user}@{apikey}', + '{schema}://{user}@{apikey}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'user': { + 'name': _('User ID'), + 'type': 'string', + 'required': True, + 'regex': (r'^[a-z0-9_-]+$', 'i'), + }, + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'required': True, + 'private': True, + 'regex': (r'^[a-z0-9]+$', 'i'), + }, + # Target Channel ID's + # If a slash is used; you must escape it + # If no slash is used; channel is presumed to be your own + 'target_channel': { + 'name': _('Target Channel ID'), + 'type': 'string', + 'prefix': '#', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'subtitle': { + # Subtitle is available for MacOS users + 'name': _('Subtitle'), + 'type': 'string', + }, + }) + + def __init__(self, apikey, targets=None, subtitle=None, **kwargs): + """ + Initialize Spontit Object + """ + super(NotifySpontit, self).__init__(**kwargs) + + # User ID (associated with project) + user = validate_regex( + self.user, *self.template_tokens['user']['regex']) + if not user: + msg = 'An invalid Spontit User ID ' \ + '({}) was specified.'.format(self.user) + self.logger.warning(msg) + raise TypeError(msg) + # use cleaned up version + self.user = user + + # API Key (associated with project) + self.apikey = validate_regex( + apikey, *self.template_tokens['apikey']['regex']) + if not self.apikey: + msg = 'An invalid Spontit API Key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # Save our subtitle information + self.subtitle = subtitle + + # Parse our targets + self.targets = list() + + for target in parse_list(targets): + # Validate targets and drop bad ones: + result = CHANNEL_REGEX.match(target) + if result: + # Just extract the channel + self.targets.append( + '{}'.format(result.group('channel'))) + continue + + self.logger.warning( + 'Dropped invalid channel/user ({}) specified.'.format(target)) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Sends Message + """ + + # error tracking (used for function return) + has_error = False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + 'X-Authorization': self.apikey, + 'X-UserId': self.user, + } + + # use the list directly + targets = list(self.targets) + + if not len(targets): + # The user did not specify a channel and therefore wants to notify + # the main account only. We just set a substitute marker of + # None so that our while loop below can still process one iteration + targets = [None, ] + + while len(targets): + # Get our target(s) to notify + target = targets.pop(0) + + # Prepare our payload + payload = { + 'message': body, + } + + # Use our body directive if we exceed the minimum message + # limitation + if len(body) > self.spontit_body_minlen: + payload['message'] = '{}...'.format( + body[:self.spontit_body_minlen - 3]) + payload['body'] = body + + if self.subtitle: + # Set title if specified + payload['subtitle'] = \ + self.subtitle[:self.spontit_subtitle_maxlen] + + elif self.app_desc: + # fall back to app description + payload['subtitle'] = \ + self.app_desc[:self.spontit_subtitle_maxlen] + + elif self.app_id: + # fall back to app id + payload['subtitle'] = \ + self.app_id[:self.spontit_subtitle_maxlen] + + if title: + # Set title if specified + payload['pushTitle'] = title + + if target is not None: + payload['channelName'] = target + + # Some Debug Logging + self.logger.debug( + 'Spontit POST URL: {} (cert_verify={})'.format( + self.notify_url, self.verify_certificate)) + self.logger.debug('Spontit Payload: {}' .format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + self.notify_url, + params=payload, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + if r.status_code not in ( + requests.codes.created, requests.codes.ok): + status_str = \ + NotifyBase.http_response_code_lookup( + r.status_code) + + try: + # Update our status response if we can + json_response = loads(r.content) + status_str = json_response.get('message', status_str) + + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + + # We could not parse JSON response. + # We will just use the status we already have. + pass + + self.logger.warning( + 'Failed to send Spontit notification to {}: ' + '{}{}error={}.'.format( + target, + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + # If we reach here; the message was sent + self.logger.info( + 'Sent Spontit notification to {}.'.format(target)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Spontit:%s ' % ( + ', '.join(self.targets)) + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + # Mark our failure + has_error = True + continue + + return not has_error + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Our URL parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + if self.subtitle: + params['subtitle'] = self.subtitle + + return '{schema}://{userid}@{apikey}/{targets}?{params}'.format( + schema=self.secure_protocol, + userid=self.user, + apikey=self.pprint(self.apikey, privacy, safe=''), + targets='/'.join( + [NotifySpontit.quote(x, safe='') for x in self.targets]), + params=NotifySpontit.urlencode(params)) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'] = NotifySpontit.split_path(results['fullpath']) + + # The hostname is our authentication key + results['apikey'] = NotifySpontit.unquote(results['host']) + + # Support MacOS subtitle option + if 'subtitle' in results['qsd'] and len(results['qsd']['subtitle']): + results['subtitle'] = results['qsd']['subtitle'] + + # Support the 'to' variable so that we can support targets this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifySpontit.parse_list(results['qsd']['to']) + + return results diff --git a/libs/apprise/py3compat/__init__.py b/libs/apprise/py3compat/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libs/apprise/py3compat/asyncio.py b/libs/apprise/py3compat/asyncio.py new file mode 100644 index 000000000..85519fa2a --- /dev/null +++ b/libs/apprise/py3compat/asyncio.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2020 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import sys +import asyncio +from ..URLBase import URLBase +from ..logger import logger + + +# A global flag that tracks if we are Python v3.7 or higher +ASYNCIO_RUN_SUPPORT = \ + sys.version_info.major > 3 or \ + (sys.version_info.major == 3 and sys.version_info.minor >= 7) + + +def notify(coroutines, debug=False): + """ + A Wrapper to the AsyncNotifyBase.async_notify() calls allowing us + to call gather() and collect the responses + """ + + # Create log entry + logger.info( + 'Notifying {} service(s) asynchronous.'.format(len(coroutines))) + + if ASYNCIO_RUN_SUPPORT: + # async reference produces a SyntaxError (E999) in Python v2.7 + # For this reason we turn on the noqa flag + async def main(results, coroutines): # noqa: E999 + """ + Task: Notify all servers specified and return our result set + through a mutable object. + """ + # send our notifications and store our result set into + # our results dictionary + results['response'] = \ + await asyncio.gather(*coroutines, return_exceptions=True) + + # Initialize a mutable object we can populate with our notification + # responses + results = {} + + # Send our notifications + asyncio.run(main(results, coroutines), debug=debug) + + # Acquire our return status + status = next((s for s in results['response'] if s is False), True) + + else: + # + # The depricated way + # + + # acquire access to our event loop + loop = asyncio.get_event_loop() + + if debug: + # Enable debug mode + loop.set_debug(1) + + # Send our notifications and acquire our status + results = loop.run_until_complete(asyncio.gather(*coroutines)) + + # Acquire our return status + status = next((r for r in results if r is False), True) + + # Returns True if all notifications succeeded, otherwise False is + # returned. + return status + + +class AsyncNotifyBase(URLBase): + """ + asyncio wrapper for the NotifyBase object + """ + + async def async_notify(self, *args, **kwargs): # noqa: E999 + """ + Async Notification Wrapper + """ + try: + return self.notify(*args, **kwargs) + + except TypeError: + # These our our internally thrown notifications + pass + + except Exception: + # A catch all so we don't have to abort early + # just because one of our plugins has a bug in it. + logger.exception("Notification Exception") + + return False