diff --git a/CHANGES.md b/CHANGES.md index f3e10232..93aad29c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,6 @@ ### 3.32.0 (2024-xx-xx xx:xx:00 UTC) +* Update apprise 1.6.0 (0c0d5da) to 1.8.0 (81caf92) * Update attr 23.1.0 (67e4ff2) to 23.2.0 (b393d79) * Update Beautiful Soup 4.12.2 (30c58a1) to 4.12.3 (7fb5175) * Update CacheControl 0.13.1 (783a338) to 0.14.0 (e2be0c2) diff --git a/lib/apprise/__init__.py b/lib/apprise/__init__.py index f8bb5c75..c57e7701 100644 --- a/lib/apprise/__init__.py +++ b/lib/apprise/__init__.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -27,10 +27,10 @@ # POSSIBILITY OF SUCH DAMAGE. __title__ = 'Apprise' -__version__ = '1.6.0' +__version__ = '1.8.0' __author__ = 'Chris Caron' __license__ = 'BSD' -__copywrite__ = 'Copyright (C) 2023 Chris Caron ' +__copywrite__ = 'Copyright (C) 2024 Chris Caron ' __email__ = 'lead2gold@gmail.com' __status__ = 'Production' @@ -49,17 +49,20 @@ from .common import CONTENT_INCLUDE_MODES from .common import ContentLocation from .common import CONTENT_LOCATIONS -from .URLBase import URLBase -from .URLBase import PrivacyMode -from .plugins.NotifyBase import NotifyBase -from .config.ConfigBase import ConfigBase -from .attachment.AttachBase import AttachBase - -from .Apprise import Apprise -from .AppriseAsset import AppriseAsset -from .AppriseConfig import AppriseConfig -from .AppriseAttachment import AppriseAttachment +from .url import URLBase +from .url import PrivacyMode +from .plugins.base import NotifyBase +from .config.base import ConfigBase +from .attachment.base import AttachBase +from .apprise import Apprise +from .locale import AppriseLocale +from .asset import AppriseAsset +from .apprise_config import AppriseConfig +from .apprise_attachment import AppriseAttachment +from .manager_attachment import AttachmentManager +from .manager_config import ConfigurationManager +from .manager_plugins import NotificationManager from . import decorators # Inherit our logging with our additional entries added to it @@ -73,7 +76,7 @@ logging.getLogger(__name__).addHandler(logging.NullHandler()) __all__ = [ # Core 'Apprise', 'AppriseAsset', 'AppriseConfig', 'AppriseAttachment', 'URLBase', - 'NotifyBase', 'ConfigBase', 'AttachBase', + 'NotifyBase', 'ConfigBase', 'AttachBase', 'AppriseLocale', # Reference 'NotifyType', 'NotifyImageSize', 'NotifyFormat', 'OverflowMode', @@ -83,6 +86,9 @@ __all__ = [ 'ContentLocation', 'CONTENT_LOCATIONS', 'PrivacyMode', + # Managers + 'NotificationManager', 'ConfigurationManager', 'AttachmentManager', + # Decorator 'decorators', diff --git a/lib/apprise/Apprise.py b/lib/apprise/apprise.py similarity index 95% rename from lib/apprise/Apprise.py rename to lib/apprise/apprise.py index 4c83c481..05a2ee3c 100644 --- a/lib/apprise/Apprise.py +++ b/lib/apprise/apprise.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -33,21 +33,25 @@ from itertools import chain from . import common from .conversion import convert_between from .utils import is_exclusive_match +from .manager_plugins import NotificationManager from .utils import parse_list from .utils import parse_urls from .utils import cwe312_url +from .emojis import apply_emojis from .logger import logger -from .AppriseAsset import AppriseAsset -from .AppriseConfig import AppriseConfig -from .AppriseAttachment import AppriseAttachment -from .AppriseLocale import AppriseLocale -from .config.ConfigBase import ConfigBase -from .plugins.NotifyBase import NotifyBase - +from .asset import AppriseAsset +from .apprise_config import AppriseConfig +from .apprise_attachment import AppriseAttachment +from .locale import AppriseLocale +from .config.base import ConfigBase +from .plugins.base import NotifyBase from . import plugins from . import __version__ +# Grant access to our Notification Manager Singleton +N_MGR = NotificationManager() + class Apprise: """ @@ -137,7 +141,7 @@ class Apprise: # We already have our result set results = url - if results.get('schema') not in common.NOTIFY_SCHEMA_MAP: + if results.get('schema') not in N_MGR: # schema is a mandatory dictionary item as it is the only way # we can index into our loaded plugins logger.error('Dictionary does not include a "schema" entry.') @@ -160,7 +164,7 @@ class Apprise: type(url)) return None - if not common.NOTIFY_SCHEMA_MAP[results['schema']].enabled: + if not N_MGR[results['schema']].enabled: # # First Plugin Enable Check (Pre Initialization) # @@ -180,13 +184,12 @@ class Apprise: try: # Attempt to create an instance of our plugin using the parsed # URL information - plugin = common.NOTIFY_SCHEMA_MAP[results['schema']](**results) + plugin = N_MGR[results['schema']](**results) # Create log entry of loaded URL logger.debug( 'Loaded {} URL: {}'.format( - common. - NOTIFY_SCHEMA_MAP[results['schema']].service_name, + N_MGR[results['schema']].service_name, plugin.url(privacy=asset.secure_logging))) except Exception: @@ -197,15 +200,14 @@ class Apprise: # the arguments are invalid or can not be used. logger.error( 'Could not load {} URL: {}'.format( - common. - NOTIFY_SCHEMA_MAP[results['schema']].service_name, + N_MGR[results['schema']].service_name, loggable_url)) return None else: # Attempt to create an instance of our plugin using the parsed # URL information but don't wrap it in a try catch - plugin = common.NOTIFY_SCHEMA_MAP[results['schema']](**results) + plugin = N_MGR[results['schema']](**results) if not plugin.enabled: # @@ -376,7 +378,7 @@ class Apprise: body, title, notify_type=notify_type, body_format=body_format, tag=tag, match_always=match_always, attach=attach, - interpret_escapes=interpret_escapes + interpret_escapes=interpret_escapes, ) except TypeError: @@ -501,6 +503,11 @@ class Apprise: key = server.notify_format if server.title_maxlen > 0\ else f'_{server.notify_format}' + if server.interpret_emojis: + # alter our key slightly to handle emojis since their value is + # pulled out of the notification + key += "-emojis" + if key not in conversion_title_map: # Prepare our title @@ -542,6 +549,16 @@ class Apprise: logger.error(msg) raise TypeError(msg) + if server.interpret_emojis: + # + # Convert our :emoji: definitions + # + + conversion_body_map[key] = \ + apply_emojis(conversion_body_map[key]) + conversion_title_map[key] = \ + apply_emojis(conversion_title_map[key]) + kwargs = dict( body=conversion_body_map[key], title=conversion_title_map[key], @@ -674,7 +691,7 @@ class Apprise: 'asset': self.asset.details(), } - for plugin in set(common.NOTIFY_SCHEMA_MAP.values()): + for plugin in N_MGR.plugins(): # Iterate over our hashed plugins and dynamically build details on # their status: diff --git a/lib/apprise/Apprise.pyi b/lib/apprise/apprise.pyi similarity index 100% rename from lib/apprise/Apprise.pyi rename to lib/apprise/apprise.pyi diff --git a/lib/apprise/AppriseAttachment.py b/lib/apprise/apprise_attachment.py similarity index 92% rename from lib/apprise/AppriseAttachment.py rename to lib/apprise/apprise_attachment.py index e00645d2..ecf415ec 100644 --- a/lib/apprise/AppriseAttachment.py +++ b/lib/apprise/apprise_attachment.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -26,15 +26,18 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -from . import attachment from . import URLBase -from .AppriseAsset import AppriseAsset +from .attachment.base import AttachBase +from .asset import AppriseAsset +from .manager_attachment import AttachmentManager from .logger import logger from .common import ContentLocation from .common import CONTENT_LOCATIONS -from .common import ATTACHMENT_SCHEMA_MAP from .utils import GET_SCHEMA_RE +# Grant access to our Notification Manager Singleton +A_MGR = AttachmentManager() + class AppriseAttachment: """ @@ -139,13 +142,8 @@ class AppriseAttachment: # prepare default asset asset = self.asset - if isinstance(attachments, attachment.AttachBase): - # Go ahead and just add our attachments into our list - self.attachments.append(attachments) - return True - - elif isinstance(attachments, str): - # Save our path + if isinstance(attachments, (AttachBase, str)): + # store our instance attachments = (attachments, ) elif not isinstance(attachments, (tuple, set, list)): @@ -169,7 +167,7 @@ class AppriseAttachment: # returns None if it fails instance = AppriseAttachment.instantiate( _attachment, asset=asset, cache=cache) - if not isinstance(instance, attachment.AttachBase): + if not isinstance(instance, AttachBase): return_status = False continue @@ -178,7 +176,7 @@ class AppriseAttachment: # append our content together instance = _attachment.attachments - elif not isinstance(_attachment, attachment.AttachBase): + elif not isinstance(_attachment, AttachBase): logger.warning( "An invalid attachment (type={}) was specified.".format( type(_attachment))) @@ -228,7 +226,7 @@ class AppriseAttachment: schema = GET_SCHEMA_RE.match(url) if schema is None: # Plan B is to assume we're dealing with a file - schema = attachment.AttachFile.protocol + schema = 'file' url = '{}://{}'.format(schema, URLBase.quote(url)) else: @@ -236,13 +234,13 @@ class AppriseAttachment: schema = schema.group('schema').lower() # Some basic validation - if schema not in ATTACHMENT_SCHEMA_MAP: + if schema not in A_MGR: logger.warning('Unsupported schema {}.'.format(schema)) return None # Parse our url details of the server object as dictionary containing # all of the information parsed from our URL - results = ATTACHMENT_SCHEMA_MAP[schema].parse_url(url) + results = A_MGR[schema].parse_url(url) if not results: # Failed to parse the server URL @@ -261,8 +259,7 @@ class AppriseAttachment: try: # Attempt to create an instance of our plugin using the parsed # URL information - attach_plugin = \ - ATTACHMENT_SCHEMA_MAP[results['schema']](**results) + attach_plugin = A_MGR[results['schema']](**results) except Exception: # the arguments are invalid or can not be used. @@ -272,7 +269,7 @@ class AppriseAttachment: else: # Attempt to create an instance of our plugin using the parsed # URL information but don't wrap it in a try catch - attach_plugin = ATTACHMENT_SCHEMA_MAP[results['schema']](**results) + attach_plugin = A_MGR[results['schema']](**results) return attach_plugin diff --git a/lib/apprise/AppriseAttachment.pyi b/lib/apprise/apprise_attachment.pyi similarity index 100% rename from lib/apprise/AppriseAttachment.pyi rename to lib/apprise/apprise_attachment.pyi diff --git a/lib/apprise/AppriseConfig.py b/lib/apprise/apprise_config.py similarity index 96% rename from lib/apprise/AppriseConfig.py rename to lib/apprise/apprise_config.py index 07e7b48e..080f70d3 100644 --- a/lib/apprise/AppriseConfig.py +++ b/lib/apprise/apprise_config.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -26,17 +26,20 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -from . import config from . import ConfigBase from . import CONFIG_FORMATS +from .manager_config import ConfigurationManager from . import URLBase -from .AppriseAsset import AppriseAsset +from .asset import AppriseAsset from . import common from .utils import GET_SCHEMA_RE from .utils import parse_list from .utils import is_exclusive_match from .logger import logger +# Grant access to our Configuration Manager Singleton +C_MGR = ConfigurationManager() + class AppriseConfig: """ @@ -251,7 +254,7 @@ class AppriseConfig: logger.debug("Loading raw configuration: {}".format(content)) # Create ourselves a ConfigMemory Object to store our configuration - instance = config.ConfigMemory( + instance = C_MGR['memory']( content=content, format=format, asset=asset, tag=tag, recursion=recursion, insecure_includes=insecure_includes) @@ -326,7 +329,7 @@ class AppriseConfig: schema = GET_SCHEMA_RE.match(url) if schema is None: # Plan B is to assume we're dealing with a file - schema = config.ConfigFile.protocol + schema = 'file' url = '{}://{}'.format(schema, URLBase.quote(url)) else: @@ -334,13 +337,13 @@ class AppriseConfig: schema = schema.group('schema').lower() # Some basic validation - if schema not in common.CONFIG_SCHEMA_MAP: + if schema not in C_MGR: logger.warning('Unsupported schema {}.'.format(schema)) return None # Parse our url details of the server object as dictionary containing # all of the information parsed from our URL - results = common.CONFIG_SCHEMA_MAP[schema].parse_url(url) + results = C_MGR[schema].parse_url(url) if not results: # Failed to parse the server URL @@ -368,8 +371,7 @@ class AppriseConfig: try: # Attempt to create an instance of our plugin using the parsed # URL information - cfg_plugin = \ - common.CONFIG_SCHEMA_MAP[results['schema']](**results) + cfg_plugin = C_MGR[results['schema']](**results) except Exception: # the arguments are invalid or can not be used. @@ -379,7 +381,7 @@ class AppriseConfig: else: # Attempt to create an instance of our plugin using the parsed # URL information but don't wrap it in a try catch - cfg_plugin = common.CONFIG_SCHEMA_MAP[results['schema']](**results) + cfg_plugin = C_MGR[results['schema']](**results) return cfg_plugin diff --git a/lib/apprise/AppriseConfig.pyi b/lib/apprise/apprise_config.pyi similarity index 100% rename from lib/apprise/AppriseConfig.pyi rename to lib/apprise/apprise_config.pyi diff --git a/lib/apprise/AppriseAsset.py b/lib/apprise/asset.py similarity index 94% rename from lib/apprise/AppriseAsset.py rename to lib/apprise/asset.py index 835c3b6a..c0fab9c0 100644 --- a/lib/apprise/AppriseAsset.py +++ b/lib/apprise/asset.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -33,7 +33,11 @@ from os.path import dirname from os.path import isfile from os.path import abspath from .common import NotifyType -from .utils import module_detection +from .manager_plugins import NotificationManager + + +# Grant access to our Notification Manager Singleton +N_MGR = NotificationManager() class AppriseAsset: @@ -66,6 +70,9 @@ class AppriseAsset: NotifyType.WARNING: '#CACF29', } + # The default color to return if a mapping isn't found in our table above + default_html_color = '#888888' + # Ascii Notification ascii_notify_map = { NotifyType.INFO: '[i]', @@ -74,8 +81,8 @@ class AppriseAsset: NotifyType.WARNING: '[~]', } - # The default color to return if a mapping isn't found in our table above - default_html_color = '#888888' + # The default ascii to return if a mapping isn't found in our table above + default_ascii_chars = '[?]' # The default image extension to use default_extension = '.png' @@ -121,6 +128,12 @@ class AppriseAsset: # notifications are sent sequentially (one after another) async_mode = True + # Support :smile:, and other alike keywords swapping them for their + # unicode value. A value of None leaves the interpretation up to the + # end user to control (allowing them to specify emojis=yes on the + # URL) + interpret_emojis = None + # Whether or not to interpret escapes found within the input text prior # to passing it upstream. Such as converting \t to an actual tab and \n # to a new line. @@ -174,7 +187,7 @@ class AppriseAsset: if plugin_paths: # Load any decorated modules if defined - module_detection(plugin_paths) + N_MGR.module_detection(plugin_paths) def color(self, notify_type, color_type=None): """ @@ -213,9 +226,8 @@ class AppriseAsset: Returns an ascii representation based on passed in notify type """ - # look our response up - return self.ascii_notify_map.get(notify_type, self.default_html_color) + return self.ascii_notify_map.get(notify_type, self.default_ascii_chars) def image_url(self, notify_type, image_size, logo=False, extension=None): """ diff --git a/lib/apprise/AppriseAsset.pyi b/lib/apprise/asset.pyi similarity index 100% rename from lib/apprise/AppriseAsset.pyi rename to lib/apprise/asset.pyi diff --git a/lib/apprise/attachment/AttachHTTP.py b/lib/apprise/attachment/AttachHTTP.py deleted file mode 100644 index 0c859477..00000000 --- a/lib/apprise/attachment/AttachHTTP.py +++ /dev/null @@ -1,337 +0,0 @@ -# -*- coding: utf-8 -*- -# BSD 2-Clause License -# -# Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# 1. Redistributions of source code must retain the above copyright notice, -# this list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE -# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. - -import re -import os -import requests -from tempfile import NamedTemporaryFile -from .AttachBase import AttachBase -from ..common import ContentLocation -from ..URLBase import PrivacyMode -from ..AppriseLocale import gettext_lazy as _ - - -class AttachHTTP(AttachBase): - """ - A wrapper for HTTP based attachment sources - """ - - # The default descriptive name associated with the service - service_name = _('Web Based') - - # The default protocol - protocol = 'http' - - # The default secure protocol - secure_protocol = 'https' - - # The number of bytes in memory to read from the remote source at a time - chunk_size = 8192 - - # Web based requests are remote/external to our current location - location = ContentLocation.HOSTED - - def __init__(self, headers=None, **kwargs): - """ - Initialize HTTP Object - - headers can be a dictionary of key/value pairs that you want to - additionally include as part of the server headers to post with - - """ - super().__init__(**kwargs) - - self.schema = 'https' if self.secure else 'http' - - self.fullpath = kwargs.get('fullpath') - if not isinstance(self.fullpath, str): - self.fullpath = '/' - - self.headers = {} - if headers: - # Store our extra headers - self.headers.update(headers) - - # Where our content is written to upon a call to download. - self._temp_file = None - - # Our Query String Dictionary; we use this to track arguments - # specified that aren't otherwise part of this class - self.qsd = {k: v for k, v in kwargs.get('qsd', {}).items() - if k not in self.template_args} - - return - - def download(self, **kwargs): - """ - Perform retrieval of the configuration based on the specified request - """ - - if self.location == ContentLocation.INACCESSIBLE: - # our content is inaccessible - return False - - # Ensure any existing content set has been invalidated - self.invalidate() - - # prepare header - headers = { - 'User-Agent': self.app_id, - } - - # Apply any/all header over-rides defined - headers.update(self.headers) - - auth = None - if self.user: - auth = (self.user, self.password) - - url = '%s://%s' % (self.schema, self.host) - if isinstance(self.port, int): - url += ':%d' % self.port - - url += self.fullpath - - self.logger.debug('HTTP POST URL: %s (cert_verify=%r)' % ( - url, self.verify_certificate, - )) - - # Where our request object will temporarily live. - r = None - - # Always call throttle before any remote server i/o is made - self.throttle() - - try: - # Make our request - with requests.get( - url, - headers=headers, - auth=auth, - params=self.qsd, - verify=self.verify_certificate, - timeout=self.request_timeout, - stream=True) as r: - - # Handle Errors - r.raise_for_status() - - # Get our file-size (if known) - try: - file_size = int(r.headers.get('Content-Length', '0')) - except (TypeError, ValueError): - # Handle edge case where Content-Length is a bad value - file_size = 0 - - # Perform a little Q/A on file limitations and restrictions - if self.max_file_size > 0 and file_size > self.max_file_size: - - # The content retrieved is to large - self.logger.error( - 'HTTP response exceeds allowable maximum file length ' - '({}KB): {}'.format( - int(self.max_file_size / 1024), - self.url(privacy=True))) - - # Return False (signifying a failure) - return False - - # Detect config format based on mime if the format isn't - # already enforced - self.detected_mimetype = r.headers.get('Content-Type') - - d = r.headers.get('Content-Disposition', '') - result = re.search( - "filename=['\"]?(?P[^'\"]+)['\"]?", d, re.I) - if result: - self.detected_name = result.group('name').strip() - - # Create a temporary file to work with - self._temp_file = NamedTemporaryFile() - - # Get our chunk size - chunk_size = self.chunk_size - - # Track all bytes written to disk - bytes_written = 0 - - # If we get here, we can now safely write our content to disk - for chunk in r.iter_content(chunk_size=chunk_size): - # filter out keep-alive chunks - if chunk: - self._temp_file.write(chunk) - bytes_written = self._temp_file.tell() - - # Prevent a case where Content-Length isn't provided - # we don't want to fetch beyond our limits - if self.max_file_size > 0: - if bytes_written > self.max_file_size: - # The content retrieved is to large - self.logger.error( - 'HTTP response exceeds allowable maximum ' - 'file length ({}KB): {}'.format( - int(self.max_file_size / 1024), - self.url(privacy=True))) - - # Invalidate any variables previously set - self.invalidate() - - # Return False (signifying a failure) - return False - - elif bytes_written + chunk_size \ - > self.max_file_size: - # Adjust out next read to accomodate up to our - # limit +1. This will prevent us from readig - # to much into our memory buffer - self.max_file_size - bytes_written + 1 - - # Ensure our content is flushed to disk for post-processing - self._temp_file.flush() - - # Set our minimum requirements for a successful download() call - self.download_path = self._temp_file.name - if not self.detected_name: - self.detected_name = os.path.basename(self.fullpath) - - except requests.RequestException as e: - self.logger.error( - 'A Connection error occurred retrieving HTTP ' - 'configuration from %s.' % self.host) - self.logger.debug('Socket Exception: %s' % str(e)) - - # Invalidate any variables previously set - self.invalidate() - - # Return False (signifying a failure) - return False - - except (IOError, OSError): - # IOError is present for backwards compatibility with Python - # versions older then 3.3. >= 3.3 throw OSError now. - - # Could not open and/or write the temporary file - self.logger.error( - 'Could not write attachment to disk: {}'.format( - self.url(privacy=True))) - - # Invalidate any variables previously set - self.invalidate() - - # Return False (signifying a failure) - return False - - # Return our success - return True - - def invalidate(self): - """ - Close our temporary file - """ - if self._temp_file: - self._temp_file.close() - self._temp_file = None - - super().invalidate() - - 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) - - # Prepare our cache value - if self.cache is not None: - if isinstance(self.cache, bool) or not self.cache: - cache = 'yes' if self.cache else 'no' - else: - cache = int(self.cache) - - # Set our cache value - params['cache'] = cache - - if self._mimetype: - # A format was enforced - params['mime'] = self._mimetype - - if self._name: - # A name was enforced - params['name'] = self._name - - # Append our headers into our parameters - params.update({'+{}'.format(k): v for k, v in self.headers.items()}) - - # Apply any remaining entries to our URL - params.update(self.qsd) - - # Determine Authentication - auth = '' - if self.user and self.password: - auth = '{user}:{password}@'.format( - user=self.quote(self.user, safe=''), - password=self.pprint( - self.password, privacy, mode=PrivacyMode.Secret, safe=''), - ) - elif self.user: - auth = '{user}@'.format( - user=self.quote(self.user, safe=''), - ) - - default_port = 443 if self.secure else 80 - - return '{schema}://{auth}{hostname}{port}{fullpath}?{params}'.format( - schema=self.secure_protocol if self.secure else self.protocol, - auth=auth, - hostname=self.quote(self.host, safe=''), - port='' if self.port is None or self.port == default_port - else ':{}'.format(self.port), - fullpath=self.quote(self.fullpath, safe='/'), - params=self.urlencode(params), - ) - - @staticmethod - def parse_url(url): - """ - Parses the URL and returns enough arguments that can allow - us to re-instantiate this object. - - """ - results = AttachBase.parse_url(url) - - if not results: - # We're done early as we couldn't load the results - return results - - # Add our headers that the user can potentially over-ride if they wish - # to to our returned result set - results['headers'] = results['qsd-'] - results['headers'].update(results['qsd+']) - - return results diff --git a/lib/apprise/attachment/__init__.py b/lib/apprise/attachment/__init__.py index ba7620a4..c2aef1ee 100644 --- a/lib/apprise/attachment/__init__.py +++ b/lib/apprise/attachment/__init__.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -26,93 +26,15 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -import re -from os import listdir -from os.path import dirname -from os.path import abspath -from ..common import ATTACHMENT_SCHEMA_MAP +# Used for testing +from .base import AttachBase +from ..manager_attachment import AttachmentManager -__all__ = [] +# Initalize our Attachment Manager Singleton +A_MGR = AttachmentManager() - -# Load our Lookup Matrix -def __load_matrix(path=abspath(dirname(__file__)), name='apprise.attachment'): - """ - Dynamically load our schema map; this allows us to gracefully - skip over modules we simply don't have the dependencies for. - - """ - # Used for the detection of additional Attachment Services objects - # The .py extension is optional as we support loading directories too - module_re = re.compile(r'^(?PAttach[a-z0-9]+)(\.py)?$', re.I) - - for f in listdir(path): - match = module_re.match(f) - if not match: - # keep going - continue - - # Store our notification/plugin name: - plugin_name = match.group('name') - try: - module = __import__( - '{}.{}'.format(name, plugin_name), - globals(), locals(), - fromlist=[plugin_name]) - - except ImportError: - # No problem, we can't use this object - continue - - if not hasattr(module, plugin_name): - # Not a library we can load as it doesn't follow the simple rule - # that the class must bear the same name as the notification - # file itself. - continue - - # Get our plugin - plugin = getattr(module, plugin_name) - if not hasattr(plugin, 'app_id'): - # Filter out non-notification modules - continue - - elif plugin_name in __all__: - # we're already handling this object - continue - - # Add our module name to our __all__ - __all__.append(plugin_name) - - # Ensure we provide the class as the reference to this directory and - # not the module: - globals()[plugin_name] = plugin - - # Load protocol(s) if defined - proto = getattr(plugin, 'protocol', None) - if isinstance(proto, str): - if proto not in ATTACHMENT_SCHEMA_MAP: - ATTACHMENT_SCHEMA_MAP[proto] = plugin - - elif isinstance(proto, (set, list, tuple)): - # Support iterables list types - for p in proto: - if p not in ATTACHMENT_SCHEMA_MAP: - ATTACHMENT_SCHEMA_MAP[p] = plugin - - # Load secure protocol(s) if defined - protos = getattr(plugin, 'secure_protocol', None) - if isinstance(protos, str): - if protos not in ATTACHMENT_SCHEMA_MAP: - ATTACHMENT_SCHEMA_MAP[protos] = plugin - - if isinstance(protos, (set, list, tuple)): - # Support iterables list types - for p in protos: - if p not in ATTACHMENT_SCHEMA_MAP: - ATTACHMENT_SCHEMA_MAP[p] = plugin - - return ATTACHMENT_SCHEMA_MAP - - -# Dynamically build our schema base -__load_matrix() +__all__ = [ + # Reference + 'AttachBase', + 'AttachmentManager', +] diff --git a/lib/apprise/attachment/AttachBase.py b/lib/apprise/attachment/base.py similarity index 90% rename from lib/apprise/attachment/AttachBase.py rename to lib/apprise/attachment/base.py index c1cadbf9..6ae9d3aa 100644 --- a/lib/apprise/attachment/AttachBase.py +++ b/lib/apprise/attachment/base.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -29,10 +29,10 @@ import os import time import mimetypes -from ..URLBase import URLBase +from ..url import URLBase from ..utils import parse_bool from ..common import ContentLocation -from ..AppriseLocale import gettext_lazy as _ +from ..locale import gettext_lazy as _ class AttachBase(URLBase): @@ -148,6 +148,9 @@ class AttachBase(URLBase): # Absolute path to attachment self.download_path = None + # Track open file pointers + self.__pointers = set() + # Set our cache flag; it can be True, False, None, or a (positive) # integer... nothing else if cache is not None: @@ -226,15 +229,14 @@ class AttachBase(URLBase): Content is cached once determied to prevent overhead of future calls. """ + if not self.exists(): + # we could not obtain our attachment + return None if self._mimetype: # return our pre-calculated cached content return self._mimetype - if not self.exists(): - # we could not obtain our attachment - return None - if not self.detected_mimetype: # guess_type() returns: (type, encoding) and sets type to None # if it can't otherwise determine it. @@ -253,11 +255,14 @@ class AttachBase(URLBase): return self.detected_mimetype \ if self.detected_mimetype else self.unknown_mimetype - def exists(self): + def exists(self, retrieve_if_missing=True): """ Simply returns true if the object has downloaded and stored the attachment AND the attachment has not expired. """ + if self.location == ContentLocation.INACCESSIBLE: + # our content is inaccessible + return False cache = self.template_args['cache']['default'] \ if self.cache is None else self.cache @@ -282,7 +287,7 @@ class AttachBase(URLBase): # The file is not present pass - return self.download() + return False if not retrieve_if_missing else self.download() def invalidate(self): """ @@ -295,6 +300,11 @@ class AttachBase(URLBase): - download_path: Must contain a absolute path to content - detected_mimetype: Should identify mimetype of content """ + + # Remove all open pointers + while self.__pointers: + self.__pointers.pop().close() + self.detected_name = None self.download_path = None self.detected_mimetype = None @@ -314,8 +324,28 @@ class AttachBase(URLBase): raise NotImplementedError( "download() is implimented by the child class.") + def open(self, mode='rb'): + """ + return our file pointer and track it (we'll auto close later + """ + pointer = open(self.path, mode=mode) + self.__pointers.add(pointer) + return pointer + + def __enter__(self): + """ + support with keyword + """ + return self.open() + + def __exit__(self, value_type, value, traceback): + """ + stub to do nothing; but support exit of with statement gracefully + """ + return + @staticmethod - def parse_url(url, verify_host=True, mimetype_db=None): + def parse_url(url, verify_host=True, mimetype_db=None, sanitize=True): """Parses the URL and returns it broken apart into a dictionary. This is very specific and customized for Apprise. @@ -333,7 +363,8 @@ class AttachBase(URLBase): successful, otherwise None is returned. """ - results = URLBase.parse_url(url, verify_host=verify_host) + results = URLBase.parse_url( + url, verify_host=verify_host, sanitize=sanitize) if not results: # We're done; we failed to parse our url @@ -375,3 +406,9 @@ class AttachBase(URLBase): True is returned if our content was downloaded correctly. """ return True if self.path else False + + def __del__(self): + """ + Perform any house cleaning + """ + self.invalidate() diff --git a/lib/apprise/attachment/AttachBase.pyi b/lib/apprise/attachment/base.pyi similarity index 100% rename from lib/apprise/attachment/AttachBase.pyi rename to lib/apprise/attachment/base.pyi diff --git a/lib/apprise/attachment/AttachFile.py b/lib/apprise/attachment/file.py similarity index 95% rename from lib/apprise/attachment/AttachFile.py rename to lib/apprise/attachment/file.py index d3085555..88d8f6e1 100644 --- a/lib/apprise/attachment/AttachFile.py +++ b/lib/apprise/attachment/file.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -28,9 +28,9 @@ import re import os -from .AttachBase import AttachBase +from .base import AttachBase from ..common import ContentLocation -from ..AppriseLocale import gettext_lazy as _ +from ..locale import gettext_lazy as _ class AttachFile(AttachBase): @@ -78,7 +78,8 @@ class AttachFile(AttachBase): return 'file://{path}{params}'.format( path=self.quote(self.dirty_path), - params='?{}'.format(self.urlencode(params)) if params else '', + params='?{}'.format(self.urlencode(params, safe='/')) + if params else '', ) def download(self, **kwargs): diff --git a/lib/apprise/attachment/http.py b/lib/apprise/attachment/http.py new file mode 100644 index 00000000..870f7cc2 --- /dev/null +++ b/lib/apprise/attachment/http.py @@ -0,0 +1,375 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2024, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +import re +import os +import requests +import threading +from tempfile import NamedTemporaryFile +from .base import AttachBase +from ..common import ContentLocation +from ..url import PrivacyMode +from ..locale import gettext_lazy as _ + + +class AttachHTTP(AttachBase): + """ + A wrapper for HTTP based attachment sources + """ + + # The default descriptive name associated with the service + service_name = _('Web Based') + + # The default protocol + protocol = 'http' + + # The default secure protocol + secure_protocol = 'https' + + # The number of bytes in memory to read from the remote source at a time + chunk_size = 8192 + + # Web based requests are remote/external to our current location + location = ContentLocation.HOSTED + + # thread safe loading + _lock = threading.Lock() + + def __init__(self, headers=None, **kwargs): + """ + Initialize HTTP Object + + headers can be a dictionary of key/value pairs that you want to + additionally include as part of the server headers to post with + + """ + super().__init__(**kwargs) + + self.schema = 'https' if self.secure else 'http' + + self.fullpath = kwargs.get('fullpath') + if not isinstance(self.fullpath, str): + self.fullpath = '/' + + self.headers = {} + if headers: + # Store our extra headers + self.headers.update(headers) + + # Where our content is written to upon a call to download. + self._temp_file = None + + # Our Query String Dictionary; we use this to track arguments + # specified that aren't otherwise part of this class + self.qsd = {k: v for k, v in kwargs.get('qsd', {}).items() + if k not in self.template_args} + + return + + def download(self, **kwargs): + """ + Perform retrieval of the configuration based on the specified request + """ + + if self.location == ContentLocation.INACCESSIBLE: + # our content is inaccessible + return False + + # prepare header + headers = { + 'User-Agent': self.app_id, + } + + # Apply any/all header over-rides defined + headers.update(self.headers) + + auth = None + if self.user: + auth = (self.user, self.password) + + url = '%s://%s' % (self.schema, self.host) + if isinstance(self.port, int): + url += ':%d' % self.port + + url += self.fullpath + + # Where our request object will temporarily live. + r = None + + # Always call throttle before any remote server i/o is made + self.throttle() + + with self._lock: + if self.exists(retrieve_if_missing=False): + # Due to locking; it's possible a concurrent thread already + # handled the retrieval in which case we can safely move on + self.logger.trace( + 'HTTP Attachment %s already retrieved', + self._temp_file.name) + return True + + # Ensure any existing content set has been invalidated + self.invalidate() + + self.logger.debug( + 'HTTP Attachment Fetch URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate)) + + try: + # Make our request + with requests.get( + url, + headers=headers, + auth=auth, + params=self.qsd, + verify=self.verify_certificate, + timeout=self.request_timeout, + stream=True) as r: + + # Handle Errors + r.raise_for_status() + + # Get our file-size (if known) + try: + file_size = int(r.headers.get('Content-Length', '0')) + except (TypeError, ValueError): + # Handle edge case where Content-Length is a bad value + file_size = 0 + + # Perform a little Q/A on file limitations and restrictions + if self.max_file_size > 0 and \ + file_size > self.max_file_size: + + # The content retrieved is to large + self.logger.error( + 'HTTP response exceeds allowable maximum file ' + 'length ({}KB): {}'.format( + int(self.max_file_size / 1024), + self.url(privacy=True))) + + # Return False (signifying a failure) + return False + + # Detect config format based on mime if the format isn't + # already enforced + self.detected_mimetype = r.headers.get('Content-Type') + + d = r.headers.get('Content-Disposition', '') + result = re.search( + "filename=['\"]?(?P[^'\"]+)['\"]?", d, re.I) + if result: + self.detected_name = result.group('name').strip() + + # Create a temporary file to work with; delete must be set + # to False or it isn't compatible with Microsoft Windows + # instances. In lieu of this, __del__ will clean up the + # file for us. + self._temp_file = NamedTemporaryFile(delete=False) + + # Get our chunk size + chunk_size = self.chunk_size + + # Track all bytes written to disk + bytes_written = 0 + + # If we get here, we can now safely write our content to + # disk + for chunk in r.iter_content(chunk_size=chunk_size): + # filter out keep-alive chunks + if chunk: + self._temp_file.write(chunk) + bytes_written = self._temp_file.tell() + + # Prevent a case where Content-Length isn't + # provided. In this case we don't want to fetch + # beyond our limits + if self.max_file_size > 0: + if bytes_written > self.max_file_size: + # The content retrieved is to large + self.logger.error( + 'HTTP response exceeds allowable ' + 'maximum file length ' + '({}KB): {}'.format( + int(self.max_file_size / 1024), + self.url(privacy=True))) + + # Invalidate any variables previously set + self.invalidate() + + # Return False (signifying a failure) + return False + + elif bytes_written + chunk_size \ + > self.max_file_size: + # Adjust out next read to accomodate up to + # our limit +1. This will prevent us from + # reading to much into our memory buffer + self.max_file_size - bytes_written + 1 + + # Ensure our content is flushed to disk for post-processing + self._temp_file.flush() + + # Set our minimum requirements for a successful download() + # call + self.download_path = self._temp_file.name + if not self.detected_name: + self.detected_name = os.path.basename(self.fullpath) + + except requests.RequestException as e: + self.logger.error( + 'A Connection error occurred retrieving HTTP ' + 'configuration from %s.' % self.host) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Invalidate any variables previously set + self.invalidate() + + # Return False (signifying a failure) + return False + + except (IOError, OSError): + # IOError is present for backwards compatibility with Python + # versions older then 3.3. >= 3.3 throw OSError now. + + # Could not open and/or write the temporary file + self.logger.error( + 'Could not write attachment to disk: {}'.format( + self.url(privacy=True))) + + # Invalidate any variables previously set + self.invalidate() + + # Return False (signifying a failure) + return False + + # Return our success + return True + + def invalidate(self): + """ + Close our temporary file + """ + if self._temp_file: + self.logger.trace( + 'Attachment cleanup of %s', self._temp_file.name) + self._temp_file.close() + + try: + # Ensure our file is removed (if it exists) + os.unlink(self._temp_file.name) + + except OSError: + pass + + # Reset our temporary file to prevent from entering + # this block again + self._temp_file = None + + super().invalidate() + + def __del__(self): + """ + Tidy memory if open + """ + self.invalidate() + + 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) + + # Prepare our cache value + if self.cache is not None: + if isinstance(self.cache, bool) or not self.cache: + cache = 'yes' if self.cache else 'no' + else: + cache = int(self.cache) + + # Set our cache value + params['cache'] = cache + + if self._mimetype: + # A format was enforced + params['mime'] = self._mimetype + + if self._name: + # A name was enforced + params['name'] = self._name + + # Append our headers into our parameters + params.update({'+{}'.format(k): v for k, v in self.headers.items()}) + + # Apply any remaining entries to our URL + params.update(self.qsd) + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=self.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=self.quote(self.user, safe=''), + ) + + default_port = 443 if self.secure else 80 + + return '{schema}://{auth}{hostname}{port}{fullpath}?{params}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + auth=auth, + hostname=self.quote(self.host, safe=''), + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + fullpath=self.quote(self.fullpath, safe='/'), + params=self.urlencode(params, safe='/'), + ) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + results = AttachBase.parse_url(url, sanitize=False) + if not results: + # We're done early as we couldn't load the results + return results + + # Add our headers that the user can potentially over-ride if they wish + # to to our returned result set + results['headers'] = results['qsd-'] + results['headers'].update(results['qsd+']) + + return results diff --git a/lib/apprise/attachment/memory.py b/lib/apprise/attachment/memory.py new file mode 100644 index 00000000..94645f26 --- /dev/null +++ b/lib/apprise/attachment/memory.py @@ -0,0 +1,212 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2024, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +import re +import os +import io +from .base import AttachBase +from ..common import ContentLocation +from ..locale import gettext_lazy as _ +import uuid + + +class AttachMemory(AttachBase): + """ + A wrapper for Memory based attachment sources + """ + + # The default descriptive name associated with the service + service_name = _('Memory') + + # The default protocol + protocol = 'memory' + + # Content is local to the same location as the apprise instance + # being called (server-side) + location = ContentLocation.LOCAL + + def __init__(self, content=None, name=None, mimetype=None, + encoding='utf-8', **kwargs): + """ + Initialize Memory Based Attachment Object + + """ + # Create our BytesIO object + self._data = io.BytesIO() + + if content is None: + # Empty; do nothing + pass + + elif isinstance(content, str): + content = content.encode(encoding) + if mimetype is None: + mimetype = 'text/plain' + + if not name: + # Generate a unique filename + name = str(uuid.uuid4()) + '.txt' + + elif not isinstance(content, bytes): + raise TypeError( + 'Provided content for memory attachment is invalid') + + # Store our content + if content: + self._data.write(content) + + if mimetype is None: + # Default mimetype + mimetype = 'application/octet-stream' + + if not name: + # Generate a unique filename + name = str(uuid.uuid4()) + '.dat' + + # Initialize our base object + super().__init__(name=name, mimetype=mimetype, **kwargs) + + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'mime': self._mimetype, + } + + return 'memory://{name}?{params}'.format( + name=self.quote(self._name), + params=self.urlencode(params, safe='/') + ) + + def open(self, *args, **kwargs): + """ + return our memory object + """ + # Return our object + self._data.seek(0, 0) + return self._data + + def __enter__(self): + """ + support with clause + """ + # Return our object + self._data.seek(0, 0) + return self._data + + def download(self, **kwargs): + """ + Handle memory download() call + """ + + if self.location == ContentLocation.INACCESSIBLE: + # our content is inaccessible + return False + + if self.max_file_size > 0 and len(self) > self.max_file_size: + # The content to attach is to large + self.logger.error( + 'Content exceeds allowable maximum memory size ' + '({}KB): {}'.format( + int(self.max_file_size / 1024), self.url(privacy=True))) + + # Return False (signifying a failure) + return False + + return True + + def invalidate(self): + """ + Removes data + """ + self._data.truncate(0) + return + + def exists(self): + """ + over-ride exists() call + """ + size = len(self) + return True if self.location != ContentLocation.INACCESSIBLE \ + and size > 0 and ( + self.max_file_size <= 0 or + (self.max_file_size > 0 and size <= self.max_file_size)) \ + else False + + @staticmethod + def parse_url(url): + """ + Parses the URL so that we can handle all different file paths + and return it as our path object + + """ + + results = AttachBase.parse_url(url, verify_host=False) + if not results: + # We're done early; it's not a good URL + return results + + if 'name' not in results: + # Allow fall-back to be from URL + match = re.match(r'memory://(?P[^?]+)(\?.*)?', url, re.I) + if match: + # Store our filename only (ignore any defined paths) + results['name'] = \ + os.path.basename(AttachMemory.unquote(match.group('path'))) + return results + + @property + def path(self): + """ + return the filename + """ + if not self.exists(): + # we could not obtain our path + return None + + return self._name + + def __len__(self): + """ + Returns the size of he memory attachment + + """ + return self._data.getbuffer().nbytes + + def __bool__(self): + """ + Allows the Apprise object to be wrapped in an based 'if statement'. + True is returned if our content was downloaded correctly. + """ + + return self.exists() diff --git a/lib/apprise/common.py b/lib/apprise/common.py index 5e3a3567..b90a8537 100644 --- a/lib/apprise/common.py +++ b/lib/apprise/common.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -26,50 +26,6 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -# we mirror our base purely for the ability to reset everything; this -# is generally only used in testing and should not be used by developers -# It is also used as a means of preventing a module from being reloaded -# in the event it already exists -NOTIFY_MODULE_MAP = {} - -# Maintains a mapping of all of the Notification services -NOTIFY_SCHEMA_MAP = {} - -# This contains a mapping of all plugins dynamicaly loaded at runtime from -# external modules such as the @notify decorator -# -# The elements here will be additionally added to the NOTIFY_SCHEMA_MAP if -# there is no conflict otherwise. -# The structure looks like the following: -# Module path, e.g. /usr/share/apprise/plugins/my_notify_hook.py -# { -# 'path': path, -# -# 'notify': { -# 'schema': { -# 'name': 'Custom schema name', -# 'fn_name': 'name_of_function_decorator_was_found_on', -# 'url': 'schema://any/additional/info/found/on/url' -# 'plugin': -# }, -# 'schema2': { -# 'name': 'Custom schema name', -# 'fn_name': 'name_of_function_decorator_was_found_on', -# 'url': 'schema://any/additional/info/found/on/url' -# 'plugin': -# } -# } -# -# Note: that the inherits from -# NotifyBase -NOTIFY_CUSTOM_MODULE_MAP = {} - -# Maintains a mapping of all configuration schema's supported -CONFIG_SCHEMA_MAP = {} - -# Maintains a mapping of all attachment schema's supported -ATTACHMENT_SCHEMA_MAP = {} - class NotifyType: """ diff --git a/lib/apprise/config/__init__.py b/lib/apprise/config/__init__.py index 4b7e3fd7..24957e88 100644 --- a/lib/apprise/config/__init__.py +++ b/lib/apprise/config/__init__.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -26,84 +26,15 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -import re -from os import listdir -from os.path import dirname -from os.path import abspath -from ..logger import logger -from ..common import CONFIG_SCHEMA_MAP +# Used for testing +from .base import ConfigBase +from ..manager_config import ConfigurationManager -__all__ = [] +# Initalize our Config Manager Singleton +C_MGR = ConfigurationManager() - -# Load our Lookup Matrix -def __load_matrix(path=abspath(dirname(__file__)), name='apprise.config'): - """ - Dynamically load our schema map; this allows us to gracefully - skip over modules we simply don't have the dependencies for. - - """ - # Used for the detection of additional Configuration Services objects - # The .py extension is optional as we support loading directories too - module_re = re.compile(r'^(?PConfig[a-z0-9]+)(\.py)?$', re.I) - - for f in listdir(path): - match = module_re.match(f) - if not match: - # keep going - continue - - # Store our notification/plugin name: - plugin_name = match.group('name') - try: - module = __import__( - '{}.{}'.format(name, plugin_name), - globals(), locals(), - fromlist=[plugin_name]) - - except ImportError: - # No problem, we can't use this object - continue - - if not hasattr(module, plugin_name): - # Not a library we can load as it doesn't follow the simple rule - # that the class must bear the same name as the notification - # file itself. - continue - - # Get our plugin - plugin = getattr(module, plugin_name) - if not hasattr(plugin, 'app_id'): - # Filter out non-notification modules - continue - - elif plugin_name in __all__: - # we're already handling this object - continue - - # Add our module name to our __all__ - __all__.append(plugin_name) - - # Ensure we provide the class as the reference to this directory and - # not the module: - globals()[plugin_name] = plugin - - fn = getattr(plugin, 'schemas', None) - schemas = set([]) if not callable(fn) else fn(plugin) - - # map our schema to our plugin - for schema in schemas: - if schema in CONFIG_SCHEMA_MAP: - logger.error( - "Config schema ({}) mismatch detected - {} to {}" - .format(schema, CONFIG_SCHEMA_MAP[schema], plugin)) - continue - - # Assign plugin - CONFIG_SCHEMA_MAP[schema] = plugin - - return CONFIG_SCHEMA_MAP - - -# Dynamically build our schema base -__load_matrix() +__all__ = [ + # Reference + 'ConfigBase', + 'ConfigurationManager', +] diff --git a/lib/apprise/config/ConfigBase.py b/lib/apprise/config/base.py similarity index 96% rename from lib/apprise/config/ConfigBase.py rename to lib/apprise/config/base.py index 0da7a8be..01a9dbff 100644 --- a/lib/apprise/config/ConfigBase.py +++ b/lib/apprise/config/base.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -32,18 +32,26 @@ import time from .. import plugins from .. import common -from ..AppriseAsset import AppriseAsset -from ..URLBase import URLBase +from ..asset import AppriseAsset +from ..url import URLBase from ..utils import GET_SCHEMA_RE from ..utils import parse_list from ..utils import parse_bool from ..utils import parse_urls from ..utils import cwe312_url +from ..manager_config import ConfigurationManager +from ..manager_plugins import NotificationManager # Test whether token is valid or not VALID_TOKEN = re.compile( r'(?P[a-z0-9][a-z0-9_]+)', re.I) +# Grant access to our Notification Manager Singleton +N_MGR = NotificationManager() + +# Grant access to our Configuration Manager Singleton +C_MGR = ConfigurationManager() + class ConfigBase(URLBase): """ @@ -228,7 +236,7 @@ class ConfigBase(URLBase): schema = schema.group('schema').lower() # Some basic validation - if schema not in common.CONFIG_SCHEMA_MAP: + if schema not in C_MGR: ConfigBase.logger.warning( 'Unsupported include schema {}.'.format(schema)) continue @@ -239,7 +247,7 @@ class ConfigBase(URLBase): # Parse our url details of the server object as dictionary # containing all of the information parsed from our URL - results = common.CONFIG_SCHEMA_MAP[schema].parse_url(url) + results = C_MGR[schema].parse_url(url) if not results: # Failed to parse the server URL self.logger.warning( @@ -247,11 +255,10 @@ class ConfigBase(URLBase): continue # Handle cross inclusion based on allow_cross_includes rules - if (common.CONFIG_SCHEMA_MAP[schema].allow_cross_includes == + if (C_MGR[schema].allow_cross_includes == common.ContentIncludeMode.STRICT and schema not in self.schemas() - and not self.insecure_includes) or \ - common.CONFIG_SCHEMA_MAP[schema] \ + and not self.insecure_includes) or C_MGR[schema] \ .allow_cross_includes == \ common.ContentIncludeMode.NEVER: @@ -279,8 +286,7 @@ class ConfigBase(URLBase): try: # Attempt to create an instance of our plugin using the # parsed URL information - cfg_plugin = \ - common.CONFIG_SCHEMA_MAP[results['schema']](**results) + cfg_plugin = C_MGR[results['schema']](**results) except Exception as e: # the arguments are invalid or can not be used. @@ -392,7 +398,11 @@ class ConfigBase(URLBase): # Track our groups groups.add(tag) - # Store what we know is worth keping + # Store what we know is worth keeping + if tag not in group_tags: # pragma: no cover + # handle cases where the tag doesn't exist + group_tags[tag] = set() + results |= group_tags[tag] - tag_groups # Get simple tag assignments @@ -753,8 +763,7 @@ class ConfigBase(URLBase): try: # Attempt to create an instance of our plugin using the # parsed URL information - plugin = common.NOTIFY_SCHEMA_MAP[ - results['schema']](**results) + plugin = N_MGR[results['schema']](**results) # Create log entry of loaded URL ConfigBase.logger.debug( @@ -807,8 +816,7 @@ class ConfigBase(URLBase): # Create a copy of our dictionary tokens = tokens.copy() - for kw, meta in common.NOTIFY_SCHEMA_MAP[schema]\ - .template_kwargs.items(): + for kw, meta in N_MGR[schema].template_kwargs.items(): # Determine our prefix: prefix = meta.get('prefix', '+') @@ -851,8 +859,7 @@ class ConfigBase(URLBase): # # This function here allows these mappings to take place within the # YAML file as independant arguments. - class_templates = \ - plugins.details(common.NOTIFY_SCHEMA_MAP[schema]) + class_templates = plugins.details(N_MGR[schema]) for key in list(tokens.keys()): diff --git a/lib/apprise/config/ConfigBase.pyi b/lib/apprise/config/base.pyi similarity index 100% rename from lib/apprise/config/ConfigBase.pyi rename to lib/apprise/config/base.pyi diff --git a/lib/apprise/config/ConfigFile.py b/lib/apprise/config/file.py similarity index 97% rename from lib/apprise/config/ConfigFile.py rename to lib/apprise/config/file.py index a0b9bf69..52ff8eba 100644 --- a/lib/apprise/config/ConfigFile.py +++ b/lib/apprise/config/file.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -28,10 +28,10 @@ import re import os -from .ConfigBase import ConfigBase +from .base import ConfigBase from ..common import ConfigFormat from ..common import ContentIncludeMode -from ..AppriseLocale import gettext_lazy as _ +from ..locale import gettext_lazy as _ class ConfigFile(ConfigBase): diff --git a/lib/apprise/config/ConfigHTTP.py b/lib/apprise/config/http.py similarity index 98% rename from lib/apprise/config/ConfigHTTP.py rename to lib/apprise/config/http.py index 82cb1f63..5b9e7375 100644 --- a/lib/apprise/config/ConfigHTTP.py +++ b/lib/apprise/config/http.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -28,11 +28,11 @@ import re import requests -from .ConfigBase import ConfigBase +from .base import ConfigBase from ..common import ConfigFormat from ..common import ContentIncludeMode -from ..URLBase import PrivacyMode -from ..AppriseLocale import gettext_lazy as _ +from ..url import PrivacyMode +from ..locale import gettext_lazy as _ # Support TEXT formats # text/plain diff --git a/lib/apprise/config/ConfigMemory.py b/lib/apprise/config/memory.py similarity index 95% rename from lib/apprise/config/ConfigMemory.py rename to lib/apprise/config/memory.py index 110e04a3..181d7623 100644 --- a/lib/apprise/config/ConfigMemory.py +++ b/lib/apprise/config/memory.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -26,8 +26,8 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -from .ConfigBase import ConfigBase -from ..AppriseLocale import gettext_lazy as _ +from .base import ConfigBase +from ..locale import gettext_lazy as _ class ConfigMemory(ConfigBase): diff --git a/lib/apprise/conversion.py b/lib/apprise/conversion.py index ffa3e3a0..0943d382 100644 --- a/lib/apprise/conversion.py +++ b/lib/apprise/conversion.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -29,7 +29,7 @@ import re #from markdown import markdown from .common import NotifyFormat -from .URLBase import URLBase +from .url import URLBase from html.parser import HTMLParser @@ -58,8 +58,8 @@ def convert_between(from_format, to_format, content): # """ # Converts specified content from markdown to HTML. # """ - -# return markdown(content) +# return markdown(content, extensions=[ +# 'markdown.extensions.nl2br', 'markdown.extensions.tables']) def text_to_html(content): diff --git a/lib/apprise/decorators/__init__.py b/lib/apprise/decorators/__init__.py index 5b089bbf..db9a15a0 100644 --- a/lib/apprise/decorators/__init__.py +++ b/lib/apprise/decorators/__init__.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: diff --git a/lib/apprise/decorators/CustomNotifyPlugin.py b/lib/apprise/decorators/base.py similarity index 76% rename from lib/apprise/decorators/CustomNotifyPlugin.py rename to lib/apprise/decorators/base.py index 5ccfded5..2661db0a 100644 --- a/lib/apprise/decorators/CustomNotifyPlugin.py +++ b/lib/apprise/decorators/base.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -27,7 +27,8 @@ # POSSIBILITY OF SUCH DAMAGE.USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -from ..plugins.NotifyBase import NotifyBase +from ..plugins.base import NotifyBase +from ..manager_plugins import NotificationManager from ..utils import URL_DETAILS_RE from ..utils import parse_url from ..utils import url_assembly @@ -36,6 +37,9 @@ from .. import common from ..logger import logger import inspect +# Grant access to our Notification Manager Singleton +N_MGR = NotificationManager() + class CustomNotifyPlugin(NotifyBase): """ @@ -51,6 +55,9 @@ class CustomNotifyPlugin(NotifyBase): # should be treated differently. category = 'custom' + # Support Attachments + attachment_support = True + # Define object templates templates = ( '{schema}://', @@ -91,17 +98,17 @@ class CustomNotifyPlugin(NotifyBase): logger.warning(msg) return None - # Acquire our plugin name - plugin_name = re_match.group('schema').lower() + # Acquire our schema + schema = re_match.group('schema').lower() if not re_match.group('base'): - url = '{}://'.format(plugin_name) + url = '{}://'.format(schema) # Keep a default set of arguments to apply to all called references base_args = parse_url( - url, default_schema=plugin_name, verify_host=False, simple=True) + url, default_schema=schema, verify_host=False, simple=True) - if plugin_name in common.NOTIFY_SCHEMA_MAP: + if schema in N_MGR: # we're already handling this object msg = 'The schema ({}) is already defined and could not be ' \ 'loaded from custom notify function {}.' \ @@ -117,10 +124,10 @@ class CustomNotifyPlugin(NotifyBase): # Our Service Name service_name = name if isinstance(name, str) \ - and name else 'Custom - {}'.format(plugin_name) + and name else 'Custom - {}'.format(schema) # Store our matched schema - secure_protocol = plugin_name + secure_protocol = schema requirements = { # Define our required packaging in order to work @@ -143,6 +150,10 @@ class CustomNotifyPlugin(NotifyBase): self._default_args = {} + # Some variables do not need to be set + if 'secure' in kwargs: + del kwargs['secure'] + # Apply our updates based on what was parsed dict_full_update(self._default_args, self._base_args) dict_full_update(self._default_args, kwargs) @@ -181,51 +192,26 @@ class CustomNotifyPlugin(NotifyBase): # Unhandled Exception self.logger.warning( 'An exception occured sending a %s notification.', - common. - NOTIFY_SCHEMA_MAP[self.secure_protocol].service_name) + N_MGR[self.secure_protocol].service_name) self.logger.debug( '%s Exception: %s', - common.NOTIFY_SCHEMA_MAP[self.secure_protocol], str(e)) + N_MGR[self.secure_protocol], str(e)) return False if response: self.logger.info( 'Sent %s notification.', - common. - NOTIFY_SCHEMA_MAP[self.secure_protocol].service_name) + N_MGR[self.secure_protocol].service_name) else: self.logger.warning( 'Failed to send %s notification.', - common. - NOTIFY_SCHEMA_MAP[self.secure_protocol].service_name) + N_MGR[self.secure_protocol].service_name) return response # Store our plugin into our core map file - common.NOTIFY_SCHEMA_MAP[plugin_name] = CustomNotifyPluginWrapper - - # Update our custom plugin map - module_pyname = str(send_func.__module__) - if module_pyname not in common.NOTIFY_CUSTOM_MODULE_MAP: - # Support non-dynamic includes as well... - common.NOTIFY_CUSTOM_MODULE_MAP[module_pyname] = { - 'path': inspect.getfile(send_func), - - # Initialize our template - 'notify': {}, - } - - common.\ - NOTIFY_CUSTOM_MODULE_MAP[module_pyname]['notify'][plugin_name] = { - # Our Serivice Description (for API and CLI --details view) - 'name': CustomNotifyPluginWrapper.service_name, - # The name of the send function the @notify decorator wrapped - 'fn_name': send_func.__name__, - # The URL that was provided in the @notify decorator call - # associated with the 'on=' - 'url': url, - # The Initialized Plugin that was generated based on the above - # parameters - 'plugin': CustomNotifyPluginWrapper} - - # return our plugin - return common.NOTIFY_SCHEMA_MAP[plugin_name] + return N_MGR.add( + plugin=CustomNotifyPluginWrapper, + schemas=schema, + send_func=send_func, + url=url, + ) diff --git a/lib/apprise/decorators/notify.py b/lib/apprise/decorators/notify.py index 07b4ceb1..892c3adf 100644 --- a/lib/apprise/decorators/notify.py +++ b/lib/apprise/decorators/notify.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -26,7 +26,7 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -from .CustomNotifyPlugin import CustomNotifyPlugin +from .base import CustomNotifyPlugin def notify(on, name=None): diff --git a/lib/apprise/emojis.py b/lib/apprise/emojis.py new file mode 100644 index 00000000..d8a82481 --- /dev/null +++ b/lib/apprise/emojis.py @@ -0,0 +1,2273 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2024, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +import re +import time +from .logger import logger + +# All Emoji's are wrapped in this character +DELIM = ':' + +# the map simply contains the emoji that should be mapped to the regular +# expression it should be swapped on. +# This list was based on: https://github.com/ikatyang/emoji-cheat-sheet +EMOJI_MAP = { + # + # Face Smiling + # + DELIM + r'grinning' + DELIM: '😄', + DELIM + r'smile' + DELIM: '😄', + DELIM + r'(laughing|satisfied)' + DELIM: '😆', + DELIM + r'rofl' + DELIM: '🤣', + DELIM + r'slightly_smiling_face' + DELIM: '🙂', + DELIM + r'wink' + DELIM: '😉', + DELIM + r'innocent' + DELIM: '😇', + DELIM + r'smiley' + DELIM: '😃', + DELIM + r'grin' + DELIM: '😃', + DELIM + r'sweat_smile' + DELIM: '😅', + DELIM + r'joy' + DELIM: '😂', + DELIM + r'upside_down_face' + DELIM: '🙃', + DELIM + r'blush' + DELIM: '😊', + + # + # Face Affection + # + DELIM + r'smiling_face_with_three_hearts' + DELIM: '🥰', + DELIM + r'star_struck' + DELIM: '🤩', + DELIM + r'kissing' + DELIM: '😗', + DELIM + r'kissing_closed_eyes' + DELIM: '😚', + DELIM + r'smiling_face_with_tear' + DELIM: '🥲', + DELIM + r'heart_eyes' + DELIM: '😍', + DELIM + r'kissing_heart' + DELIM: '😘', + DELIM + r'relaxed' + DELIM: '☺️', + DELIM + r'kissing_smiling_eyes' + DELIM: '😙', + + # + # Face Tongue + # + DELIM + r'yum' + DELIM: '😋', + DELIM + r'stuck_out_tongue_winking_eye' + DELIM: '😜', + DELIM + r'stuck_out_tongue_closed_eyes' + DELIM: '😝', + DELIM + r'stuck_out_tongue' + DELIM: '😛', + DELIM + r'zany_face' + DELIM: '🤪', + DELIM + r'money_mouth_face' + DELIM: '🤑', + + # + # Face Hand + # + DELIM + r'hugs' + DELIM: '🤗', + DELIM + r'shushing_face' + DELIM: '🤫', + DELIM + r'hand_over_mouth' + DELIM: '🤭', + DELIM + r'thinking' + DELIM: '🤔', + + # + # Face Neutral Skeptical + # + DELIM + r'zipper_mouth_face' + DELIM: '🤐', + DELIM + r'neutral_face' + DELIM: '😐', + DELIM + r'no_mouth' + DELIM: '😶', + DELIM + r'smirk' + DELIM: '😏', + DELIM + r'roll_eyes' + DELIM: '🙄', + DELIM + r'face_exhaling' + DELIM: '😮‍💨', + DELIM + r'raised_eyebrow' + DELIM: '🤨', + DELIM + r'expressionless' + DELIM: '😑', + DELIM + r'face_in_clouds' + DELIM: '😶‍🌫️', + DELIM + r'unamused' + DELIM: '😒', + DELIM + r'grimacing' + DELIM: '😬', + DELIM + r'lying_face' + DELIM: '🤥', + + # + # Face Sleepy + # + DELIM + r'relieved' + DELIM: '😌', + DELIM + r'sleepy' + DELIM: '😪', + DELIM + r'sleeping' + DELIM: '😴', + DELIM + r'pensive' + DELIM: '😔', + DELIM + r'drooling_face' + DELIM: '🤤', + + # + # Face Unwell + # + DELIM + r'mask' + DELIM: '😷', + DELIM + r'face_with_head_bandage' + DELIM: '🤕', + DELIM + r'vomiting_face' + DELIM: '🤮', + DELIM + r'hot_face' + DELIM: '🥵', + DELIM + r'woozy_face' + DELIM: '🥴', + DELIM + r'face_with_spiral_eyes' + DELIM: '😵‍💫', + DELIM + r'face_with_thermometer' + DELIM: '🤒', + DELIM + r'nauseated_face' + DELIM: '🤢', + DELIM + r'sneezing_face' + DELIM: '🤧', + DELIM + r'cold_face' + DELIM: '🥶', + DELIM + r'dizzy_face' + DELIM: '😵', + DELIM + r'exploding_head' + DELIM: '🤯', + + # + # Face Hat + # + DELIM + r'cowboy_hat_face' + DELIM: '🤠', + DELIM + r'disguised_face' + DELIM: '🥸', + DELIM + r'partying_face' + DELIM: '🥳', + + # + # Face Glasses + # + DELIM + r'sunglasses' + DELIM: '😎', + DELIM + r'monocle_face' + DELIM: '🧐', + DELIM + r'nerd_face' + DELIM: '🤓', + + # + # Face Concerned + # + DELIM + r'confused' + DELIM: '😕', + DELIM + r'slightly_frowning_face' + DELIM: '🙁', + DELIM + r'open_mouth' + DELIM: '😮', + DELIM + r'astonished' + DELIM: '😲', + DELIM + r'pleading_face' + DELIM: '🥺', + DELIM + r'anguished' + DELIM: '😧', + DELIM + r'cold_sweat' + DELIM: '😰', + DELIM + r'cry' + DELIM: '😢', + DELIM + r'scream' + DELIM: '😱', + DELIM + r'persevere' + DELIM: '😣', + DELIM + r'sweat' + DELIM: '😓', + DELIM + r'tired_face' + DELIM: '😫', + DELIM + r'worried' + DELIM: '😟', + DELIM + r'frowning_face' + DELIM: '☹️', + DELIM + r'hushed' + DELIM: '😯', + DELIM + r'flushed' + DELIM: '😳', + DELIM + r'frowning' + DELIM: '😦', + DELIM + r'fearful' + DELIM: '😨', + DELIM + r'disappointed_relieved' + DELIM: '😥', + DELIM + r'sob' + DELIM: '😭', + DELIM + r'confounded' + DELIM: '😖', + DELIM + r'disappointed' + DELIM: '😞', + DELIM + r'weary' + DELIM: '😩', + DELIM + r'yawning_face' + DELIM: '🥱', + + # + # Face Negative + # + DELIM + r'triumph' + DELIM: '😤', + DELIM + r'angry' + DELIM: '😠', + DELIM + r'smiling_imp' + DELIM: '😈', + DELIM + r'skull' + DELIM: '💀', + DELIM + r'(pout|rage)' + DELIM: '😡', + DELIM + r'cursing_face' + DELIM: '🤬', + DELIM + r'imp' + DELIM: '👿', + DELIM + r'skull_and_crossbones' + DELIM: '☠️', + + # + # Face Costume + # + DELIM + r'(hankey|poop|shit)' + DELIM: '💩', + DELIM + r'japanese_ogre' + DELIM: '👹', + DELIM + r'ghost' + DELIM: '👻', + DELIM + r'space_invader' + DELIM: '👾', + DELIM + r'clown_face' + DELIM: '🤡', + DELIM + r'japanese_goblin' + DELIM: '👺', + DELIM + r'alien' + DELIM: '👽', + DELIM + r'robot' + DELIM: '🤖', + + # + # Cat Face + # + DELIM + r'smiley_cat' + DELIM: '😺', + DELIM + r'joy_cat' + DELIM: '😹', + DELIM + r'smirk_cat' + DELIM: '😼', + DELIM + r'scream_cat' + DELIM: '🙀', + DELIM + r'pouting_cat' + DELIM: '😾', + DELIM + r'smile_cat' + DELIM: '😸', + DELIM + r'heart_eyes_cat' + DELIM: '😻', + DELIM + r'kissing_cat' + DELIM: '😽', + DELIM + r'crying_cat_face' + DELIM: '😿', + + # + # Monkey Face + # + DELIM + r'see_no_evil' + DELIM: '🙈', + DELIM + r'speak_no_evil' + DELIM: '🙊', + DELIM + r'hear_no_evil' + DELIM: '🙉', + + # + # Heart + # + DELIM + r'love_letter' + DELIM: '💌', + DELIM + r'gift_heart' + DELIM: '💝', + DELIM + r'heartpulse' + DELIM: '💗', + DELIM + r'revolving_hearts' + DELIM: '💞', + DELIM + r'heart_decoration' + DELIM: '💟', + DELIM + r'broken_heart' + DELIM: '💔', + DELIM + r'mending_heart' + DELIM: '❤️‍🩹', + DELIM + r'orange_heart' + DELIM: '🧡', + DELIM + r'green_heart' + DELIM: '💚', + DELIM + r'purple_heart' + DELIM: '💜', + DELIM + r'black_heart' + DELIM: '🖤', + DELIM + r'cupid' + DELIM: '💘', + DELIM + r'sparkling_heart' + DELIM: '💖', + DELIM + r'heartbeat' + DELIM: '💓', + DELIM + r'two_hearts' + DELIM: '💕', + DELIM + r'heavy_heart_exclamation' + DELIM: '❣️', + DELIM + r'heart_on_fire' + DELIM: '❤️‍🔥', + DELIM + r'heart' + DELIM: '❤️', + DELIM + r'yellow_heart' + DELIM: '💛', + DELIM + r'blue_heart' + DELIM: '💙', + DELIM + r'brown_heart' + DELIM: '🤎', + DELIM + r'white_heart' + DELIM: '🤍', + + # + # Emotion + # + DELIM + r'kiss' + DELIM: '💋', + DELIM + r'anger' + DELIM: '💢', + DELIM + r'dizzy' + DELIM: '💫', + DELIM + r'dash' + DELIM: '💨', + DELIM + r'speech_balloon' + DELIM: '💬', + DELIM + r'left_speech_bubble' + DELIM: '🗨️', + DELIM + r'thought_balloon' + DELIM: '💭', + DELIM + r'100' + DELIM: '💯', + DELIM + r'(boom|collision)' + DELIM: '💥', + DELIM + r'sweat_drops' + DELIM: '💦', + DELIM + r'hole' + DELIM: '🕳️', + DELIM + r'eye_speech_bubble' + DELIM: '👁️‍🗨️', + DELIM + r'right_anger_bubble' + DELIM: '🗯️', + DELIM + r'zzz' + DELIM: '💤', + + # + # Hand Fingers Open + # + DELIM + r'wave' + DELIM: '👋', + DELIM + r'raised_hand_with_fingers_splayed' + DELIM: '🖐️', + DELIM + r'vulcan_salute' + DELIM: '🖖', + DELIM + r'raised_back_of_hand' + DELIM: '🤚', + DELIM + r'(raised_)?hand' + DELIM: '✋', + + # + # Hand Fingers Partial + # + DELIM + r'ok_hand' + DELIM: '👌', + DELIM + r'pinched_fingers' + DELIM: '🤌', + DELIM + r'pinching_hand' + DELIM: '🤏', + DELIM + r'v' + DELIM: '✌️', + DELIM + r'crossed_fingers' + DELIM: '🤞', + DELIM + r'love_you_gesture' + DELIM: '🤟', + DELIM + r'metal' + DELIM: '🤘', + DELIM + r'call_me_hand' + DELIM: '🤙', + + # + # Hand Single Finger + # + DELIM + r'point_left' + DELIM: '👈', + DELIM + r'point_right' + DELIM: '👉', + DELIM + r'point_up_2' + DELIM: '👆', + DELIM + r'(fu|middle_finger)' + DELIM: '🖕', + DELIM + r'point_down' + DELIM: '👇', + DELIM + r'point_up' + DELIM: '☝️', + + # + # Hand Fingers Closed + # + DELIM + r'(\+1|thumbsup)' + DELIM: '👍', + DELIM + r'(-1|thumbsdown)' + DELIM: '👎', + DELIM + r'fist' + DELIM: '✊', + DELIM + r'(fist_(raised|oncoming)|(face)?punch)' + DELIM: '👊', + DELIM + r'fist_left' + DELIM: '🤛', + DELIM + r'fist_right' + DELIM: '🤜', + + # + # Hands + # + DELIM + r'clap' + DELIM: '👏', + DELIM + r'raised_hands' + DELIM: '🙌', + DELIM + r'open_hands' + DELIM: '👐', + DELIM + r'palms_up_together' + DELIM: '🤲', + DELIM + r'handshake' + DELIM: '🤝', + DELIM + r'pray' + DELIM: '🙏', + + # + # Hand Prop + # + DELIM + r'writing_hand' + DELIM: '✍️', + DELIM + r'nail_care' + DELIM: '💅', + DELIM + r'selfie' + DELIM: '🤳', + + # + # Body Parts + # + DELIM + r'muscle' + DELIM: '💪', + DELIM + r'mechanical_arm' + DELIM: '🦾', + DELIM + r'mechanical_leg' + DELIM: '🦿', + DELIM + r'leg' + DELIM: '🦵', + DELIM + r'foot' + DELIM: '🦶', + DELIM + r'ear' + DELIM: '👂', + DELIM + r'ear_with_hearing_aid' + DELIM: '🦻', + DELIM + r'nose' + DELIM: '👃', + DELIM + r'brain' + DELIM: '🧠', + DELIM + r'anatomical_heart' + DELIM: '🫀', + DELIM + r'lungs' + DELIM: '🫁', + DELIM + r'tooth' + DELIM: '🦷', + DELIM + r'bone' + DELIM: '🦴', + DELIM + r'eyes' + DELIM: '👀', + DELIM + r'eye' + DELIM: '👁️', + DELIM + r'tongue' + DELIM: '👅', + DELIM + r'lips' + DELIM: '👄', + + # + # Person + # + DELIM + r'baby' + DELIM: '👶', + DELIM + r'child' + DELIM: '🧒', + DELIM + r'boy' + DELIM: '👦', + DELIM + r'girl' + DELIM: '👧', + DELIM + r'adult' + DELIM: '🧑', + DELIM + r'blond_haired_person' + DELIM: '👱', + DELIM + r'man' + DELIM: '👨', + DELIM + r'bearded_person' + DELIM: '🧔', + DELIM + r'man_beard' + DELIM: '🧔‍♂️', + DELIM + r'woman_beard' + DELIM: '🧔‍♀️', + DELIM + r'red_haired_man' + DELIM: '👨‍🦰', + DELIM + r'curly_haired_man' + DELIM: '👨‍🦱', + DELIM + r'white_haired_man' + DELIM: '👨‍🦳', + DELIM + r'bald_man' + DELIM: '👨‍🦲', + DELIM + r'woman' + DELIM: '👩', + DELIM + r'red_haired_woman' + DELIM: '👩‍🦰', + DELIM + r'person_red_hair' + DELIM: '🧑‍🦰', + DELIM + r'curly_haired_woman' + DELIM: '👩‍🦱', + DELIM + r'person_curly_hair' + DELIM: '🧑‍🦱', + DELIM + r'white_haired_woman' + DELIM: '👩‍🦳', + DELIM + r'person_white_hair' + DELIM: '🧑‍🦳', + DELIM + r'bald_woman' + DELIM: '👩‍🦲', + DELIM + r'person_bald' + DELIM: '🧑‍🦲', + DELIM + r'blond_(haired_)?woman' + DELIM: '👱‍♀️', + DELIM + r'blond_haired_man' + DELIM: '👱‍♂️', + DELIM + r'older_adult' + DELIM: '🧓', + DELIM + r'older_man' + DELIM: '👴', + DELIM + r'older_woman' + DELIM: '👵', + + # + # Person Gesture + # + DELIM + r'frowning_person' + DELIM: '🙍', + DELIM + r'frowning_man' + DELIM: '🙍‍♂️', + DELIM + r'frowning_woman' + DELIM: '🙍‍♀️', + DELIM + r'pouting_face' + DELIM: '🙎', + DELIM + r'pouting_man' + DELIM: '🙎‍♂️', + DELIM + r'pouting_woman' + DELIM: '🙎‍♀️', + DELIM + r'no_good' + DELIM: '🙅', + DELIM + r'(ng|no_good)_man' + DELIM: '🙅‍♂️', + DELIM + r'(ng_woman|no_good_woman)' + DELIM: '🙅‍♀️', + DELIM + r'ok_person' + DELIM: '🙆', + DELIM + r'ok_man' + DELIM: '🙆‍♂️', + DELIM + r'ok_woman' + DELIM: '🙆‍♀️', + DELIM + r'(information_desk|tipping_hand_)person' + DELIM: '💁', + DELIM + r'(sassy_man|tipping_hand_man)' + DELIM: '💁‍♂️', + DELIM + r'(sassy_woman|tipping_hand_woman)' + DELIM: '💁‍♀️', + DELIM + r'raising_hand' + DELIM: '🙋', + DELIM + r'raising_hand_man' + DELIM: '🙋‍♂️', + DELIM + r'raising_hand_woman' + DELIM: '🙋‍♀️', + DELIM + r'deaf_person' + DELIM: '🧏', + DELIM + r'deaf_man' + DELIM: '🧏‍♂️', + DELIM + r'deaf_woman' + DELIM: '🧏‍♀️', + DELIM + r'bow' + DELIM: '🙇', + DELIM + r'bowing_man' + DELIM: '🙇‍♂️', + DELIM + r'bowing_woman' + DELIM: '🙇‍♀️', + DELIM + r'facepalm' + DELIM: '🤦', + DELIM + r'man_facepalming' + DELIM: '🤦‍♂️', + DELIM + r'woman_facepalming' + DELIM: '🤦‍♀️', + DELIM + r'shrug' + DELIM: '🤷', + DELIM + r'man_shrugging' + DELIM: '🤷‍♂️', + DELIM + r'woman_shrugging' + DELIM: '🤷‍♀️', + + # + # Person Role + # + DELIM + r'health_worker' + DELIM: '🧑‍⚕️', + DELIM + r'man_health_worker' + DELIM: '👨‍⚕️', + DELIM + r'woman_health_worker' + DELIM: '👩‍⚕️', + DELIM + r'student' + DELIM: '🧑‍🎓', + DELIM + r'man_student' + DELIM: '👨‍🎓', + DELIM + r'woman_student' + DELIM: '👩‍🎓', + DELIM + r'teacher' + DELIM: '🧑‍🏫', + DELIM + r'man_teacher' + DELIM: '👨‍🏫', + DELIM + r'woman_teacher' + DELIM: '👩‍🏫', + DELIM + r'judge' + DELIM: '🧑‍⚖️', + DELIM + r'man_judge' + DELIM: '👨‍⚖️', + DELIM + r'woman_judge' + DELIM: '👩‍⚖️', + DELIM + r'farmer' + DELIM: '🧑‍🌾', + DELIM + r'man_farmer' + DELIM: '👨‍🌾', + DELIM + r'woman_farmer' + DELIM: '👩‍🌾', + DELIM + r'cook' + DELIM: '🧑‍🍳', + DELIM + r'man_cook' + DELIM: '👨‍🍳', + DELIM + r'woman_cook' + DELIM: '👩‍🍳', + DELIM + r'mechanic' + DELIM: '🧑‍🔧', + DELIM + r'man_mechanic' + DELIM: '👨‍🔧', + DELIM + r'woman_mechanic' + DELIM: '👩‍🔧', + DELIM + r'factory_worker' + DELIM: '🧑‍🏭', + DELIM + r'man_factory_worker' + DELIM: '👨‍🏭', + DELIM + r'woman_factory_worker' + DELIM: '👩‍🏭', + DELIM + r'office_worker' + DELIM: '🧑‍💼', + DELIM + r'man_office_worker' + DELIM: '👨‍💼', + DELIM + r'woman_office_worker' + DELIM: '👩‍💼', + DELIM + r'scientist' + DELIM: '🧑‍🔬', + DELIM + r'man_scientist' + DELIM: '👨‍🔬', + DELIM + r'woman_scientist' + DELIM: '👩‍🔬', + DELIM + r'technologist' + DELIM: '🧑‍💻', + DELIM + r'man_technologist' + DELIM: '👨‍💻', + DELIM + r'woman_technologist' + DELIM: '👩‍💻', + DELIM + r'singer' + DELIM: '🧑‍🎤', + DELIM + r'man_singer' + DELIM: '👨‍🎤', + DELIM + r'woman_singer' + DELIM: '👩‍🎤', + DELIM + r'artist' + DELIM: '🧑‍🎨', + DELIM + r'man_artist' + DELIM: '👨‍🎨', + DELIM + r'woman_artist' + DELIM: '👩‍🎨', + DELIM + r'pilot' + DELIM: '🧑‍✈️', + DELIM + r'man_pilot' + DELIM: '👨‍✈️', + DELIM + r'woman_pilot' + DELIM: '👩‍✈️', + DELIM + r'astronaut' + DELIM: '🧑‍🚀', + DELIM + r'man_astronaut' + DELIM: '👨‍🚀', + DELIM + r'woman_astronaut' + DELIM: '👩‍🚀', + DELIM + r'firefighter' + DELIM: '🧑‍🚒', + DELIM + r'man_firefighter' + DELIM: '👨‍🚒', + DELIM + r'woman_firefighter' + DELIM: '👩‍🚒', + DELIM + r'cop' + DELIM: '👮', + DELIM + r'police(_officer|man)' + DELIM: '👮‍♂️', + DELIM + r'policewoman' + DELIM: '👮‍♀️', + DELIM + r'detective' + DELIM: '🕵️', + DELIM + r'male_detective' + DELIM: '🕵️‍♂️', + DELIM + r'female_detective' + DELIM: '🕵️‍♀️', + DELIM + r'guard' + DELIM: '💂', + DELIM + r'guardsman' + DELIM: '💂‍♂️', + DELIM + r'guardswoman' + DELIM: '💂‍♀️', + DELIM + r'ninja' + DELIM: '🥷', + DELIM + r'construction_worker' + DELIM: '👷', + DELIM + r'construction_worker_man' + DELIM: '👷‍♂️', + DELIM + r'construction_worker_woman' + DELIM: '👷‍♀️', + DELIM + r'prince' + DELIM: '🤴', + DELIM + r'princess' + DELIM: '👸', + DELIM + r'person_with_turban' + DELIM: '👳', + DELIM + r'man_with_turban' + DELIM: '👳‍♂️', + DELIM + r'woman_with_turban' + DELIM: '👳‍♀️', + DELIM + r'man_with_gua_pi_mao' + DELIM: '👲', + DELIM + r'woman_with_headscarf' + DELIM: '🧕', + DELIM + r'person_in_tuxedo' + DELIM: '🤵', + DELIM + r'man_in_tuxedo' + DELIM: '🤵‍♂️', + DELIM + r'woman_in_tuxedo' + DELIM: '🤵‍♀️', + DELIM + r'person_with_veil' + DELIM: '👰', + DELIM + r'man_with_veil' + DELIM: '👰‍♂️', + DELIM + r'(bride|woman)_with_veil' + DELIM: '👰‍♀️', + DELIM + r'pregnant_woman' + DELIM: '🤰', + DELIM + r'breast_feeding' + DELIM: '🤱', + DELIM + r'woman_feeding_baby' + DELIM: '👩‍🍼', + DELIM + r'man_feeding_baby' + DELIM: '👨‍🍼', + DELIM + r'person_feeding_baby' + DELIM: '🧑‍🍼', + + # + # Person Fantasy + # + DELIM + r'angel' + DELIM: '👼', + DELIM + r'santa' + DELIM: '🎅', + DELIM + r'mrs_claus' + DELIM: '🤶', + DELIM + r'mx_claus' + DELIM: '🧑‍🎄', + DELIM + r'superhero' + DELIM: '🦸', + DELIM + r'superhero_man' + DELIM: '🦸‍♂️', + DELIM + r'superhero_woman' + DELIM: '🦸‍♀️', + DELIM + r'supervillain' + DELIM: '🦹', + DELIM + r'supervillain_man' + DELIM: '🦹‍♂️', + DELIM + r'supervillain_woman' + DELIM: '🦹‍♀️', + DELIM + r'mage' + DELIM: '🧙', + DELIM + r'mage_man' + DELIM: '🧙‍♂️', + DELIM + r'mage_woman' + DELIM: '🧙‍♀️', + DELIM + r'fairy' + DELIM: '🧚', + DELIM + r'fairy_man' + DELIM: '🧚‍♂️', + DELIM + r'fairy_woman' + DELIM: '🧚‍♀️', + DELIM + r'vampire' + DELIM: '🧛', + DELIM + r'vampire_man' + DELIM: '🧛‍♂️', + DELIM + r'vampire_woman' + DELIM: '🧛‍♀️', + DELIM + r'merperson' + DELIM: '🧜', + DELIM + r'merman' + DELIM: '🧜‍♂️', + DELIM + r'mermaid' + DELIM: '🧜‍♀️', + DELIM + r'elf' + DELIM: '🧝', + DELIM + r'elf_man' + DELIM: '🧝‍♂️', + DELIM + r'elf_woman' + DELIM: '🧝‍♀️', + DELIM + r'genie' + DELIM: '🧞', + DELIM + r'genie_man' + DELIM: '🧞‍♂️', + DELIM + r'genie_woman' + DELIM: '🧞‍♀️', + DELIM + r'zombie' + DELIM: '🧟', + DELIM + r'zombie_man' + DELIM: '🧟‍♂️', + DELIM + r'zombie_woman' + DELIM: '🧟‍♀️', + + # + # Person Activity + # + DELIM + r'massage' + DELIM: '💆', + DELIM + r'massage_man' + DELIM: '💆‍♂️', + DELIM + r'massage_woman' + DELIM: '💆‍♀️', + DELIM + r'haircut' + DELIM: '💇', + DELIM + r'haircut_man' + DELIM: '💇‍♂️', + DELIM + r'haircut_woman' + DELIM: '💇‍♀️', + DELIM + r'walking' + DELIM: '🚶', + DELIM + r'walking_man' + DELIM: '🚶‍♂️', + DELIM + r'walking_woman' + DELIM: '🚶‍♀️', + DELIM + r'standing_person' + DELIM: '🧍', + DELIM + r'standing_man' + DELIM: '🧍‍♂️', + DELIM + r'standing_woman' + DELIM: '🧍‍♀️', + DELIM + r'kneeling_person' + DELIM: '🧎', + DELIM + r'kneeling_man' + DELIM: '🧎‍♂️', + DELIM + r'kneeling_woman' + DELIM: '🧎‍♀️', + DELIM + r'person_with_probing_cane' + DELIM: '🧑‍🦯', + DELIM + r'man_with_probing_cane' + DELIM: '👨‍🦯', + DELIM + r'woman_with_probing_cane' + DELIM: '👩‍🦯', + DELIM + r'person_in_motorized_wheelchair' + DELIM: '🧑‍🦼', + DELIM + r'man_in_motorized_wheelchair' + DELIM: '👨‍🦼', + DELIM + r'woman_in_motorized_wheelchair' + DELIM: '👩‍🦼', + DELIM + r'person_in_manual_wheelchair' + DELIM: '🧑‍🦽', + DELIM + r'man_in_manual_wheelchair' + DELIM: '👨‍🦽', + DELIM + r'woman_in_manual_wheelchair' + DELIM: '👩‍🦽', + DELIM + r'runn(er|ing)' + DELIM: '🏃', + DELIM + r'running_man' + DELIM: '🏃‍♂️', + DELIM + r'running_woman' + DELIM: '🏃‍♀️', + DELIM + r'(dancer|woman_dancing)' + DELIM: '💃', + DELIM + r'man_dancing' + DELIM: '🕺', + DELIM + r'business_suit_levitating' + DELIM: '🕴️', + DELIM + r'dancers' + DELIM: '👯', + DELIM + r'dancing_men' + DELIM: '👯‍♂️', + DELIM + r'dancing_women' + DELIM: '👯‍♀️', + DELIM + r'sauna_person' + DELIM: '🧖', + DELIM + r'sauna_man' + DELIM: '🧖‍♂️', + DELIM + r'sauna_woman' + DELIM: '🧖‍♀️', + DELIM + r'climbing' + DELIM: '🧗', + DELIM + r'climbing_man' + DELIM: '🧗‍♂️', + DELIM + r'climbing_woman' + DELIM: '🧗‍♀️', + + # + # Person Sport + # + DELIM + r'person_fencing' + DELIM: '🤺', + DELIM + r'horse_racing' + DELIM: '🏇', + DELIM + r'skier' + DELIM: '⛷️', + DELIM + r'snowboarder' + DELIM: '🏂', + DELIM + r'golfing' + DELIM: '🏌️', + DELIM + r'golfing_man' + DELIM: '🏌️‍♂️', + DELIM + r'golfing_woman' + DELIM: '🏌️‍♀️', + DELIM + r'surfer' + DELIM: '🏄', + DELIM + r'surfing_man' + DELIM: '🏄‍♂️', + DELIM + r'surfing_woman' + DELIM: '🏄‍♀️', + DELIM + r'rowboat' + DELIM: '🚣', + DELIM + r'rowing_man' + DELIM: '🚣‍♂️', + DELIM + r'rowing_woman' + DELIM: '🚣‍♀️', + DELIM + r'swimmer' + DELIM: '🏊', + DELIM + r'swimming_man' + DELIM: '🏊‍♂️', + DELIM + r'swimming_woman' + DELIM: '🏊‍♀️', + DELIM + r'bouncing_ball_person' + DELIM: '⛹️', + DELIM + r'(basketball|bouncing_ball)_man' + DELIM: '⛹️‍♂️', + DELIM + r'(basketball|bouncing_ball)_woman' + DELIM: '⛹️‍♀️', + DELIM + r'weight_lifting' + DELIM: '🏋️', + DELIM + r'weight_lifting_man' + DELIM: '🏋️‍♂️', + DELIM + r'weight_lifting_woman' + DELIM: '🏋️‍♀️', + DELIM + r'bicyclist' + DELIM: '🚴', + DELIM + r'biking_man' + DELIM: '🚴‍♂️', + DELIM + r'biking_woman' + DELIM: '🚴‍♀️', + DELIM + r'mountain_bicyclist' + DELIM: '🚵', + DELIM + r'mountain_biking_man' + DELIM: '🚵‍♂️', + DELIM + r'mountain_biking_woman' + DELIM: '🚵‍♀️', + DELIM + r'cartwheeling' + DELIM: '🤸', + DELIM + r'man_cartwheeling' + DELIM: '🤸‍♂️', + DELIM + r'woman_cartwheeling' + DELIM: '🤸‍♀️', + DELIM + r'wrestling' + DELIM: '🤼', + DELIM + r'men_wrestling' + DELIM: '🤼‍♂️', + DELIM + r'women_wrestling' + DELIM: '🤼‍♀️', + DELIM + r'water_polo' + DELIM: '🤽', + DELIM + r'man_playing_water_polo' + DELIM: '🤽‍♂️', + DELIM + r'woman_playing_water_polo' + DELIM: '🤽‍♀️', + DELIM + r'handball_person' + DELIM: '🤾', + DELIM + r'man_playing_handball' + DELIM: '🤾‍♂️', + DELIM + r'woman_playing_handball' + DELIM: '🤾‍♀️', + DELIM + r'juggling_person' + DELIM: '🤹', + DELIM + r'man_juggling' + DELIM: '🤹‍♂️', + DELIM + r'woman_juggling' + DELIM: '🤹‍♀️', + + # + # Person Resting + # + DELIM + r'lotus_position' + DELIM: '🧘', + DELIM + r'lotus_position_man' + DELIM: '🧘‍♂️', + DELIM + r'lotus_position_woman' + DELIM: '🧘‍♀️', + DELIM + r'bath' + DELIM: '🛀', + DELIM + r'sleeping_bed' + DELIM: '🛌', + + # + # Family + # + DELIM + r'people_holding_hands' + DELIM: '🧑‍🤝‍🧑', + DELIM + r'two_women_holding_hands' + DELIM: '👭', + DELIM + r'couple' + DELIM: '👫', + DELIM + r'two_men_holding_hands' + DELIM: '👬', + DELIM + r'couplekiss' + DELIM: '💏', + DELIM + r'couplekiss_man_woman' + DELIM: '👩‍❤️‍💋‍👨', + DELIM + r'couplekiss_man_man' + DELIM: '👨‍❤️‍💋‍👨', + DELIM + r'couplekiss_woman_woman' + DELIM: '👩‍❤️‍💋‍👩', + DELIM + r'couple_with_heart' + DELIM: '💑', + DELIM + r'couple_with_heart_woman_man' + DELIM: '👩‍❤️‍👨', + DELIM + r'couple_with_heart_man_man' + DELIM: '👨‍❤️‍👨', + DELIM + r'couple_with_heart_woman_woman' + DELIM: '👩‍❤️‍👩', + DELIM + r'family_man_woman_boy' + DELIM: '👨‍👩‍👦', + DELIM + r'family_man_woman_girl' + DELIM: '👨‍👩‍👧', + DELIM + r'family_man_woman_girl_boy' + DELIM: '👨‍👩‍👧‍👦', + DELIM + r'family_man_woman_boy_boy' + DELIM: '👨‍👩‍👦‍👦', + DELIM + r'family_man_woman_girl_girl' + DELIM: '👨‍👩‍👧‍👧', + DELIM + r'family_man_man_boy' + DELIM: '👨‍👨‍👦', + DELIM + r'family_man_man_girl' + DELIM: '👨‍👨‍👧', + DELIM + r'family_man_man_girl_boy' + DELIM: '👨‍👨‍👧‍👦', + DELIM + r'family_man_man_boy_boy' + DELIM: '👨‍👨‍👦‍👦', + DELIM + r'family_man_man_girl_girl' + DELIM: '👨‍👨‍👧‍👧', + DELIM + r'family_woman_woman_boy' + DELIM: '👩‍👩‍👦', + DELIM + r'family_woman_woman_girl' + DELIM: '👩‍👩‍👧', + DELIM + r'family_woman_woman_girl_boy' + DELIM: '👩‍👩‍👧‍👦', + DELIM + r'family_woman_woman_boy_boy' + DELIM: '👩‍👩‍👦‍👦', + DELIM + r'family_woman_woman_girl_girl' + DELIM: '👩‍👩‍👧‍👧', + DELIM + r'family_man_boy' + DELIM: '👨‍👦', + DELIM + r'family_man_boy_boy' + DELIM: '👨‍👦‍👦', + DELIM + r'family_man_girl' + DELIM: '👨‍👧', + DELIM + r'family_man_girl_boy' + DELIM: '👨‍👧‍👦', + DELIM + r'family_man_girl_girl' + DELIM: '👨‍👧‍👧', + DELIM + r'family_woman_boy' + DELIM: '👩‍👦', + DELIM + r'family_woman_boy_boy' + DELIM: '👩‍👦‍👦', + DELIM + r'family_woman_girl' + DELIM: '👩‍👧', + DELIM + r'family_woman_girl_boy' + DELIM: '👩‍👧‍👦', + DELIM + r'family_woman_girl_girl' + DELIM: '👩‍👧‍👧', + + # + # Person Symbol + # + DELIM + r'speaking_head' + DELIM: '🗣️', + DELIM + r'bust_in_silhouette' + DELIM: '👤', + DELIM + r'busts_in_silhouette' + DELIM: '👥', + DELIM + r'people_hugging' + DELIM: '🫂', + DELIM + r'family' + DELIM: '👪', + DELIM + r'footprints' + DELIM: '👣', + + # + # Animal Mammal + # + DELIM + r'monkey_face' + DELIM: '🐵', + DELIM + r'monkey' + DELIM: '🐒', + DELIM + r'gorilla' + DELIM: '🦍', + DELIM + r'orangutan' + DELIM: '🦧', + DELIM + r'dog' + DELIM: '🐶', + DELIM + r'dog2' + DELIM: '🐕', + DELIM + r'guide_dog' + DELIM: '🦮', + DELIM + r'service_dog' + DELIM: '🐕‍🦺', + DELIM + r'poodle' + DELIM: '🐩', + DELIM + r'wolf' + DELIM: '🐺', + DELIM + r'fox_face' + DELIM: '🦊', + DELIM + r'raccoon' + DELIM: '🦝', + DELIM + r'cat' + DELIM: '🐱', + DELIM + r'cat2' + DELIM: '🐈', + DELIM + r'black_cat' + DELIM: '🐈‍⬛', + DELIM + r'lion' + DELIM: '🦁', + DELIM + r'tiger' + DELIM: '🐯', + DELIM + r'tiger2' + DELIM: '🐅', + DELIM + r'leopard' + DELIM: '🐆', + DELIM + r'horse' + DELIM: '🐴', + DELIM + r'racehorse' + DELIM: '🐎', + DELIM + r'unicorn' + DELIM: '🦄', + DELIM + r'zebra' + DELIM: '🦓', + DELIM + r'deer' + DELIM: '🦌', + DELIM + r'bison' + DELIM: '🦬', + DELIM + r'cow' + DELIM: '🐮', + DELIM + r'ox' + DELIM: '🐂', + DELIM + r'water_buffalo' + DELIM: '🐃', + DELIM + r'cow2' + DELIM: '🐄', + DELIM + r'pig' + DELIM: '🐷', + DELIM + r'pig2' + DELIM: '🐖', + DELIM + r'boar' + DELIM: '🐗', + DELIM + r'pig_nose' + DELIM: '🐽', + DELIM + r'ram' + DELIM: '🐏', + DELIM + r'sheep' + DELIM: '🐑', + DELIM + r'goat' + DELIM: '🐐', + DELIM + r'dromedary_camel' + DELIM: '🐪', + DELIM + r'camel' + DELIM: '🐫', + DELIM + r'llama' + DELIM: '🦙', + DELIM + r'giraffe' + DELIM: '🦒', + DELIM + r'elephant' + DELIM: '🐘', + DELIM + r'mammoth' + DELIM: '🦣', + DELIM + r'rhinoceros' + DELIM: '🦏', + DELIM + r'hippopotamus' + DELIM: '🦛', + DELIM + r'mouse' + DELIM: '🐭', + DELIM + r'mouse2' + DELIM: '🐁', + DELIM + r'rat' + DELIM: '🐀', + DELIM + r'hamster' + DELIM: '🐹', + DELIM + r'rabbit' + DELIM: '🐰', + DELIM + r'rabbit2' + DELIM: '🐇', + DELIM + r'chipmunk' + DELIM: '🐿️', + DELIM + r'beaver' + DELIM: '🦫', + DELIM + r'hedgehog' + DELIM: '🦔', + DELIM + r'bat' + DELIM: '🦇', + DELIM + r'bear' + DELIM: '🐻', + DELIM + r'polar_bear' + DELIM: '🐻‍❄️', + DELIM + r'koala' + DELIM: '🐨', + DELIM + r'panda_face' + DELIM: '🐼', + DELIM + r'sloth' + DELIM: '🦥', + DELIM + r'otter' + DELIM: '🦦', + DELIM + r'skunk' + DELIM: '🦨', + DELIM + r'kangaroo' + DELIM: '🦘', + DELIM + r'badger' + DELIM: '🦡', + DELIM + r'(feet|paw_prints)' + DELIM: '🐾', + + # + # Animal Bird + # + DELIM + r'turkey' + DELIM: '🦃', + DELIM + r'chicken' + DELIM: '🐔', + DELIM + r'rooster' + DELIM: '🐓', + DELIM + r'hatching_chick' + DELIM: '🐣', + DELIM + r'baby_chick' + DELIM: '🐤', + DELIM + r'hatched_chick' + DELIM: '🐥', + DELIM + r'bird' + DELIM: '🐦', + DELIM + r'penguin' + DELIM: '🐧', + DELIM + r'dove' + DELIM: '🕊️', + DELIM + r'eagle' + DELIM: '🦅', + DELIM + r'duck' + DELIM: '🦆', + DELIM + r'swan' + DELIM: '🦢', + DELIM + r'owl' + DELIM: '🦉', + DELIM + r'dodo' + DELIM: '🦤', + DELIM + r'feather' + DELIM: '🪶', + DELIM + r'flamingo' + DELIM: '🦩', + DELIM + r'peacock' + DELIM: '🦚', + DELIM + r'parrot' + DELIM: '🦜', + + # + # Animal Amphibian + # + DELIM + r'frog' + DELIM: '🐸', + + # + # Animal Reptile + # + DELIM + r'crocodile' + DELIM: '🐊', + DELIM + r'turtle' + DELIM: '🐢', + DELIM + r'lizard' + DELIM: '🦎', + DELIM + r'snake' + DELIM: '🐍', + DELIM + r'dragon_face' + DELIM: '🐲', + DELIM + r'dragon' + DELIM: '🐉', + DELIM + r'sauropod' + DELIM: '🦕', + DELIM + r't-rex' + DELIM: '🦖', + + # + # Animal Marine + # + DELIM + r'whale' + DELIM: '🐳', + DELIM + r'whale2' + DELIM: '🐋', + DELIM + r'dolphin' + DELIM: '🐬', + DELIM + r'(seal|flipper)' + DELIM: '🦭', + DELIM + r'fish' + DELIM: '🐟', + DELIM + r'tropical_fish' + DELIM: '🐠', + DELIM + r'blowfish' + DELIM: '🐡', + DELIM + r'shark' + DELIM: '🦈', + DELIM + r'octopus' + DELIM: '🐙', + DELIM + r'shell' + DELIM: '🐚', + + # + # Animal Bug + # + DELIM + r'snail' + DELIM: '🐌', + DELIM + r'butterfly' + DELIM: '🦋', + DELIM + r'bug' + DELIM: '🐛', + DELIM + r'ant' + DELIM: '🐜', + DELIM + r'bee' + DELIM: '🐝', + DELIM + r'honeybee' + DELIM: '🪲', + DELIM + r'(lady_)?beetle' + DELIM: '🐞', + DELIM + r'cricket' + DELIM: '🦗', + DELIM + r'cockroach' + DELIM: '🪳', + DELIM + r'spider' + DELIM: '🕷️', + DELIM + r'spider_web' + DELIM: '🕸️', + DELIM + r'scorpion' + DELIM: '🦂', + DELIM + r'mosquito' + DELIM: '🦟', + DELIM + r'fly' + DELIM: '🪰', + DELIM + r'worm' + DELIM: '🪱', + DELIM + r'microbe' + DELIM: '🦠', + + # + # Plant Flower + # + DELIM + r'bouquet' + DELIM: '💐', + DELIM + r'cherry_blossom' + DELIM: '🌸', + DELIM + r'white_flower' + DELIM: '💮', + DELIM + r'rosette' + DELIM: '🏵️', + DELIM + r'rose' + DELIM: '🌹', + DELIM + r'wilted_flower' + DELIM: '🥀', + DELIM + r'hibiscus' + DELIM: '🌺', + DELIM + r'sunflower' + DELIM: '🌻', + DELIM + r'blossom' + DELIM: '🌼', + DELIM + r'tulip' + DELIM: '🌷', + + # + # Plant Other + # + DELIM + r'seedling' + DELIM: '🌱', + DELIM + r'potted_plant' + DELIM: '🪴', + DELIM + r'evergreen_tree' + DELIM: '🌲', + DELIM + r'deciduous_tree' + DELIM: '🌳', + DELIM + r'palm_tree' + DELIM: '🌴', + DELIM + r'cactus' + DELIM: '🌵', + DELIM + r'ear_of_rice' + DELIM: '🌾', + DELIM + r'herb' + DELIM: '🌿', + DELIM + r'shamrock' + DELIM: '☘️', + DELIM + r'four_leaf_clover' + DELIM: '🍀', + DELIM + r'maple_leaf' + DELIM: '🍁', + DELIM + r'fallen_leaf' + DELIM: '🍂', + DELIM + r'leaves' + DELIM: '🍃', + DELIM + r'mushroom' + DELIM: '🍄', + + # + # Food Fruit + # + DELIM + r'grapes' + DELIM: '🍇', + DELIM + r'melon' + DELIM: '🍈', + DELIM + r'watermelon' + DELIM: '🍉', + DELIM + r'(orange|mandarin|tangerine)' + DELIM: '🍊', + DELIM + r'lemon' + DELIM: '🍋', + DELIM + r'banana' + DELIM: '🍌', + DELIM + r'pineapple' + DELIM: '🍍', + DELIM + r'mango' + DELIM: '🥭', + DELIM + r'apple' + DELIM: '🍎', + DELIM + r'green_apple' + DELIM: '🍏', + DELIM + r'pear' + DELIM: '🍐', + DELIM + r'peach' + DELIM: '🍑', + DELIM + r'cherries' + DELIM: '🍒', + DELIM + r'strawberry' + DELIM: '🍓', + DELIM + r'blueberries' + DELIM: '🫐', + DELIM + r'kiwi_fruit' + DELIM: '🥝', + DELIM + r'tomato' + DELIM: '🍅', + DELIM + r'olive' + DELIM: '🫒', + DELIM + r'coconut' + DELIM: '🥥', + + # + # Food Vegetable + # + DELIM + r'avocado' + DELIM: '🥑', + DELIM + r'eggplant' + DELIM: '🍆', + DELIM + r'potato' + DELIM: '🥔', + DELIM + r'carrot' + DELIM: '🥕', + DELIM + r'corn' + DELIM: '🌽', + DELIM + r'hot_pepper' + DELIM: '🌶️', + DELIM + r'bell_pepper' + DELIM: '🫑', + DELIM + r'cucumber' + DELIM: '🥒', + DELIM + r'leafy_green' + DELIM: '🥬', + DELIM + r'broccoli' + DELIM: '🥦', + DELIM + r'garlic' + DELIM: '🧄', + DELIM + r'onion' + DELIM: '🧅', + DELIM + r'peanuts' + DELIM: '🥜', + DELIM + r'chestnut' + DELIM: '🌰', + + # + # Food Prepared + # + DELIM + r'bread' + DELIM: '🍞', + DELIM + r'croissant' + DELIM: '🥐', + DELIM + r'baguette_bread' + DELIM: '🥖', + DELIM + r'flatbread' + DELIM: '🫓', + DELIM + r'pretzel' + DELIM: '🥨', + DELIM + r'bagel' + DELIM: '🥯', + DELIM + r'pancakes' + DELIM: '🥞', + DELIM + r'waffle' + DELIM: '🧇', + DELIM + r'cheese' + DELIM: '🧀', + DELIM + r'meat_on_bone' + DELIM: '🍖', + DELIM + r'poultry_leg' + DELIM: '🍗', + DELIM + r'cut_of_meat' + DELIM: '🥩', + DELIM + r'bacon' + DELIM: '🥓', + DELIM + r'hamburger' + DELIM: '🍔', + DELIM + r'fries' + DELIM: '🍟', + DELIM + r'pizza' + DELIM: '🍕', + DELIM + r'hotdog' + DELIM: '🌭', + DELIM + r'sandwich' + DELIM: '🥪', + DELIM + r'taco' + DELIM: '🌮', + DELIM + r'burrito' + DELIM: '🌯', + DELIM + r'tamale' + DELIM: '🫔', + DELIM + r'stuffed_flatbread' + DELIM: '🥙', + DELIM + r'falafel' + DELIM: '🧆', + DELIM + r'egg' + DELIM: '🥚', + DELIM + r'fried_egg' + DELIM: '🍳', + DELIM + r'shallow_pan_of_food' + DELIM: '🥘', + DELIM + r'stew' + DELIM: '🍲', + DELIM + r'fondue' + DELIM: '🫕', + DELIM + r'bowl_with_spoon' + DELIM: '🥣', + DELIM + r'green_salad' + DELIM: '🥗', + DELIM + r'popcorn' + DELIM: '🍿', + DELIM + r'butter' + DELIM: '🧈', + DELIM + r'salt' + DELIM: '🧂', + DELIM + r'canned_food' + DELIM: '🥫', + + # + # Food Asian + # + DELIM + r'bento' + DELIM: '🍱', + DELIM + r'rice_cracker' + DELIM: '🍘', + DELIM + r'rice_ball' + DELIM: '🍙', + DELIM + r'rice' + DELIM: '🍚', + DELIM + r'curry' + DELIM: '🍛', + DELIM + r'ramen' + DELIM: '🍜', + DELIM + r'spaghetti' + DELIM: '🍝', + DELIM + r'sweet_potato' + DELIM: '🍠', + DELIM + r'oden' + DELIM: '🍢', + DELIM + r'sushi' + DELIM: '🍣', + DELIM + r'fried_shrimp' + DELIM: '🍤', + DELIM + r'fish_cake' + DELIM: '🍥', + DELIM + r'moon_cake' + DELIM: '🥮', + DELIM + r'dango' + DELIM: '🍡', + DELIM + r'dumpling' + DELIM: '🥟', + DELIM + r'fortune_cookie' + DELIM: '🥠', + DELIM + r'takeout_box' + DELIM: '🥡', + + # + # Food Marine + # + DELIM + r'crab' + DELIM: '🦀', + DELIM + r'lobster' + DELIM: '🦞', + DELIM + r'shrimp' + DELIM: '🦐', + DELIM + r'squid' + DELIM: '🦑', + DELIM + r'oyster' + DELIM: '🦪', + + # + # Food Sweet + # + DELIM + r'icecream' + DELIM: '🍦', + DELIM + r'shaved_ice' + DELIM: '🍧', + DELIM + r'ice_cream' + DELIM: '🍨', + DELIM + r'doughnut' + DELIM: '🍩', + DELIM + r'cookie' + DELIM: '🍪', + DELIM + r'birthday' + DELIM: '🎂', + DELIM + r'cake' + DELIM: '🍰', + DELIM + r'cupcake' + DELIM: '🧁', + DELIM + r'pie' + DELIM: '🥧', + DELIM + r'chocolate_bar' + DELIM: '🍫', + DELIM + r'candy' + DELIM: '🍬', + DELIM + r'lollipop' + DELIM: '🍭', + DELIM + r'custard' + DELIM: '🍮', + DELIM + r'honey_pot' + DELIM: '🍯', + + # + # Drink + # + DELIM + r'baby_bottle' + DELIM: '🍼', + DELIM + r'milk_glass' + DELIM: '🥛', + DELIM + r'coffee' + DELIM: '☕', + DELIM + r'teapot' + DELIM: '🫖', + DELIM + r'tea' + DELIM: '🍵', + DELIM + r'sake' + DELIM: '🍶', + DELIM + r'champagne' + DELIM: '🍾', + DELIM + r'wine_glass' + DELIM: '🍷', + DELIM + r'cocktail' + DELIM: '🍸', + DELIM + r'tropical_drink' + DELIM: '🍹', + DELIM + r'beer' + DELIM: '🍺', + DELIM + r'beers' + DELIM: '🍻', + DELIM + r'clinking_glasses' + DELIM: '🥂', + DELIM + r'tumbler_glass' + DELIM: '🥃', + DELIM + r'cup_with_straw' + DELIM: '🥤', + DELIM + r'bubble_tea' + DELIM: '🧋', + DELIM + r'beverage_box' + DELIM: '🧃', + DELIM + r'mate' + DELIM: '🧉', + DELIM + r'ice_cube' + DELIM: '🧊', + + # + # Dishware + # + DELIM + r'chopsticks' + DELIM: '🥢', + DELIM + r'plate_with_cutlery' + DELIM: '🍽️', + DELIM + r'fork_and_knife' + DELIM: '🍴', + DELIM + r'spoon' + DELIM: '🥄', + DELIM + r'(hocho|knife)' + DELIM: '🔪', + DELIM + r'amphora' + DELIM: '🏺', + + # + # Place Map + # + DELIM + r'earth_africa' + DELIM: '🌍', + DELIM + r'earth_americas' + DELIM: '🌎', + DELIM + r'earth_asia' + DELIM: '🌏', + DELIM + r'globe_with_meridians' + DELIM: '🌐', + DELIM + r'world_map' + DELIM: '🗺️', + DELIM + r'japan' + DELIM: '🗾', + DELIM + r'compass' + DELIM: '🧭', + + # + # Place Geographic + # + DELIM + r'mountain_snow' + DELIM: '🏔️', + DELIM + r'mountain' + DELIM: '⛰️', + DELIM + r'volcano' + DELIM: '🌋', + DELIM + r'mount_fuji' + DELIM: '🗻', + DELIM + r'camping' + DELIM: '🏕️', + DELIM + r'beach_umbrella' + DELIM: '🏖️', + DELIM + r'desert' + DELIM: '🏜️', + DELIM + r'desert_island' + DELIM: '🏝️', + DELIM + r'national_park' + DELIM: '🏞️', + + # + # Place Building + # + DELIM + r'stadium' + DELIM: '🏟️', + DELIM + r'classical_building' + DELIM: '🏛️', + DELIM + r'building_construction' + DELIM: '🏗️', + DELIM + r'bricks' + DELIM: '🧱', + DELIM + r'rock' + DELIM: '🪨', + DELIM + r'wood' + DELIM: '🪵', + DELIM + r'hut' + DELIM: '🛖', + DELIM + r'houses' + DELIM: '🏘️', + DELIM + r'derelict_house' + DELIM: '🏚️', + DELIM + r'house' + DELIM: '🏠', + DELIM + r'house_with_garden' + DELIM: '🏡', + DELIM + r'office' + DELIM: '🏢', + DELIM + r'post_office' + DELIM: '🏣', + DELIM + r'european_post_office' + DELIM: '🏤', + DELIM + r'hospital' + DELIM: '🏥', + DELIM + r'bank' + DELIM: '🏦', + DELIM + r'hotel' + DELIM: '🏨', + DELIM + r'love_hotel' + DELIM: '🏩', + DELIM + r'convenience_store' + DELIM: '🏪', + DELIM + r'school' + DELIM: '🏫', + DELIM + r'department_store' + DELIM: '🏬', + DELIM + r'factory' + DELIM: '🏭', + DELIM + r'japanese_castle' + DELIM: '🏯', + DELIM + r'european_castle' + DELIM: '🏰', + DELIM + r'wedding' + DELIM: '💒', + DELIM + r'tokyo_tower' + DELIM: '🗼', + DELIM + r'statue_of_liberty' + DELIM: '🗽', + + # + # Place Religious + # + DELIM + r'church' + DELIM: '⛪', + DELIM + r'mosque' + DELIM: '🕌', + DELIM + r'hindu_temple' + DELIM: '🛕', + DELIM + r'synagogue' + DELIM: '🕍', + DELIM + r'shinto_shrine' + DELIM: '⛩️', + DELIM + r'kaaba' + DELIM: '🕋', + + # + # Place Other + # + DELIM + r'fountain' + DELIM: '⛲', + DELIM + r'tent' + DELIM: '⛺', + DELIM + r'foggy' + DELIM: '🌁', + DELIM + r'night_with_stars' + DELIM: '🌃', + DELIM + r'cityscape' + DELIM: '🏙️', + DELIM + r'sunrise_over_mountains' + DELIM: '🌄', + DELIM + r'sunrise' + DELIM: '🌅', + DELIM + r'city_sunset' + DELIM: '🌆', + DELIM + r'city_sunrise' + DELIM: '🌇', + DELIM + r'bridge_at_night' + DELIM: '🌉', + DELIM + r'hotsprings' + DELIM: '♨️', + DELIM + r'carousel_horse' + DELIM: '🎠', + DELIM + r'ferris_wheel' + DELIM: '🎡', + DELIM + r'roller_coaster' + DELIM: '🎢', + DELIM + r'barber' + DELIM: '💈', + DELIM + r'circus_tent' + DELIM: '🎪', + + # + # Transport Ground + # + DELIM + r'steam_locomotive' + DELIM: '🚂', + DELIM + r'railway_car' + DELIM: '🚃', + DELIM + r'bullettrain_side' + DELIM: '🚄', + DELIM + r'bullettrain_front' + DELIM: '🚅', + DELIM + r'train2' + DELIM: '🚆', + DELIM + r'metro' + DELIM: '🚇', + DELIM + r'light_rail' + DELIM: '🚈', + DELIM + r'station' + DELIM: '🚉', + DELIM + r'tram' + DELIM: '🚊', + DELIM + r'monorail' + DELIM: '🚝', + DELIM + r'mountain_railway' + DELIM: '🚞', + DELIM + r'train' + DELIM: '🚋', + DELIM + r'bus' + DELIM: '🚌', + DELIM + r'oncoming_bus' + DELIM: '🚍', + DELIM + r'trolleybus' + DELIM: '🚎', + DELIM + r'minibus' + DELIM: '🚐', + DELIM + r'ambulance' + DELIM: '🚑', + DELIM + r'fire_engine' + DELIM: '🚒', + DELIM + r'police_car' + DELIM: '🚓', + DELIM + r'oncoming_police_car' + DELIM: '🚔', + DELIM + r'taxi' + DELIM: '🚕', + DELIM + r'oncoming_taxi' + DELIM: '🚖', + DELIM + r'car' + DELIM: '🚗', + DELIM + r'(red_car|oncoming_automobile)' + DELIM: '🚘', + DELIM + r'blue_car' + DELIM: '🚙', + DELIM + r'pickup_truck' + DELIM: '🛻', + DELIM + r'truck' + DELIM: '🚚', + DELIM + r'articulated_lorry' + DELIM: '🚛', + DELIM + r'tractor' + DELIM: '🚜', + DELIM + r'racing_car' + DELIM: '🏎️', + DELIM + r'motorcycle' + DELIM: '🏍️', + DELIM + r'motor_scooter' + DELIM: '🛵', + DELIM + r'manual_wheelchair' + DELIM: '🦽', + DELIM + r'motorized_wheelchair' + DELIM: '🦼', + DELIM + r'auto_rickshaw' + DELIM: '🛺', + DELIM + r'bike' + DELIM: '🚲', + DELIM + r'kick_scooter' + DELIM: '🛴', + DELIM + r'skateboard' + DELIM: '🛹', + DELIM + r'roller_skate' + DELIM: '🛼', + DELIM + r'busstop' + DELIM: '🚏', + DELIM + r'motorway' + DELIM: '🛣️', + DELIM + r'railway_track' + DELIM: '🛤️', + DELIM + r'oil_drum' + DELIM: '🛢️', + DELIM + r'fuelpump' + DELIM: '⛽', + DELIM + r'rotating_light' + DELIM: '🚨', + DELIM + r'traffic_light' + DELIM: '🚥', + DELIM + r'vertical_traffic_light' + DELIM: '🚦', + DELIM + r'stop_sign' + DELIM: '🛑', + DELIM + r'construction' + DELIM: '🚧', + + # + # Transport Water + # + DELIM + r'anchor' + DELIM: '⚓', + DELIM + r'(sailboat|boat)' + DELIM: '⛵', + DELIM + r'canoe' + DELIM: '🛶', + DELIM + r'speedboat' + DELIM: '🚤', + DELIM + r'passenger_ship' + DELIM: '🛳️', + DELIM + r'ferry' + DELIM: '⛴️', + DELIM + r'motor_boat' + DELIM: '🛥️', + DELIM + r'ship' + DELIM: '🚢', + + # + # Transport Air + # + DELIM + r'airplane' + DELIM: '✈️', + DELIM + r'small_airplane' + DELIM: '🛩️', + DELIM + r'flight_departure' + DELIM: '🛫', + DELIM + r'flight_arrival' + DELIM: '🛬', + DELIM + r'parachute' + DELIM: '🪂', + DELIM + r'seat' + DELIM: '💺', + DELIM + r'helicopter' + DELIM: '🚁', + DELIM + r'suspension_railway' + DELIM: '🚟', + DELIM + r'mountain_cableway' + DELIM: '🚠', + DELIM + r'aerial_tramway' + DELIM: '🚡', + DELIM + r'artificial_satellite' + DELIM: '🛰️', + DELIM + r'rocket' + DELIM: '🚀', + DELIM + r'flying_saucer' + DELIM: '🛸', + + # + # Hotel + # + DELIM + r'bellhop_bell' + DELIM: '🛎️', + DELIM + r'luggage' + DELIM: '🧳', + + # + # Time + # + DELIM + r'hourglass' + DELIM: '⌛', + DELIM + r'hourglass_flowing_sand' + DELIM: '⏳', + DELIM + r'watch' + DELIM: '⌚', + DELIM + r'alarm_clock' + DELIM: '⏰', + DELIM + r'stopwatch' + DELIM: '⏱️', + DELIM + r'timer_clock' + DELIM: '⏲️', + DELIM + r'mantelpiece_clock' + DELIM: '🕰️', + DELIM + r'clock12' + DELIM: '🕛', + DELIM + r'clock1230' + DELIM: '🕧', + DELIM + r'clock1' + DELIM: '🕐', + DELIM + r'clock130' + DELIM: '🕜', + DELIM + r'clock2' + DELIM: '🕑', + DELIM + r'clock230' + DELIM: '🕝', + DELIM + r'clock3' + DELIM: '🕒', + DELIM + r'clock330' + DELIM: '🕞', + DELIM + r'clock4' + DELIM: '🕓', + DELIM + r'clock430' + DELIM: '🕟', + DELIM + r'clock5' + DELIM: '🕔', + DELIM + r'clock530' + DELIM: '🕠', + DELIM + r'clock6' + DELIM: '🕕', + DELIM + r'clock630' + DELIM: '🕡', + DELIM + r'clock7' + DELIM: '🕖', + DELIM + r'clock730' + DELIM: '🕢', + DELIM + r'clock8' + DELIM: '🕗', + DELIM + r'clock830' + DELIM: '🕣', + DELIM + r'clock9' + DELIM: '🕘', + DELIM + r'clock930' + DELIM: '🕤', + DELIM + r'clock10' + DELIM: '🕙', + DELIM + r'clock1030' + DELIM: '🕥', + DELIM + r'clock11' + DELIM: '🕚', + DELIM + r'clock1130' + DELIM: '🕦', + + # Sky & Weather + DELIM + r'new_moon' + DELIM: '🌑', + DELIM + r'waxing_crescent_moon' + DELIM: '🌒', + DELIM + r'first_quarter_moon' + DELIM: '🌓', + DELIM + r'moon' + DELIM: '🌔', + DELIM + r'(waxing_gibbous_moon|full_moon)' + DELIM: '🌕', + DELIM + r'waning_gibbous_moon' + DELIM: '🌖', + DELIM + r'last_quarter_moon' + DELIM: '🌗', + DELIM + r'waning_crescent_moon' + DELIM: '🌘', + DELIM + r'crescent_moon' + DELIM: '🌙', + DELIM + r'new_moon_with_face' + DELIM: '🌚', + DELIM + r'first_quarter_moon_with_face' + DELIM: '🌛', + DELIM + r'last_quarter_moon_with_face' + DELIM: '🌜', + DELIM + r'thermometer' + DELIM: '🌡️', + DELIM + r'sunny' + DELIM: '☀️', + DELIM + r'full_moon_with_face' + DELIM: '🌝', + DELIM + r'sun_with_face' + DELIM: '🌞', + DELIM + r'ringed_planet' + DELIM: '🪐', + DELIM + r'star' + DELIM: '⭐', + DELIM + r'star2' + DELIM: '🌟', + DELIM + r'stars' + DELIM: '🌠', + DELIM + r'milky_way' + DELIM: '🌌', + DELIM + r'cloud' + DELIM: '☁️', + DELIM + r'partly_sunny' + DELIM: '⛅', + DELIM + r'cloud_with_lightning_and_rain' + DELIM: '⛈️', + DELIM + r'sun_behind_small_cloud' + DELIM: '🌤️', + DELIM + r'sun_behind_large_cloud' + DELIM: '🌥️', + DELIM + r'sun_behind_rain_cloud' + DELIM: '🌦️', + DELIM + r'cloud_with_rain' + DELIM: '🌧️', + DELIM + r'cloud_with_snow' + DELIM: '🌨️', + DELIM + r'cloud_with_lightning' + DELIM: '🌩️', + DELIM + r'tornado' + DELIM: '🌪️', + DELIM + r'fog' + DELIM: '🌫️', + DELIM + r'wind_face' + DELIM: '🌬️', + DELIM + r'cyclone' + DELIM: '🌀', + DELIM + r'rainbow' + DELIM: '🌈', + DELIM + r'closed_umbrella' + DELIM: '🌂', + DELIM + r'open_umbrella' + DELIM: '☂️', + DELIM + r'umbrella' + DELIM: '☔', + DELIM + r'parasol_on_ground' + DELIM: '⛱️', + DELIM + r'zap' + DELIM: '⚡', + DELIM + r'snowflake' + DELIM: '❄️', + DELIM + r'snowman_with_snow' + DELIM: '☃️', + DELIM + r'snowman' + DELIM: '⛄', + DELIM + r'comet' + DELIM: '☄️', + DELIM + r'fire' + DELIM: '🔥', + DELIM + r'droplet' + DELIM: '💧', + DELIM + r'ocean' + DELIM: '🌊', + + # + # Event + # + DELIM + r'jack_o_lantern' + DELIM: '🎃', + DELIM + r'christmas_tree' + DELIM: '🎄', + DELIM + r'fireworks' + DELIM: '🎆', + DELIM + r'sparkler' + DELIM: '🎇', + DELIM + r'firecracker' + DELIM: '🧨', + DELIM + r'sparkles' + DELIM: '✨', + DELIM + r'balloon' + DELIM: '🎈', + DELIM + r'tada' + DELIM: '🎉', + DELIM + r'confetti_ball' + DELIM: '🎊', + DELIM + r'tanabata_tree' + DELIM: '🎋', + DELIM + r'bamboo' + DELIM: '🎍', + DELIM + r'dolls' + DELIM: '🎎', + DELIM + r'flags' + DELIM: '🎏', + DELIM + r'wind_chime' + DELIM: '🎐', + DELIM + r'rice_scene' + DELIM: '🎑', + DELIM + r'red_envelope' + DELIM: '🧧', + DELIM + r'ribbon' + DELIM: '🎀', + DELIM + r'gift' + DELIM: '🎁', + DELIM + r'reminder_ribbon' + DELIM: '🎗️', + DELIM + r'tickets' + DELIM: '🎟️', + DELIM + r'ticket' + DELIM: '🎫', + + # + # Award Medal + # + DELIM + r'medal_military' + DELIM: '🎖️', + DELIM + r'trophy' + DELIM: '🏆', + DELIM + r'medal_sports' + DELIM: '🏅', + DELIM + r'1st_place_medal' + DELIM: '🥇', + DELIM + r'2nd_place_medal' + DELIM: '🥈', + DELIM + r'3rd_place_medal' + DELIM: '🥉', + + # + # Sport + # + DELIM + r'soccer' + DELIM: '⚽', + DELIM + r'baseball' + DELIM: '⚾', + DELIM + r'softball' + DELIM: '🥎', + DELIM + r'basketball' + DELIM: '🏀', + DELIM + r'volleyball' + DELIM: '🏐', + DELIM + r'football' + DELIM: '🏈', + DELIM + r'rugby_football' + DELIM: '🏉', + DELIM + r'tennis' + DELIM: '🎾', + DELIM + r'flying_disc' + DELIM: '🥏', + DELIM + r'bowling' + DELIM: '🎳', + DELIM + r'cricket_game' + DELIM: '🏏', + DELIM + r'field_hockey' + DELIM: '🏑', + DELIM + r'ice_hockey' + DELIM: '🏒', + DELIM + r'lacrosse' + DELIM: '🥍', + DELIM + r'ping_pong' + DELIM: '🏓', + DELIM + r'badminton' + DELIM: '🏸', + DELIM + r'boxing_glove' + DELIM: '🥊', + DELIM + r'martial_arts_uniform' + DELIM: '🥋', + DELIM + r'goal_net' + DELIM: '🥅', + DELIM + r'golf' + DELIM: '⛳', + DELIM + r'ice_skate' + DELIM: '⛸️', + DELIM + r'fishing_pole_and_fish' + DELIM: '🎣', + DELIM + r'diving_mask' + DELIM: '🤿', + DELIM + r'running_shirt_with_sash' + DELIM: '🎽', + DELIM + r'ski' + DELIM: '🎿', + DELIM + r'sled' + DELIM: '🛷', + DELIM + r'curling_stone' + DELIM: '🥌', + + # + # Game + # + DELIM + r'dart' + DELIM: '🎯', + DELIM + r'yo_yo' + DELIM: '🪀', + DELIM + r'kite' + DELIM: '🪁', + DELIM + r'gun' + DELIM: '🔫', + DELIM + r'8ball' + DELIM: '🎱', + DELIM + r'crystal_ball' + DELIM: '🔮', + DELIM + r'magic_wand' + DELIM: '🪄', + DELIM + r'video_game' + DELIM: '🎮', + DELIM + r'joystick' + DELIM: '🕹️', + DELIM + r'slot_machine' + DELIM: '🎰', + DELIM + r'game_die' + DELIM: '🎲', + DELIM + r'jigsaw' + DELIM: '🧩', + DELIM + r'teddy_bear' + DELIM: '🧸', + DELIM + r'pinata' + DELIM: '🪅', + DELIM + r'nesting_dolls' + DELIM: '🪆', + DELIM + r'spades' + DELIM: '♠️', + DELIM + r'hearts' + DELIM: '♥️', + DELIM + r'diamonds' + DELIM: '♦️', + DELIM + r'clubs' + DELIM: '♣️', + DELIM + r'chess_pawn' + DELIM: '♟️', + DELIM + r'black_joker' + DELIM: '🃏', + DELIM + r'mahjong' + DELIM: '🀄', + DELIM + r'flower_playing_cards' + DELIM: '🎴', + + # + # Arts & Crafts + # + DELIM + r'performing_arts' + DELIM: '🎭', + DELIM + r'framed_picture' + DELIM: '🖼️', + DELIM + r'art' + DELIM: '🎨', + DELIM + r'thread' + DELIM: '🧵', + DELIM + r'sewing_needle' + DELIM: '🪡', + DELIM + r'yarn' + DELIM: '🧶', + DELIM + r'knot' + DELIM: '🪢', + + # + # Clothing + # + DELIM + r'eyeglasses' + DELIM: '👓', + DELIM + r'dark_sunglasses' + DELIM: '🕶️', + DELIM + r'goggles' + DELIM: '🥽', + DELIM + r'lab_coat' + DELIM: '🥼', + DELIM + r'safety_vest' + DELIM: '🦺', + DELIM + r'necktie' + DELIM: '👔', + DELIM + r't?shirt' + DELIM: '👕', + DELIM + r'jeans' + DELIM: '👖', + DELIM + r'scarf' + DELIM: '🧣', + DELIM + r'gloves' + DELIM: '🧤', + DELIM + r'coat' + DELIM: '🧥', + DELIM + r'socks' + DELIM: '🧦', + DELIM + r'dress' + DELIM: '👗', + DELIM + r'kimono' + DELIM: '👘', + DELIM + r'sari' + DELIM: '🥻', + DELIM + r'one_piece_swimsuit' + DELIM: '🩱', + DELIM + r'swim_brief' + DELIM: '🩲', + DELIM + r'shorts' + DELIM: '🩳', + DELIM + r'bikini' + DELIM: '👙', + DELIM + r'womans_clothes' + DELIM: '👚', + DELIM + r'purse' + DELIM: '👛', + DELIM + r'handbag' + DELIM: '👜', + DELIM + r'pouch' + DELIM: '👝', + DELIM + r'shopping' + DELIM: '🛍️', + DELIM + r'school_satchel' + DELIM: '🎒', + DELIM + r'thong_sandal' + DELIM: '🩴', + DELIM + r'(mans_)?shoe' + DELIM: '👞', + DELIM + r'athletic_shoe' + DELIM: '👟', + DELIM + r'hiking_boot' + DELIM: '🥾', + DELIM + r'flat_shoe' + DELIM: '🥿', + DELIM + r'high_heel' + DELIM: '👠', + DELIM + r'sandal' + DELIM: '👡', + DELIM + r'ballet_shoes' + DELIM: '🩰', + DELIM + r'boot' + DELIM: '👢', + DELIM + r'crown' + DELIM: '👑', + DELIM + r'womans_hat' + DELIM: '👒', + DELIM + r'tophat' + DELIM: '🎩', + DELIM + r'mortar_board' + DELIM: '🎓', + DELIM + r'billed_cap' + DELIM: '🧢', + DELIM + r'military_helmet' + DELIM: '🪖', + DELIM + r'rescue_worker_helmet' + DELIM: '⛑️', + DELIM + r'prayer_beads' + DELIM: '📿', + DELIM + r'lipstick' + DELIM: '💄', + DELIM + r'ring' + DELIM: '💍', + DELIM + r'gem' + DELIM: '💎', + + # + # Sound + # + DELIM + r'mute' + DELIM: '🔇', + DELIM + r'speaker' + DELIM: '🔈', + DELIM + r'sound' + DELIM: '🔉', + DELIM + r'loud_sound' + DELIM: '🔊', + DELIM + r'loudspeaker' + DELIM: '📢', + DELIM + r'mega' + DELIM: '📣', + DELIM + r'postal_horn' + DELIM: '📯', + DELIM + r'bell' + DELIM: '🔔', + DELIM + r'no_bell' + DELIM: '🔕', + + # + # Music + # + DELIM + r'musical_score' + DELIM: '🎼', + DELIM + r'musical_note' + DELIM: '🎵', + DELIM + r'notes' + DELIM: '🎶', + DELIM + r'studio_microphone' + DELIM: '🎙️', + DELIM + r'level_slider' + DELIM: '🎚️', + DELIM + r'control_knobs' + DELIM: '🎛️', + DELIM + r'microphone' + DELIM: '🎤', + DELIM + r'headphones' + DELIM: '🎧', + DELIM + r'radio' + DELIM: '📻', + + # + # Musical Instrument + # + DELIM + r'saxophone' + DELIM: '🎷', + DELIM + r'accordion' + DELIM: '🪗', + DELIM + r'guitar' + DELIM: '🎸', + DELIM + r'musical_keyboard' + DELIM: '🎹', + DELIM + r'trumpet' + DELIM: '🎺', + DELIM + r'violin' + DELIM: '🎻', + DELIM + r'banjo' + DELIM: '🪕', + DELIM + r'drum' + DELIM: '🥁', + DELIM + r'long_drum' + DELIM: '🪘', + + # + # Phone + # + DELIM + r'iphone' + DELIM: '📱', + DELIM + r'calling' + DELIM: '📲', + DELIM + r'phone' + DELIM: '☎️', + DELIM + r'telephone(_receiver)?' + DELIM: '📞', + DELIM + r'pager' + DELIM: '📟', + DELIM + r'fax' + DELIM: '📠', + + # + # Computer + # + DELIM + r'battery' + DELIM: '🔋', + DELIM + r'electric_plug' + DELIM: '🔌', + DELIM + r'computer' + DELIM: '💻', + DELIM + r'desktop_computer' + DELIM: '🖥️', + DELIM + r'printer' + DELIM: '🖨️', + DELIM + r'keyboard' + DELIM: '⌨️', + DELIM + r'computer_mouse' + DELIM: '🖱️', + DELIM + r'trackball' + DELIM: '🖲️', + DELIM + r'minidisc' + DELIM: '💽', + DELIM + r'floppy_disk' + DELIM: '💾', + DELIM + r'cd' + DELIM: '💿', + DELIM + r'dvd' + DELIM: '📀', + DELIM + r'abacus' + DELIM: '🧮', + + # + # Light & Video + # + DELIM + r'movie_camera' + DELIM: '🎥', + DELIM + r'film_strip' + DELIM: '🎞️', + DELIM + r'film_projector' + DELIM: '📽️', + DELIM + r'clapper' + DELIM: '🎬', + DELIM + r'tv' + DELIM: '📺', + DELIM + r'camera' + DELIM: '📷', + DELIM + r'camera_flash' + DELIM: '📸', + DELIM + r'video_camera' + DELIM: '📹', + DELIM + r'vhs' + DELIM: '📼', + DELIM + r'mag' + DELIM: '🔍', + DELIM + r'mag_right' + DELIM: '🔎', + DELIM + r'candle' + DELIM: '🕯️', + DELIM + r'bulb' + DELIM: '💡', + DELIM + r'flashlight' + DELIM: '🔦', + DELIM + r'(izakaya_)?lantern' + DELIM: '🏮', + DELIM + r'diya_lamp' + DELIM: '🪔', + + # + # Book Paper + # + DELIM + r'notebook_with_decorative_cover' + DELIM: '📔', + DELIM + r'closed_book' + DELIM: '📕', + DELIM + r'(open_)?book' + DELIM: '📖', + DELIM + r'green_book' + DELIM: '📗', + DELIM + r'blue_book' + DELIM: '📘', + DELIM + r'orange_book' + DELIM: '📙', + DELIM + r'books' + DELIM: '📚', + DELIM + r'notebook' + DELIM: '📓', + DELIM + r'ledger' + DELIM: '📒', + DELIM + r'page_with_curl' + DELIM: '📃', + DELIM + r'scroll' + DELIM: '📜', + DELIM + r'page_facing_up' + DELIM: '📄', + DELIM + r'newspaper' + DELIM: '📰', + DELIM + r'newspaper_roll' + DELIM: '🗞️', + DELIM + r'bookmark_tabs' + DELIM: '📑', + DELIM + r'bookmark' + DELIM: '🔖', + DELIM + r'label' + DELIM: '🏷️', + + # + # Money + # + DELIM + r'moneybag' + DELIM: '💰', + DELIM + r'coin' + DELIM: '🪙', + DELIM + r'yen' + DELIM: '💴', + DELIM + r'dollar' + DELIM: '💵', + DELIM + r'euro' + DELIM: '💶', + DELIM + r'pound' + DELIM: '💷', + DELIM + r'money_with_wings' + DELIM: '💸', + DELIM + r'credit_card' + DELIM: '💳', + DELIM + r'receipt' + DELIM: '🧾', + DELIM + r'chart' + DELIM: '💹', + + # + # Mail + # + DELIM + r'envelope' + DELIM: '✉️', + DELIM + r'e-?mail' + DELIM: '📧', + DELIM + r'incoming_envelope' + DELIM: '📨', + DELIM + r'envelope_with_arrow' + DELIM: '📩', + DELIM + r'outbox_tray' + DELIM: '📤', + DELIM + r'inbox_tray' + DELIM: '📥', + DELIM + r'package' + DELIM: '📦', + DELIM + r'mailbox' + DELIM: '📫', + DELIM + r'mailbox_closed' + DELIM: '📪', + DELIM + r'mailbox_with_mail' + DELIM: '📬', + DELIM + r'mailbox_with_no_mail' + DELIM: '📭', + DELIM + r'postbox' + DELIM: '📮', + DELIM + r'ballot_box' + DELIM: '🗳️', + + # + # Writing + # + DELIM + r'pencil2' + DELIM: '✏️', + DELIM + r'black_nib' + DELIM: '✒️', + DELIM + r'fountain_pen' + DELIM: '🖋️', + DELIM + r'pen' + DELIM: '🖊️', + DELIM + r'paintbrush' + DELIM: '🖌️', + DELIM + r'crayon' + DELIM: '🖍️', + DELIM + r'(memo|pencil)' + DELIM: '📝', + + # + # Office + # + DELIM + r'briefcase' + DELIM: '💼', + DELIM + r'file_folder' + DELIM: '📁', + DELIM + r'open_file_folder' + DELIM: '📂', + DELIM + r'card_index_dividers' + DELIM: '🗂️', + DELIM + r'date' + DELIM: '📅', + DELIM + r'calendar' + DELIM: '📆', + DELIM + r'spiral_notepad' + DELIM: '🗒️', + DELIM + r'spiral_calendar' + DELIM: '🗓️', + DELIM + r'card_index' + DELIM: '📇', + DELIM + r'chart_with_upwards_trend' + DELIM: '📈', + DELIM + r'chart_with_downwards_trend' + DELIM: '📉', + DELIM + r'bar_chart' + DELIM: '📊', + DELIM + r'clipboard' + DELIM: '📋', + DELIM + r'pushpin' + DELIM: '📌', + DELIM + r'round_pushpin' + DELIM: '📍', + DELIM + r'paperclip' + DELIM: '📎', + DELIM + r'paperclips' + DELIM: '🖇️', + DELIM + r'straight_ruler' + DELIM: '📏', + DELIM + r'triangular_ruler' + DELIM: '📐', + DELIM + r'scissors' + DELIM: '✂️', + DELIM + r'card_file_box' + DELIM: '🗃️', + DELIM + r'file_cabinet' + DELIM: '🗄️', + DELIM + r'wastebasket' + DELIM: '🗑️', + + # + # Lock + # + DELIM + r'lock' + DELIM: '🔒', + DELIM + r'unlock' + DELIM: '🔓', + DELIM + r'lock_with_ink_pen' + DELIM: '🔏', + DELIM + r'closed_lock_with_key' + DELIM: '🔐', + DELIM + r'key' + DELIM: '🔑', + DELIM + r'old_key' + DELIM: '🗝️', + + # + # Tool + # + DELIM + r'hammer' + DELIM: '🔨', + DELIM + r'axe' + DELIM: '🪓', + DELIM + r'pick' + DELIM: '⛏️', + DELIM + r'hammer_and_pick' + DELIM: '⚒️', + DELIM + r'hammer_and_wrench' + DELIM: '🛠️', + DELIM + r'dagger' + DELIM: '🗡️', + DELIM + r'crossed_swords' + DELIM: '⚔️', + DELIM + r'bomb' + DELIM: '💣', + DELIM + r'boomerang' + DELIM: '🪃', + DELIM + r'bow_and_arrow' + DELIM: '🏹', + DELIM + r'shield' + DELIM: '🛡️', + DELIM + r'carpentry_saw' + DELIM: '🪚', + DELIM + r'wrench' + DELIM: '🔧', + DELIM + r'screwdriver' + DELIM: '🪛', + DELIM + r'nut_and_bolt' + DELIM: '🔩', + DELIM + r'gear' + DELIM: '⚙️', + DELIM + r'clamp' + DELIM: '🗜️', + DELIM + r'balance_scale' + DELIM: '⚖️', + DELIM + r'probing_cane' + DELIM: '🦯', + DELIM + r'link' + DELIM: '🔗', + DELIM + r'chains' + DELIM: '⛓️', + DELIM + r'hook' + DELIM: '🪝', + DELIM + r'toolbox' + DELIM: '🧰', + DELIM + r'magnet' + DELIM: '🧲', + DELIM + r'ladder' + DELIM: '🪜', + + # + # Science + # + DELIM + r'alembic' + DELIM: '⚗️', + DELIM + r'test_tube' + DELIM: '🧪', + DELIM + r'petri_dish' + DELIM: '🧫', + DELIM + r'dna' + DELIM: '🧬', + DELIM + r'microscope' + DELIM: '🔬', + DELIM + r'telescope' + DELIM: '🔭', + DELIM + r'satellite' + DELIM: '📡', + + # + # Medical + # + DELIM + r'syringe' + DELIM: '💉', + DELIM + r'drop_of_blood' + DELIM: '🩸', + DELIM + r'pill' + DELIM: '💊', + DELIM + r'adhesive_bandage' + DELIM: '🩹', + DELIM + r'stethoscope' + DELIM: '🩺', + + # + # Household + # + DELIM + r'door' + DELIM: '🚪', + DELIM + r'elevator' + DELIM: '🛗', + DELIM + r'mirror' + DELIM: '🪞', + DELIM + r'window' + DELIM: '🪟', + DELIM + r'bed' + DELIM: '🛏️', + DELIM + r'couch_and_lamp' + DELIM: '🛋️', + DELIM + r'chair' + DELIM: '🪑', + DELIM + r'toilet' + DELIM: '🚽', + DELIM + r'plunger' + DELIM: '🪠', + DELIM + r'shower' + DELIM: '🚿', + DELIM + r'bathtub' + DELIM: '🛁', + DELIM + r'mouse_trap' + DELIM: '🪤', + DELIM + r'razor' + DELIM: '🪒', + DELIM + r'lotion_bottle' + DELIM: '🧴', + DELIM + r'safety_pin' + DELIM: '🧷', + DELIM + r'broom' + DELIM: '🧹', + DELIM + r'basket' + DELIM: '🧺', + DELIM + r'roll_of_paper' + DELIM: '🧻', + DELIM + r'bucket' + DELIM: '🪣', + DELIM + r'soap' + DELIM: '🧼', + DELIM + r'toothbrush' + DELIM: '🪥', + DELIM + r'sponge' + DELIM: '🧽', + DELIM + r'fire_extinguisher' + DELIM: '🧯', + DELIM + r'shopping_cart' + DELIM: '🛒', + + # + # Other Object + # + DELIM + r'smoking' + DELIM: '🚬', + DELIM + r'coffin' + DELIM: '⚰️', + DELIM + r'headstone' + DELIM: '🪦', + DELIM + r'funeral_urn' + DELIM: '⚱️', + DELIM + r'nazar_amulet' + DELIM: '🧿', + DELIM + r'moyai' + DELIM: '🗿', + DELIM + r'placard' + DELIM: '🪧', + + # + # Transport Sign + # + DELIM + r'atm' + DELIM: '🏧', + DELIM + r'put_litter_in_its_place' + DELIM: '🚮', + DELIM + r'potable_water' + DELIM: '🚰', + DELIM + r'wheelchair' + DELIM: '♿', + DELIM + r'mens' + DELIM: '🚹', + DELIM + r'womens' + DELIM: '🚺', + DELIM + r'restroom' + DELIM: '🚻', + DELIM + r'baby_symbol' + DELIM: '🚼', + DELIM + r'wc' + DELIM: '🚾', + DELIM + r'passport_control' + DELIM: '🛂', + DELIM + r'customs' + DELIM: '🛃', + DELIM + r'baggage_claim' + DELIM: '🛄', + DELIM + r'left_luggage' + DELIM: '🛅', + + # + # Warning + # + DELIM + r'warning' + DELIM: '⚠️', + DELIM + r'children_crossing' + DELIM: '🚸', + DELIM + r'no_entry' + DELIM: '⛔', + DELIM + r'no_entry_sign' + DELIM: '🚫', + DELIM + r'no_bicycles' + DELIM: '🚳', + DELIM + r'no_smoking' + DELIM: '🚭', + DELIM + r'do_not_litter' + DELIM: '🚯', + DELIM + r'non-potable_water' + DELIM: '🚱', + DELIM + r'no_pedestrians' + DELIM: '🚷', + DELIM + r'no_mobile_phones' + DELIM: '📵', + DELIM + r'underage' + DELIM: '🔞', + DELIM + r'radioactive' + DELIM: '☢️', + DELIM + r'biohazard' + DELIM: '☣️', + + # + # Arrow + # + DELIM + r'arrow_up' + DELIM: '⬆️', + DELIM + r'arrow_upper_right' + DELIM: '↗️', + DELIM + r'arrow_right' + DELIM: '➡️', + DELIM + r'arrow_lower_right' + DELIM: '↘️', + DELIM + r'arrow_down' + DELIM: '⬇️', + DELIM + r'arrow_lower_left' + DELIM: '↙️', + DELIM + r'arrow_left' + DELIM: '⬅️', + DELIM + r'arrow_upper_left' + DELIM: '↖️', + DELIM + r'arrow_up_down' + DELIM: '↕️', + DELIM + r'left_right_arrow' + DELIM: '↔️', + DELIM + r'leftwards_arrow_with_hook' + DELIM: '↩️', + DELIM + r'arrow_right_hook' + DELIM: '↪️', + DELIM + r'arrow_heading_up' + DELIM: '⤴️', + DELIM + r'arrow_heading_down' + DELIM: '⤵️', + DELIM + r'arrows_clockwise' + DELIM: '🔃', + DELIM + r'arrows_counterclockwise' + DELIM: '🔄', + DELIM + r'back' + DELIM: '🔙', + DELIM + r'end' + DELIM: '🔚', + DELIM + r'on' + DELIM: '🔛', + DELIM + r'soon' + DELIM: '🔜', + DELIM + r'top' + DELIM: '🔝', + + # + # Religion + # + DELIM + r'place_of_worship' + DELIM: '🛐', + DELIM + r'atom_symbol' + DELIM: '⚛️', + DELIM + r'om' + DELIM: '🕉️', + DELIM + r'star_of_david' + DELIM: '✡️', + DELIM + r'wheel_of_dharma' + DELIM: '☸️', + DELIM + r'yin_yang' + DELIM: '☯️', + DELIM + r'latin_cross' + DELIM: '✝️', + DELIM + r'orthodox_cross' + DELIM: '☦️', + DELIM + r'star_and_crescent' + DELIM: '☪️', + DELIM + r'peace_symbol' + DELIM: '☮️', + DELIM + r'menorah' + DELIM: '🕎', + DELIM + r'six_pointed_star' + DELIM: '🔯', + + # + # Zodiac + # + DELIM + r'aries' + DELIM: '♈', + DELIM + r'taurus' + DELIM: '♉', + DELIM + r'gemini' + DELIM: '♊', + DELIM + r'cancer' + DELIM: '♋', + DELIM + r'leo' + DELIM: '♌', + DELIM + r'virgo' + DELIM: '♍', + DELIM + r'libra' + DELIM: '♎', + DELIM + r'scorpius' + DELIM: '♏', + DELIM + r'sagittarius' + DELIM: '♐', + DELIM + r'capricorn' + DELIM: '♑', + DELIM + r'aquarius' + DELIM: '♒', + DELIM + r'pisces' + DELIM: '♓', + DELIM + r'ophiuchus' + DELIM: '⛎', + + # + # Av Symbol + # + DELIM + r'twisted_rightwards_arrows' + DELIM: '🔀', + DELIM + r'repeat' + DELIM: '🔁', + DELIM + r'repeat_one' + DELIM: '🔂', + DELIM + r'arrow_forward' + DELIM: '▶️', + DELIM + r'fast_forward' + DELIM: '⏩', + DELIM + r'next_track_button' + DELIM: '⏭️', + DELIM + r'play_or_pause_button' + DELIM: '⏯️', + DELIM + r'arrow_backward' + DELIM: '◀️', + DELIM + r'rewind' + DELIM: '⏪', + DELIM + r'previous_track_button' + DELIM: '⏮️', + DELIM + r'arrow_up_small' + DELIM: '🔼', + DELIM + r'arrow_double_up' + DELIM: '⏫', + DELIM + r'arrow_down_small' + DELIM: '🔽', + DELIM + r'arrow_double_down' + DELIM: '⏬', + DELIM + r'pause_button' + DELIM: '⏸️', + DELIM + r'stop_button' + DELIM: '⏹️', + DELIM + r'record_button' + DELIM: '⏺️', + DELIM + r'eject_button' + DELIM: '⏏️', + DELIM + r'cinema' + DELIM: '🎦', + DELIM + r'low_brightness' + DELIM: '🔅', + DELIM + r'high_brightness' + DELIM: '🔆', + DELIM + r'signal_strength' + DELIM: '📶', + DELIM + r'vibration_mode' + DELIM: '📳', + DELIM + r'mobile_phone_off' + DELIM: '📴', + + # + # Gender + # + DELIM + r'female_sign' + DELIM: '♀️', + DELIM + r'male_sign' + DELIM: '♂️', + DELIM + r'transgender_symbol' + DELIM: '⚧️', + + # + # Math + # + DELIM + r'heavy_multiplication_x' + DELIM: '✖️', + DELIM + r'heavy_plus_sign' + DELIM: '➕', + DELIM + r'heavy_minus_sign' + DELIM: '➖', + DELIM + r'heavy_division_sign' + DELIM: '➗', + DELIM + r'infinity' + DELIM: '♾️', + + # + # Punctuation + # + DELIM + r'bangbang' + DELIM: '‼️', + DELIM + r'interrobang' + DELIM: '⁉️', + DELIM + r'question' + DELIM: '❓', + DELIM + r'grey_question' + DELIM: '❔', + DELIM + r'grey_exclamation' + DELIM: '❕', + DELIM + r'(heavy_exclamation_mark|exclamation)' + DELIM: '❗', + DELIM + r'wavy_dash' + DELIM: '〰️', + + # + # Currency + # + DELIM + r'currency_exchange' + DELIM: '💱', + DELIM + r'heavy_dollar_sign' + DELIM: '💲', + + # + # Other Symbol + # + DELIM + r'medical_symbol' + DELIM: '⚕️', + DELIM + r'recycle' + DELIM: '♻️', + DELIM + r'fleur_de_lis' + DELIM: '⚜️', + DELIM + r'trident' + DELIM: '🔱', + DELIM + r'name_badge' + DELIM: '📛', + DELIM + r'beginner' + DELIM: '🔰', + DELIM + r'o' + DELIM: '⭕', + DELIM + r'white_check_mark' + DELIM: '✅', + DELIM + r'ballot_box_with_check' + DELIM: '☑️', + DELIM + r'heavy_check_mark' + DELIM: '✔️', + DELIM + r'x' + DELIM: '❌', + DELIM + r'negative_squared_cross_mark' + DELIM: '❎', + DELIM + r'curly_loop' + DELIM: '➰', + DELIM + r'loop' + DELIM: '➿', + DELIM + r'part_alternation_mark' + DELIM: '〽️', + DELIM + r'eight_spoked_asterisk' + DELIM: '✳️', + DELIM + r'eight_pointed_black_star' + DELIM: '✴️', + DELIM + r'sparkle' + DELIM: '❇️', + DELIM + r'copyright' + DELIM: '©️', + DELIM + r'registered' + DELIM: '®️', + DELIM + r'tm' + DELIM: '™️', + + # + # Keycap + # + DELIM + r'hash' + DELIM: '#️⃣', + DELIM + r'asterisk' + DELIM: '*️⃣', + DELIM + r'zero' + DELIM: '0️⃣', + DELIM + r'one' + DELIM: '1️⃣', + DELIM + r'two' + DELIM: '2️⃣', + DELIM + r'three' + DELIM: '3️⃣', + DELIM + r'four' + DELIM: '4️⃣', + DELIM + r'five' + DELIM: '5️⃣', + DELIM + r'six' + DELIM: '6️⃣', + DELIM + r'seven' + DELIM: '7️⃣', + DELIM + r'eight' + DELIM: '8️⃣', + DELIM + r'nine' + DELIM: '9️⃣', + DELIM + r'keycap_ten' + DELIM: '🔟', + + # + # Alphanum + # + DELIM + r'capital_abcd' + DELIM: '🔠', + DELIM + r'abcd' + DELIM: '🔡', + DELIM + r'1234' + DELIM: '🔢', + DELIM + r'symbols' + DELIM: '🔣', + DELIM + r'abc' + DELIM: '🔤', + DELIM + r'a' + DELIM: '🅰️', + DELIM + r'ab' + DELIM: '🆎', + DELIM + r'b' + DELIM: '🅱️', + DELIM + r'cl' + DELIM: '🆑', + DELIM + r'cool' + DELIM: '🆒', + DELIM + r'free' + DELIM: '🆓', + DELIM + r'information_source' + DELIM: 'ℹ️', + DELIM + r'id' + DELIM: '🆔', + DELIM + r'm' + DELIM: 'Ⓜ️', + DELIM + r'new' + DELIM: '🆕', + DELIM + r'ng' + DELIM: '🆖', + DELIM + r'o2' + DELIM: '🅾️', + DELIM + r'ok' + DELIM: '🆗', + DELIM + r'parking' + DELIM: '🅿️', + DELIM + r'sos' + DELIM: '🆘', + DELIM + r'up' + DELIM: '🆙', + DELIM + r'vs' + DELIM: '🆚', + DELIM + r'koko' + DELIM: '🈁', + DELIM + r'sa' + DELIM: '🈂️', + DELIM + r'u6708' + DELIM: '🈷️', + DELIM + r'u6709' + DELIM: '🈶', + DELIM + r'u6307' + DELIM: '🈯', + DELIM + r'ideograph_advantage' + DELIM: '🉐', + DELIM + r'u5272' + DELIM: '🈹', + DELIM + r'u7121' + DELIM: '🈚', + DELIM + r'u7981' + DELIM: '🈲', + DELIM + r'accept' + DELIM: '🉑', + DELIM + r'u7533' + DELIM: '🈸', + DELIM + r'u5408' + DELIM: '🈴', + DELIM + r'u7a7a' + DELIM: '🈳', + DELIM + r'congratulations' + DELIM: '㊗️', + DELIM + r'secret' + DELIM: '㊙️', + DELIM + r'u55b6' + DELIM: '🈺', + DELIM + r'u6e80' + DELIM: '🈵', + + # + # Geometric + # + DELIM + r'red_circle' + DELIM: '🔴', + DELIM + r'orange_circle' + DELIM: '🟠', + DELIM + r'yellow_circle' + DELIM: '🟡', + DELIM + r'green_circle' + DELIM: '🟢', + DELIM + r'large_blue_circle' + DELIM: '🔵', + DELIM + r'purple_circle' + DELIM: '🟣', + DELIM + r'brown_circle' + DELIM: '🟤', + DELIM + r'black_circle' + DELIM: '⚫', + DELIM + r'white_circle' + DELIM: '⚪', + DELIM + r'red_square' + DELIM: '🟥', + DELIM + r'orange_square' + DELIM: '🟧', + DELIM + r'yellow_square' + DELIM: '🟨', + DELIM + r'green_square' + DELIM: '🟩', + DELIM + r'blue_square' + DELIM: '🟦', + DELIM + r'purple_square' + DELIM: '🟪', + DELIM + r'brown_square' + DELIM: '🟫', + DELIM + r'black_large_square' + DELIM: '⬛', + DELIM + r'white_large_square' + DELIM: '⬜', + DELIM + r'black_medium_square' + DELIM: '◼️', + DELIM + r'white_medium_square' + DELIM: '◻️', + DELIM + r'black_medium_small_square' + DELIM: '◾', + DELIM + r'white_medium_small_square' + DELIM: '◽', + DELIM + r'black_small_square' + DELIM: '▪️', + DELIM + r'white_small_square' + DELIM: '▫️', + DELIM + r'large_orange_diamond' + DELIM: '🔶', + DELIM + r'large_blue_diamond' + DELIM: '🔷', + DELIM + r'small_orange_diamond' + DELIM: '🔸', + DELIM + r'small_blue_diamond' + DELIM: '🔹', + DELIM + r'small_red_triangle' + DELIM: '🔺', + DELIM + r'small_red_triangle_down' + DELIM: '🔻', + DELIM + r'diamond_shape_with_a_dot_inside' + DELIM: '💠', + DELIM + r'radio_button' + DELIM: '🔘', + DELIM + r'white_square_button' + DELIM: '🔳', + DELIM + r'black_square_button' + DELIM: '🔲', + + # + # Flag + # + DELIM + r'checkered_flag' + DELIM: '🏁', + DELIM + r'triangular_flag_on_post' + DELIM: '🚩', + DELIM + r'crossed_flags' + DELIM: '🎌', + DELIM + r'black_flag' + DELIM: '🏴', + DELIM + r'white_flag' + DELIM: '🏳️', + DELIM + r'rainbow_flag' + DELIM: '🏳️‍🌈', + DELIM + r'transgender_flag' + DELIM: '🏳️‍⚧️', + DELIM + r'pirate_flag' + DELIM: '🏴‍☠️', + + # + # Country Flag + # + DELIM + r'ascension_island' + DELIM: '🇦🇨', + DELIM + r'andorra' + DELIM: '🇦🇩', + DELIM + r'united_arab_emirates' + DELIM: '🇦🇪', + DELIM + r'afghanistan' + DELIM: '🇦🇫', + DELIM + r'antigua_barbuda' + DELIM: '🇦🇬', + DELIM + r'anguilla' + DELIM: '🇦🇮', + DELIM + r'albania' + DELIM: '🇦🇱', + DELIM + r'armenia' + DELIM: '🇦🇲', + DELIM + r'angola' + DELIM: '🇦🇴', + DELIM + r'antarctica' + DELIM: '🇦🇶', + DELIM + r'argentina' + DELIM: '🇦🇷', + DELIM + r'american_samoa' + DELIM: '🇦🇸', + DELIM + r'austria' + DELIM: '🇦🇹', + DELIM + r'australia' + DELIM: '🇦🇺', + DELIM + r'aruba' + DELIM: '🇦🇼', + DELIM + r'aland_islands' + DELIM: '🇦🇽', + DELIM + r'azerbaijan' + DELIM: '🇦🇿', + DELIM + r'bosnia_herzegovina' + DELIM: '🇧🇦', + DELIM + r'barbados' + DELIM: '🇧🇧', + DELIM + r'bangladesh' + DELIM: '🇧🇩', + DELIM + r'belgium' + DELIM: '🇧🇪', + DELIM + r'burkina_faso' + DELIM: '🇧🇫', + DELIM + r'bulgaria' + DELIM: '🇧🇬', + DELIM + r'bahrain' + DELIM: '🇧🇭', + DELIM + r'burundi' + DELIM: '🇧🇮', + DELIM + r'benin' + DELIM: '🇧🇯', + DELIM + r'st_barthelemy' + DELIM: '🇧🇱', + DELIM + r'bermuda' + DELIM: '🇧🇲', + DELIM + r'brunei' + DELIM: '🇧🇳', + DELIM + r'bolivia' + DELIM: '🇧🇴', + DELIM + r'caribbean_netherlands' + DELIM: '🇧🇶', + DELIM + r'brazil' + DELIM: '🇧🇷', + DELIM + r'bahamas' + DELIM: '🇧🇸', + DELIM + r'bhutan' + DELIM: '🇧🇹', + DELIM + r'bouvet_island' + DELIM: '🇧🇻', + DELIM + r'botswana' + DELIM: '🇧🇼', + DELIM + r'belarus' + DELIM: '🇧🇾', + DELIM + r'belize' + DELIM: '🇧🇿', + DELIM + r'canada' + DELIM: '🇨🇦', + DELIM + r'cocos_islands' + DELIM: '🇨🇨', + DELIM + r'congo_kinshasa' + DELIM: '🇨🇩', + DELIM + r'central_african_republic' + DELIM: '🇨🇫', + DELIM + r'congo_brazzaville' + DELIM: '🇨🇬', + DELIM + r'switzerland' + DELIM: '🇨🇭', + DELIM + r'cote_divoire' + DELIM: '🇨🇮', + DELIM + r'cook_islands' + DELIM: '🇨🇰', + DELIM + r'chile' + DELIM: '🇨🇱', + DELIM + r'cameroon' + DELIM: '🇨🇲', + DELIM + r'cn' + DELIM: '🇨🇳', + DELIM + r'colombia' + DELIM: '🇨🇴', + DELIM + r'clipperton_island' + DELIM: '🇨🇵', + DELIM + r'costa_rica' + DELIM: '🇨🇷', + DELIM + r'cuba' + DELIM: '🇨🇺', + DELIM + r'cape_verde' + DELIM: '🇨🇻', + DELIM + r'curacao' + DELIM: '🇨🇼', + DELIM + r'christmas_island' + DELIM: '🇨🇽', + DELIM + r'cyprus' + DELIM: '🇨🇾', + DELIM + r'czech_republic' + DELIM: '🇨🇿', + DELIM + r'de' + DELIM: '🇩🇪', + DELIM + r'diego_garcia' + DELIM: '🇩🇬', + DELIM + r'djibouti' + DELIM: '🇩🇯', + DELIM + r'denmark' + DELIM: '🇩🇰', + DELIM + r'dominica' + DELIM: '🇩🇲', + DELIM + r'dominican_republic' + DELIM: '🇩🇴', + DELIM + r'algeria' + DELIM: '🇩🇿', + DELIM + r'ceuta_melilla' + DELIM: '🇪🇦', + DELIM + r'ecuador' + DELIM: '🇪🇨', + DELIM + r'estonia' + DELIM: '🇪🇪', + DELIM + r'egypt' + DELIM: '🇪🇬', + DELIM + r'western_sahara' + DELIM: '🇪🇭', + DELIM + r'eritrea' + DELIM: '🇪🇷', + DELIM + r'es' + DELIM: '🇪🇸', + DELIM + r'ethiopia' + DELIM: '🇪🇹', + DELIM + r'(eu|european_union)' + DELIM: '🇪🇺', + DELIM + r'finland' + DELIM: '🇫🇮', + DELIM + r'fiji' + DELIM: '🇫🇯', + DELIM + r'falkland_islands' + DELIM: '🇫🇰', + DELIM + r'micronesia' + DELIM: '🇫🇲', + DELIM + r'faroe_islands' + DELIM: '🇫🇴', + DELIM + r'fr' + DELIM: '🇫🇷', + DELIM + r'gabon' + DELIM: '🇬🇦', + DELIM + r'(uk|gb)' + DELIM: '🇬🇧', + DELIM + r'grenada' + DELIM: '🇬🇩', + DELIM + r'georgia' + DELIM: '🇬🇪', + DELIM + r'french_guiana' + DELIM: '🇬🇫', + DELIM + r'guernsey' + DELIM: '🇬🇬', + DELIM + r'ghana' + DELIM: '🇬🇭', + DELIM + r'gibraltar' + DELIM: '🇬🇮', + DELIM + r'greenland' + DELIM: '🇬🇱', + DELIM + r'gambia' + DELIM: '🇬🇲', + DELIM + r'guinea' + DELIM: '🇬🇳', + DELIM + r'guadeloupe' + DELIM: '🇬🇵', + DELIM + r'equatorial_guinea' + DELIM: '🇬🇶', + DELIM + r'greece' + DELIM: '🇬🇷', + DELIM + r'south_georgia_south_sandwich_islands' + DELIM: '🇬🇸', + DELIM + r'guatemala' + DELIM: '🇬🇹', + DELIM + r'guam' + DELIM: '🇬🇺', + DELIM + r'guinea_bissau' + DELIM: '🇬🇼', + DELIM + r'guyana' + DELIM: '🇬🇾', + DELIM + r'hong_kong' + DELIM: '🇭🇰', + DELIM + r'heard_mcdonald_islands' + DELIM: '🇭🇲', + DELIM + r'honduras' + DELIM: '🇭🇳', + DELIM + r'croatia' + DELIM: '🇭🇷', + DELIM + r'haiti' + DELIM: '🇭🇹', + DELIM + r'hungary' + DELIM: '🇭🇺', + DELIM + r'canary_islands' + DELIM: '🇮🇨', + DELIM + r'indonesia' + DELIM: '🇮🇩', + DELIM + r'ireland' + DELIM: '🇮🇪', + DELIM + r'israel' + DELIM: '🇮🇱', + DELIM + r'isle_of_man' + DELIM: '🇮🇲', + DELIM + r'india' + DELIM: '🇮🇳', + DELIM + r'british_indian_ocean_territory' + DELIM: '🇮🇴', + DELIM + r'iraq' + DELIM: '🇮🇶', + DELIM + r'iran' + DELIM: '🇮🇷', + DELIM + r'iceland' + DELIM: '🇮🇸', + DELIM + r'it' + DELIM: '🇮🇹', + DELIM + r'jersey' + DELIM: '🇯🇪', + DELIM + r'jamaica' + DELIM: '🇯🇲', + DELIM + r'jordan' + DELIM: '🇯🇴', + DELIM + r'jp' + DELIM: '🇯🇵', + DELIM + r'kenya' + DELIM: '🇰🇪', + DELIM + r'kyrgyzstan' + DELIM: '🇰🇬', + DELIM + r'cambodia' + DELIM: '🇰🇭', + DELIM + r'kiribati' + DELIM: '🇰🇮', + DELIM + r'comoros' + DELIM: '🇰🇲', + DELIM + r'st_kitts_nevis' + DELIM: '🇰🇳', + DELIM + r'north_korea' + DELIM: '🇰🇵', + DELIM + r'kr' + DELIM: '🇰🇷', + DELIM + r'kuwait' + DELIM: '🇰🇼', + DELIM + r'cayman_islands' + DELIM: '🇰🇾', + DELIM + r'kazakhstan' + DELIM: '🇰🇿', + DELIM + r'laos' + DELIM: '🇱🇦', + DELIM + r'lebanon' + DELIM: '🇱🇧', + DELIM + r'st_lucia' + DELIM: '🇱🇨', + DELIM + r'liechtenstein' + DELIM: '🇱🇮', + DELIM + r'sri_lanka' + DELIM: '🇱🇰', + DELIM + r'liberia' + DELIM: '🇱🇷', + DELIM + r'lesotho' + DELIM: '🇱🇸', + DELIM + r'lithuania' + DELIM: '🇱🇹', + DELIM + r'luxembourg' + DELIM: '🇱🇺', + DELIM + r'latvia' + DELIM: '🇱🇻', + DELIM + r'libya' + DELIM: '🇱🇾', + DELIM + r'morocco' + DELIM: '🇲🇦', + DELIM + r'monaco' + DELIM: '🇲🇨', + DELIM + r'moldova' + DELIM: '🇲🇩', + DELIM + r'montenegro' + DELIM: '🇲🇪', + DELIM + r'st_martin' + DELIM: '🇲🇫', + DELIM + r'madagascar' + DELIM: '🇲🇬', + DELIM + r'marshall_islands' + DELIM: '🇲🇭', + DELIM + r'macedonia' + DELIM: '🇲🇰', + DELIM + r'mali' + DELIM: '🇲🇱', + DELIM + r'myanmar' + DELIM: '🇲🇲', + DELIM + r'mongolia' + DELIM: '🇲🇳', + DELIM + r'macau' + DELIM: '🇲🇴', + DELIM + r'northern_mariana_islands' + DELIM: '🇲🇵', + DELIM + r'martinique' + DELIM: '🇲🇶', + DELIM + r'mauritania' + DELIM: '🇲🇷', + DELIM + r'montserrat' + DELIM: '🇲🇸', + DELIM + r'malta' + DELIM: '🇲🇹', + DELIM + r'mauritius' + DELIM: '🇲🇺', + DELIM + r'maldives' + DELIM: '🇲🇻', + DELIM + r'malawi' + DELIM: '🇲🇼', + DELIM + r'mexico' + DELIM: '🇲🇽', + DELIM + r'malaysia' + DELIM: '🇲🇾', + DELIM + r'mozambique' + DELIM: '🇲🇿', + DELIM + r'namibia' + DELIM: '🇳🇦', + DELIM + r'new_caledonia' + DELIM: '🇳🇨', + DELIM + r'niger' + DELIM: '🇳🇪', + DELIM + r'norfolk_island' + DELIM: '🇳🇫', + DELIM + r'nigeria' + DELIM: '🇳🇬', + DELIM + r'nicaragua' + DELIM: '🇳🇮', + DELIM + r'netherlands' + DELIM: '🇳🇱', + DELIM + r'norway' + DELIM: '🇳🇴', + DELIM + r'nepal' + DELIM: '🇳🇵', + DELIM + r'nauru' + DELIM: '🇳🇷', + DELIM + r'niue' + DELIM: '🇳🇺', + DELIM + r'new_zealand' + DELIM: '🇳🇿', + DELIM + r'oman' + DELIM: '🇴🇲', + DELIM + r'panama' + DELIM: '🇵🇦', + DELIM + r'peru' + DELIM: '🇵🇪', + DELIM + r'french_polynesia' + DELIM: '🇵🇫', + DELIM + r'papua_new_guinea' + DELIM: '🇵🇬', + DELIM + r'philippines' + DELIM: '🇵🇭', + DELIM + r'pakistan' + DELIM: '🇵🇰', + DELIM + r'poland' + DELIM: '🇵🇱', + DELIM + r'st_pierre_miquelon' + DELIM: '🇵🇲', + DELIM + r'pitcairn_islands' + DELIM: '🇵🇳', + DELIM + r'puerto_rico' + DELIM: '🇵🇷', + DELIM + r'palestinian_territories' + DELIM: '🇵🇸', + DELIM + r'portugal' + DELIM: '🇵🇹', + DELIM + r'palau' + DELIM: '🇵🇼', + DELIM + r'paraguay' + DELIM: '🇵🇾', + DELIM + r'qatar' + DELIM: '🇶🇦', + DELIM + r'reunion' + DELIM: '🇷🇪', + DELIM + r'romania' + DELIM: '🇷🇴', + DELIM + r'serbia' + DELIM: '🇷🇸', + DELIM + r'ru' + DELIM: '🇷🇺', + DELIM + r'rwanda' + DELIM: '🇷🇼', + DELIM + r'saudi_arabia' + DELIM: '🇸🇦', + DELIM + r'solomon_islands' + DELIM: '🇸🇧', + DELIM + r'seychelles' + DELIM: '🇸🇨', + DELIM + r'sudan' + DELIM: '🇸🇩', + DELIM + r'sweden' + DELIM: '🇸🇪', + DELIM + r'singapore' + DELIM: '🇸🇬', + DELIM + r'st_helena' + DELIM: '🇸🇭', + DELIM + r'slovenia' + DELIM: '🇸🇮', + DELIM + r'svalbard_jan_mayen' + DELIM: '🇸🇯', + DELIM + r'slovakia' + DELIM: '🇸🇰', + DELIM + r'sierra_leone' + DELIM: '🇸🇱', + DELIM + r'san_marino' + DELIM: '🇸🇲', + DELIM + r'senegal' + DELIM: '🇸🇳', + DELIM + r'somalia' + DELIM: '🇸🇴', + DELIM + r'suriname' + DELIM: '🇸🇷', + DELIM + r'south_sudan' + DELIM: '🇸🇸', + DELIM + r'sao_tome_principe' + DELIM: '🇸🇹', + DELIM + r'el_salvador' + DELIM: '🇸🇻', + DELIM + r'sint_maarten' + DELIM: '🇸🇽', + DELIM + r'syria' + DELIM: '🇸🇾', + DELIM + r'swaziland' + DELIM: '🇸🇿', + DELIM + r'tristan_da_cunha' + DELIM: '🇹🇦', + DELIM + r'turks_caicos_islands' + DELIM: '🇹🇨', + DELIM + r'chad' + DELIM: '🇹🇩', + DELIM + r'french_southern_territories' + DELIM: '🇹🇫', + DELIM + r'togo' + DELIM: '🇹🇬', + DELIM + r'thailand' + DELIM: '🇹🇭', + DELIM + r'tajikistan' + DELIM: '🇹🇯', + DELIM + r'tokelau' + DELIM: '🇹🇰', + DELIM + r'timor_leste' + DELIM: '🇹🇱', + DELIM + r'turkmenistan' + DELIM: '🇹🇲', + DELIM + r'tunisia' + DELIM: '🇹🇳', + DELIM + r'tonga' + DELIM: '🇹🇴', + DELIM + r'tr' + DELIM: '🇹🇷', + DELIM + r'trinidad_tobago' + DELIM: '🇹🇹', + DELIM + r'tuvalu' + DELIM: '🇹🇻', + DELIM + r'taiwan' + DELIM: '🇹🇼', + DELIM + r'tanzania' + DELIM: '🇹🇿', + DELIM + r'ukraine' + DELIM: '🇺🇦', + DELIM + r'uganda' + DELIM: '🇺🇬', + DELIM + r'us_outlying_islands' + DELIM: '🇺🇲', + DELIM + r'united_nations' + DELIM: '🇺🇳', + DELIM + r'us' + DELIM: '🇺🇸', + DELIM + r'uruguay' + DELIM: '🇺🇾', + DELIM + r'uzbekistan' + DELIM: '🇺🇿', + DELIM + r'vatican_city' + DELIM: '🇻🇦', + DELIM + r'st_vincent_grenadines' + DELIM: '🇻🇨', + DELIM + r'venezuela' + DELIM: '🇻🇪', + DELIM + r'british_virgin_islands' + DELIM: '🇻🇬', + DELIM + r'us_virgin_islands' + DELIM: '🇻🇮', + DELIM + r'vietnam' + DELIM: '🇻🇳', + DELIM + r'vanuatu' + DELIM: '🇻🇺', + DELIM + r'wallis_futuna' + DELIM: '🇼🇫', + DELIM + r'samoa' + DELIM: '🇼🇸', + DELIM + r'kosovo' + DELIM: '🇽🇰', + DELIM + r'yemen' + DELIM: '🇾🇪', + DELIM + r'mayotte' + DELIM: '🇾🇹', + DELIM + r'south_africa' + DELIM: '🇿🇦', + DELIM + r'zambia' + DELIM: '🇿🇲', + DELIM + r'zimbabwe' + DELIM: '🇿🇼', + + # + # Subdivision Flag + # + DELIM + r'england' + DELIM: '🏴󠁧󠁢󠁥󠁮󠁧󠁿', + DELIM + r'scotland' + DELIM: '🏴󠁧󠁢󠁳󠁣󠁴󠁿', + DELIM + r'wales' + DELIM: '🏴󠁧󠁢󠁷󠁬󠁳󠁿', +} + +# Define our singlton +EMOJI_COMPILED_MAP = None + + +def apply_emojis(content): + """ + Takes the content and swaps any matched emoji's found with their + utf-8 encoded mapping + """ + + global EMOJI_COMPILED_MAP + + if EMOJI_COMPILED_MAP is None: + t_start = time.time() + # Perform our compilation + EMOJI_COMPILED_MAP = re.compile( + r'(' + '|'.join(EMOJI_MAP.keys()) + r')', + re.IGNORECASE) + logger.trace( + 'Emoji engine loaded in {:.4f}s'.format((time.time() - t_start))) + + try: + return EMOJI_COMPILED_MAP.sub(lambda x: EMOJI_MAP[x.group()], content) + + except TypeError: + # No change; but force string return + return '' diff --git a/lib/apprise/i18n/apprise.pot b/lib/apprise/i18n/apprise.pot index 434ce91d..f81df84d 100644 --- a/lib/apprise/i18n/apprise.pot +++ b/lib/apprise/i18n/apprise.pot @@ -1,21 +1,21 @@ # Translations template for apprise. -# Copyright (C) 2023 Chris Caron +# Copyright (C) 2024 Chris Caron # This file is distributed under the same license as the apprise project. -# FIRST AUTHOR , 2023. +# FIRST AUTHOR , 2024. # #, fuzzy msgid "" msgstr "" -"Project-Id-Version: apprise 1.6.0\n" +"Project-Id-Version: apprise 1.8.0\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" -"POT-Creation-Date: 2023-10-15 15:56-0400\n" +"POT-Creation-Date: 2024-05-11 16:13-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.11.0\n" +"Generated-By: Babel 2.13.1\n" msgid "A local Gnome environment is required." msgstr "" @@ -32,6 +32,9 @@ msgstr "" msgid "API Secret" msgstr "" +msgid "API Token" +msgstr "" + msgid "Access Key" msgstr "" @@ -101,9 +104,6 @@ msgstr "" msgid "Authentication Type" msgstr "" -msgid "Authorization Token" -msgstr "" - msgid "Avatar Image" msgstr "" @@ -125,6 +125,9 @@ msgstr "" msgid "Bot Token" msgstr "" +msgid "Bot Webhook Key" +msgstr "" + msgid "Cache Age" msgstr "" @@ -140,9 +143,15 @@ msgstr "" msgid "Category" msgstr "" +msgid "Channel ID" +msgstr "" + msgid "Channels" msgstr "" +msgid "Chantify" +msgstr "" + msgid "Class" msgstr "" @@ -230,6 +239,9 @@ msgstr "" msgid "Email Header" msgstr "" +msgid "Embed URL" +msgstr "" + msgid "Entity" msgstr "" @@ -245,6 +257,9 @@ msgstr "" msgid "Facility" msgstr "" +msgid "Feishu" +msgstr "" + msgid "Fetch Method" msgstr "" @@ -266,6 +281,9 @@ msgstr "" msgid "Forced Mime Type" msgstr "" +msgid "Free-Mobile" +msgstr "" + msgid "From Email" msgstr "" @@ -281,6 +299,12 @@ msgstr "" msgid "GET Params" msgstr "" +msgid "Gateway" +msgstr "" + +msgid "Gateway ID" +msgstr "" + msgid "Gnome Notification" msgstr "" @@ -299,6 +323,9 @@ msgstr "" msgid "Icon Type" msgstr "" +msgid "Icon URL" +msgstr "" + msgid "Idempotency-Key" msgstr "" @@ -323,6 +350,9 @@ msgstr "" msgid "Integration Key" msgstr "" +msgid "Interpret Emojis" +msgstr "" + msgid "Is Ad?" msgstr "" @@ -344,6 +374,9 @@ msgstr "" msgid "Local File" msgstr "" +msgid "Locale" +msgstr "" + msgid "Log PID" msgstr "" @@ -356,6 +389,9 @@ msgstr "" msgid "MacOSX Notification" msgstr "" +msgid "Markdown Version" +msgstr "" + msgid "Master Key" msgstr "" @@ -490,6 +526,9 @@ msgstr "" msgid "Reply To Email" msgstr "" +msgid "Resend Delay" +msgstr "" + msgid "Resubmit Flag" msgstr "" @@ -661,6 +700,9 @@ msgstr "" msgid "Target Team" msgstr "" +msgid "Target Threema ID" +msgstr "" + msgid "Target Topic" msgstr "" @@ -757,6 +799,9 @@ msgstr "" msgid "Unicode Characters" msgstr "" +msgid "Upload" +msgstr "" + msgid "Urgency" msgstr "" @@ -775,9 +820,6 @@ msgstr "" msgid "User Email" msgstr "" -msgid "User ID" -msgstr "" - msgid "User Key" msgstr "" diff --git a/lib/apprise/AppriseLocale.py b/lib/apprise/locale.py similarity index 97% rename from lib/apprise/AppriseLocale.py rename to lib/apprise/locale.py index c80afae2..e900ce5b 100644 --- a/lib/apprise/AppriseLocale.py +++ b/lib/apprise/locale.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -219,6 +219,9 @@ class AppriseLocale: try: # Acquire our locale lang = locale.getlocale()[0] + # Compatibility for Python >= 3.12 + if lang == 'C': + lang = AppriseLocale._default_language except (ValueError, TypeError) as e: # This occurs when an invalid locale was parsed from the diff --git a/lib/apprise/logger.py b/lib/apprise/logger.py index 6a594ec6..d9efe47c 100644 --- a/lib/apprise/logger.py +++ b/lib/apprise/logger.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: diff --git a/lib/apprise/manager.py b/lib/apprise/manager.py new file mode 100644 index 00000000..70dc1070 --- /dev/null +++ b/lib/apprise/manager.py @@ -0,0 +1,756 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2024, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +import os +import re +import sys +import time +import hashlib +import inspect +import threading +from .utils import import_module +from .utils import Singleton +from .utils import parse_list +from os.path import dirname +from os.path import abspath +from os.path import join + +from .logger import logger + + +class PluginManager(metaclass=Singleton): + """ + Designed to be a singleton object to maintain all initialized loading + of modules in memory. + """ + + # Description (used for logging) + name = 'Singleton Plugin' + + # Memory Space + _id = 'undefined' + + # Our Module Python path name + module_name_prefix = f'apprise.{_id}' + + # The module path to scan + module_path = join(abspath(dirname(__file__)), _id) + + # For filtering our result when scanning a module + module_filter_re = re.compile(r'^(?P((?!_)[A-Za-z0-9]+))$') + + # thread safe loading + _lock = threading.Lock() + + def __init__(self, *args, **kwargs): + """ + Over-ride our class instantiation to provide a singleton + """ + + self._module_map = None + self._schema_map = None + + # This contains a mapping of all plugins dynamicaly loaded at runtime + # from external modules such as the @notify decorator + # + # The elements here will be additionally added to the _schema_map if + # there is no conflict otherwise. + # The structure looks like the following: + # Module path, e.g. /usr/share/apprise/plugins/my_notify_hook.py + # { + # 'path': path, + # + # 'notify': { + # 'schema': { + # 'name': 'Custom schema name', + # 'fn_name': 'name_of_function_decorator_was_found_on', + # 'url': 'schema://any/additional/info/found/on/url' + # 'plugin': + # }, + # 'schema2': { + # 'name': 'Custom schema name', + # 'fn_name': 'name_of_function_decorator_was_found_on', + # 'url': 'schema://any/additional/info/found/on/url' + # 'plugin': + # } + # } + # Note: that the inherits from + # NotifyBase + self._custom_module_map = {} + + # Track manually disabled modules (by their schema) + self._disabled = set() + + # Hash of all paths previously scanned so we don't waste + # effort/overhead doing it again + self._paths_previously_scanned = set() + + # Track loaded module paths to prevent from loading them again + self._loaded = set() + + def unload_modules(self, disable_native=False): + """ + Reset our object and unload all modules + """ + + with self._lock: + if self._custom_module_map: + # Handle Custom Module Assignments + for meta in self._custom_module_map.values(): + if meta['name'] not in self._module_map: + # Nothing to remove + continue + + # For the purpose of tidying up un-used modules in memory + loaded = [m for m in sys.modules.keys() + if m.startswith( + self._module_map[meta['name']]['path'])] + + for module_path in loaded: + del sys.modules[module_path] + + # Reset disabled plugins (if any) + for schema in self._disabled: + self._schema_map[schema].enabled = True + self._disabled.clear() + + # Reset our variables + self._schema_map = {} + self._custom_module_map = {} + if disable_native: + self._module_map = {} + + else: + self._module_map = None + self._loaded = set() + + # Reset our path cache + self._paths_previously_scanned = set() + + def load_modules(self, path=None, name=None, force=False): + """ + Load our modules into memory + """ + + # Default value + module_name_prefix = self.module_name_prefix if name is None else name + module_path = self.module_path if path is None else path + + with self._lock: + if not force and module_path in self._loaded: + # We're done + return + + # Our base reference + module_count = len(self._module_map) if self._module_map else 0 + schema_count = len(self._schema_map) if self._schema_map else 0 + + if not self: + # Initialize our maps + self._module_map = {} + self._schema_map = {} + self._custom_module_map = {} + + # Used for the detection of additional Notify Services objects + # The .py extension is optional as we support loading directories + # too + module_re = re.compile( + r'^(?P(?!base|_)[a-z0-9_]+)(\.py)?$', + re.I) + + t_start = time.time() + for f in os.listdir(module_path): + tl_start = time.time() + match = module_re.match(f) + if not match: + # keep going + continue + + # Store our notification/plugin name: + module_name = match.group('name') + module_pyname = '{}.{}'.format(module_name_prefix, module_name) + + if module_name in self._module_map: + logger.warning( + "%s(s) (%s) already loaded; ignoring %s", + self.name, module_name, os.path.join(module_path, f)) + continue + + try: + module = __import__( + module_pyname, + globals(), locals(), + fromlist=[module_name]) + + except ImportError: + # No problem, we can try again another way... + module = import_module( + os.path.join(module_path, f), module_pyname) + if not module: + # logging found in import_module and not needed here + continue + + module_class = None + for m_class in [obj for obj in dir(module) + if self.module_filter_re.match(obj)]: + # Get our plugin + plugin = getattr(module, m_class) + if not hasattr(plugin, 'app_id'): + # Filter out non-notification modules + logger.trace( + "(%s) import failed; no app_id defined in %s", + self.name, m_class, os.path.join(module_path, f)) + continue + + # Add our plugin name to our module map + self._module_map[module_name] = { + 'plugin': set([plugin]), + 'module': module, + 'path': '{}.{}'.format( + module_name_prefix, module_name), + 'native': True, + } + + fn = getattr(plugin, 'schemas', None) + schemas = set([]) if not callable(fn) else fn(plugin) + + # map our schema to our plugin + for schema in schemas: + if schema in self._schema_map: + logger.error( + "{} schema ({}) mismatch detected - {} to {}" + .format(self.name, schema, self._schema_map, + plugin)) + continue + + # Assign plugin + self._schema_map[schema] = plugin + + # Store our class + module_class = m_class + break + + if not module_class: + # Not a library we can load as it doesn't follow the simple + # rule that the class must bear the same name as the + # notification file itself. + logger.trace( + "%s (%s) import failed; no filename/Class " + "match found in %s", + self.name, module_name, os.path.join(module_path, f)) + continue + + logger.trace( + '{} {} loaded in {:.6f}s'.format( + self.name, module_name, (time.time() - tl_start))) + + # Track the directory loaded so we never load it again + self._loaded.add(module_path) + + logger.debug( + '{} {}(s) and {} Schema(s) loaded in {:.4f}s' + .format( + self.name, + len(self._module_map) - module_count, + len(self._schema_map) - schema_count, + (time.time() - t_start))) + + def module_detection(self, paths, cache=True): + """ + Leverage the @notify decorator and load all objects found matching + this. + """ + # A simple restriction that we don't allow periods in the filename at + # all so it can't be hidden (Linux OS's) and it won't conflict with + # Python path naming. This also prevents us from loading any python + # file that starts with an underscore or dash + # We allow for __init__.py as well + module_re = re.compile( + r'^(?P[_a-z0-9][a-z0-9._-]+)?(\.py)?$', re.I) + + # Validate if we're a loadable Python file or not + valid_python_file_re = re.compile(r'.+\.py(o|c)?$', re.IGNORECASE) + + if isinstance(paths, str): + paths = [paths, ] + + if not paths or not isinstance(paths, (tuple, list)): + # We're done + return + + def _import_module(path): + # Since our plugin name can conflict (as a module) with another + # we want to generate random strings to avoid steping on + # another's namespace + if not (path and valid_python_file_re.match(path)): + # Ignore file/module type + logger.trace('Plugin Scan: Skipping %s', path) + return + + t_start = time.time() + module_name = hashlib.sha1(path.encode('utf-8')).hexdigest() + module_pyname = "{prefix}.{name}".format( + prefix='apprise.custom.module', name=module_name) + + if module_pyname in self._custom_module_map: + # First clear out existing entries + for schema in \ + self._custom_module_map[module_pyname]['notify']\ + .keys(): + + # Remove any mapped modules to this file + del self._schema_map[schema] + + # Reset + del self._custom_module_map[module_pyname] + + # Load our module + module = import_module(path, module_pyname) + if not module: + # No problem, we can't use this object + logger.warning('Failed to load custom module: %s', _path) + return + + # Print our loaded modules if any + if module_pyname in self._custom_module_map: + logger.debug( + 'Custom module %s - %d schema(s) (name=%s) ' + 'loaded in %.6fs', _path, + len(self._custom_module_map[module_pyname]['notify']), + module_name, (time.time() - t_start)) + + # Add our plugin name to our module map + self._module_map[module_name] = { + 'plugin': set(), + 'module': module, + 'path': module_pyname, + 'native': False, + } + + for schema, meta in\ + self._custom_module_map[module_pyname]['notify']\ + .items(): + + # For mapping purposes; map our element in our main list + self._module_map[module_name]['plugin'].add( + self._schema_map[schema]) + + # Log our success + logger.info('Loaded custom notification: %s://', schema) + else: + # The code reaches here if we successfully loaded the Python + # module but no hooks/triggers were found. So we can safely + # just remove/ignore this entry + del sys.modules[module_pyname] + return + + # end of _import_module() + return + + for _path in paths: + path = os.path.abspath(os.path.expanduser(_path)) + if (cache and path in self._paths_previously_scanned) \ + or not os.path.exists(path): + # We're done as we've already scanned this + continue + + # Store our path as a way of hashing it has been handled + self._paths_previously_scanned.add(path) + + if os.path.isdir(path) and not \ + os.path.isfile(os.path.join(path, '__init__.py')): + + logger.debug('Scanning for custom plugins in: %s', path) + for entry in os.listdir(path): + re_match = module_re.match(entry) + if not re_match: + # keep going + logger.trace('Plugin Scan: Ignoring %s', entry) + continue + + new_path = os.path.join(path, entry) + if os.path.isdir(new_path): + # Update our path + new_path = os.path.join(path, entry, '__init__.py') + if not os.path.isfile(new_path): + logger.trace( + 'Plugin Scan: Ignoring %s', + os.path.join(path, entry)) + continue + + if not cache or \ + (cache and new_path not in + self._paths_previously_scanned): + # Load our module + _import_module(new_path) + + # Add our subdir path + self._paths_previously_scanned.add(new_path) + else: + if os.path.isdir(path): + # This logic is safe to apply because we already + # validated the directories state above; update our + # path + path = os.path.join(path, '__init__.py') + if cache and path in self._paths_previously_scanned: + continue + + self._paths_previously_scanned.add(path) + + # directly load as is + re_match = module_re.match(os.path.basename(path)) + # must be a match and must have a .py extension + if not re_match or not re_match.group(1): + # keep going + logger.trace('Plugin Scan: Ignoring %s', path) + continue + + # Load our module + _import_module(path) + + return None + + def add(self, plugin, schemas=None, url=None, send_func=None): + """ + Ability to manually add Notification services to our stack + """ + + if not self: + # Lazy load + self.load_modules() + + # Acquire a list of schemas + p_schemas = parse_list(plugin.secure_protocol, plugin.protocol) + if isinstance(schemas, str): + schemas = [schemas, ] + + elif schemas is None: + # Default + schemas = p_schemas + + if not schemas or not isinstance(schemas, (set, tuple, list)): + # We're done + logger.error( + 'The schemas provided (type %s) is unsupported; ' + 'loaded from %s.', + type(schemas), + send_func.__name__ if send_func else plugin.__class__.__name__) + return False + + # Convert our schemas into a set + schemas = set([s.lower() for s in schemas]) | set(p_schemas) + + # Valdation + conflict = [s for s in schemas if s in self] + if conflict: + # we're already handling this schema + logger.warning( + 'The schema(s) (%s) are already defined and could not be ' + 'loaded from %s%s.', + ', '.join(conflict), + 'custom notify function ' if send_func else '', + send_func.__name__ if send_func else plugin.__class__.__name__) + return False + + if send_func: + # Acquire the function name + fn_name = send_func.__name__ + + # Acquire the python filename path + path = inspect.getfile(send_func) + + # Acquire our path to our module + module_name = str(send_func.__module__) + + if module_name not in self._custom_module_map: + # Support non-dynamic includes as well... + self._custom_module_map[module_name] = { + # Name can be useful for indexing back into the + # _module_map object; this is the key to do it with: + 'name': module_name.split('.')[-1], + + # The path to the module loaded + 'path': path, + + # Initialize our template + 'notify': {}, + } + + for schema in schemas: + self._custom_module_map[module_name]['notify'][schema] = { + # The name of the send function the @notify decorator + # wrapped + 'fn_name': fn_name, + # The URL that was provided in the @notify decorator call + # associated with the 'on=' + 'url': url, + } + + else: + module_name = hashlib.sha1( + ''.join(schemas).encode('utf-8')).hexdigest() + module_pyname = "{prefix}.{name}".format( + prefix='apprise.adhoc.module', name=module_name) + + # Add our plugin name to our module map + self._module_map[module_name] = { + 'plugin': set([plugin]), + 'module': None, + 'path': module_pyname, + 'native': False, + } + + for schema in schemas: + # Assign our mapping + self._schema_map[schema] = plugin + + return True + + def remove(self, *schemas): + """ + Removes a loaded element (if defined) + """ + if not self: + # Lazy load + self.load_modules() + + for schema in schemas: + try: + del self[schema] + + except KeyError: + pass + + def plugins(self, include_disabled=True): + """ + Return all of our loaded plugins + """ + if not self: + # Lazy load + self.load_modules() + + for module in self._module_map.values(): + for plugin in module['plugin']: + if not include_disabled and not plugin.enabled: + continue + yield plugin + + def schemas(self, include_disabled=True): + """ + Return all of our loaded schemas + + if include_disabled == True, then even disabled notifications are + returned + """ + if not self: + # Lazy load + self.load_modules() + + # Return our list + return list(self._schema_map.keys()) if include_disabled else \ + [s for s in self._schema_map.keys() if self._schema_map[s].enabled] + + def disable(self, *schemas): + """ + Disables the modules associated with the specified schemas + """ + if not self: + # Lazy load + self.load_modules() + + for schema in schemas: + if schema not in self._schema_map: + continue + + if not self._schema_map[schema].enabled: + continue + + # Disable + self._schema_map[schema].enabled = False + self._disabled.add(schema) + + def enable_only(self, *schemas): + """ + Disables the modules associated with the specified schemas + """ + if not self: + # Lazy load + self.load_modules() + + # convert to set for faster indexing + schemas = set(schemas) + + for plugin in self.plugins(): + # Get our plugin's schema list + p_schemas = set( + parse_list(plugin.secure_protocol, plugin.protocol)) + + if not schemas & p_schemas: + if plugin.enabled: + # Disable it (only if previously enabled); this prevents us + # from adjusting schemas that were disabled due to missing + # libraries or other environment reasons + plugin.enabled = False + self._disabled |= p_schemas + continue + + # If we reach here, our schema was flagged to be enabled + if p_schemas & self._disabled: + # Previously disabled; no worries, let's clear this up + self._disabled -= p_schemas + plugin.enabled = True + + def __contains__(self, schema): + """ + Checks if a schema exists + """ + if not self: + # Lazy load + self.load_modules() + + return schema in self._schema_map + + def __delitem__(self, schema): + if not self: + # Lazy load + self.load_modules() + + # Get our plugin (otherwise we throw a KeyError) which is + # intended on del action that doesn't align + plugin = self._schema_map[schema] + + # Our list of all schema entries + p_schemas = set([schema]) + + for key in list(self._module_map.keys()): + if plugin in self._module_map[key]['plugin']: + # Remove our plugin + self._module_map[key]['plugin'].remove(plugin) + + # Custom Plugin Entry; Clean up cross reference + module_pyname = self._module_map[key]['path'] + if not self._module_map[key]['native'] and \ + module_pyname in self._custom_module_map: + + del self.\ + _custom_module_map[module_pyname]['notify'][schema] + + if not self._custom_module_map[module_pyname]['notify']: + # + # Last custom loaded element + # + + # Free up custom object entry + del self._custom_module_map[module_pyname] + + if not self._module_map[key]['plugin']: + # + # Last element + # + if self._module_map[key]['native']: + # Get our plugin's schema list + p_schemas = \ + set([s for s in parse_list( + plugin.secure_protocol, plugin.protocol) + if s in self._schema_map]) + + # free system memory + if self._module_map[key]['module']: + del sys.modules[self._module_map[key]['path']] + + # free last remaining pointer in module map + del self._module_map[key] + + for schema in p_schemas: + # Final Tidy + del self._schema_map[schema] + + def __setitem__(self, schema, plugin): + """ + Support fast assigning of Plugin/Notification Objects + """ + if not self: + # Lazy load + self.load_modules() + + # Set default values if not otherwise set + if not plugin.service_name: + # Assign service name if one doesn't exist + plugin.service_name = f'{schema}://' + + p_schemas = set( + parse_list(plugin.secure_protocol, plugin.protocol)) + if not p_schemas: + # Assign our protocol + plugin.secure_protocol = schema + p_schemas.add(schema) + + elif schema not in p_schemas: + # Add our others (if defined) + plugin.secure_protocol = \ + set([schema] + parse_list(plugin.secure_protocol)) + p_schemas.add(schema) + + if not self.add(plugin, schemas=p_schemas): + raise KeyError('Conflicting Assignment') + + def __getitem__(self, schema): + """ + Returns the indexed plugin identified by the schema specified + """ + if not self: + # Lazy load + self.load_modules() + + return self._schema_map[schema] + + def __iter__(self): + """ + Returns an iterator so we can iterate over our loaded modules + """ + if not self: + # Lazy load + self.load_modules() + + return iter(self._module_map.values()) + + def __len__(self): + """ + Returns the number of modules/plugins loaded + """ + if not self: + # Lazy load + self.load_modules() + + return len(self._module_map) + + def __bool__(self): + """ + Determines if object has loaded or not + """ + return True if self._loaded and self._module_map is not None else False diff --git a/lib/apprise/manager_attachment.py b/lib/apprise/manager_attachment.py new file mode 100644 index 00000000..d1288a94 --- /dev/null +++ b/lib/apprise/manager_attachment.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2024, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +import re +from os.path import dirname +from os.path import abspath +from os.path import join +from .manager import PluginManager + + +class AttachmentManager(PluginManager): + """ + Designed to be a singleton object to maintain all initialized + attachment plugins/modules in memory. + """ + + # Description (used for logging) + name = 'Attachment Plugin' + + # Filename Prefix to filter on + fname_prefix = 'Attach' + + # Memory Space + _id = 'attachment' + + # Our Module Python path name + module_name_prefix = f'apprise.{_id}' + + # The module path to scan + module_path = join(abspath(dirname(__file__)), _id) + + # For filtering our result set + module_filter_re = re.compile( + r'^(?P' + fname_prefix + r'(?!Base)[A-Za-z0-9]+)$') diff --git a/lib/apprise/manager_config.py b/lib/apprise/manager_config.py new file mode 100644 index 00000000..69a6bedb --- /dev/null +++ b/lib/apprise/manager_config.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2024, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +import re +from os.path import dirname +from os.path import abspath +from os.path import join +from .manager import PluginManager + + +class ConfigurationManager(PluginManager): + """ + Designed to be a singleton object to maintain all initialized + configuration plugins/modules in memory. + """ + + # Description (used for logging) + name = 'Configuration Plugin' + + # Filename Prefix to filter on + fname_prefix = 'Config' + + # Memory Space + _id = 'config' + + # Our Module Python path name + module_name_prefix = f'apprise.{_id}' + + # The module path to scan + module_path = join(abspath(dirname(__file__)), _id) + + # For filtering our result set + module_filter_re = re.compile( + r'^(?P' + fname_prefix + r'(?!Base)[A-Za-z0-9]+)$') diff --git a/lib/apprise/manager_plugins.py b/lib/apprise/manager_plugins.py new file mode 100644 index 00000000..74ed370e --- /dev/null +++ b/lib/apprise/manager_plugins.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2024, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +import re +from os.path import dirname +from os.path import abspath +from os.path import join +from .manager import PluginManager + + +class NotificationManager(PluginManager): + """ + Designed to be a singleton object to maintain all initialized notifications + in memory. + """ + + # Description (used for logging) + name = 'Notification Plugin' + + # Filename Prefix to filter on + fname_prefix = 'Notify' + + # Memory Space + _id = 'plugins' + + # Our Module Python path name + module_name_prefix = f'apprise.{_id}' + + # The module path to scan + module_path = join(abspath(dirname(__file__)), _id) + + # For filtering our result set + module_filter_re = re.compile( + r'^(?P' + fname_prefix + + r'(?!Base|ImageSize|Type)[A-Za-z0-9]+)$') diff --git a/lib/apprise/plugins/NotifyGrowl/gntp/__init__.py b/lib/apprise/plugins/NotifyGrowl/gntp/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/lib/apprise/plugins/NotifySpontit.py b/lib/apprise/plugins/NotifySpontit.py deleted file mode 100644 index 4705fc05..00000000 --- a/lib/apprise/plugins/NotifySpontit.py +++ /dev/null @@ -1,386 +0,0 @@ -# -*- coding: utf-8 -*- -# BSD 2-Clause License -# -# Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# 1. Redistributions of source code must retain the above copyright notice, -# this list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE -# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. - -# 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', - }, - }) - - # 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().__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)) - - def __len__(self): - """ - Returns the number of targets associated with this notification - """ - targets = len(self.targets) - return targets if targets > 0 else 1 - - @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'] = \ - NotifySpontit.unquote(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/lib/apprise/plugins/__init__.py b/lib/apprise/plugins/__init__.py index 27afef05..bfce1437 100644 --- a/lib/apprise/plugins/__init__.py +++ b/lib/apprise/plugins/__init__.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -27,26 +27,26 @@ # POSSIBILITY OF SUCH DAMAGE. import os -import re import copy -from os.path import dirname -from os.path import abspath - # Used for testing -from .NotifyBase import NotifyBase +from .base import NotifyBase from ..common import NotifyImageSize from ..common import NOTIFY_IMAGE_SIZES from ..common import NotifyType from ..common import NOTIFY_TYPES -from .. import common from ..utils import parse_list from ..utils import cwe312_url from ..utils import GET_SCHEMA_RE from ..logger import logger -from ..AppriseLocale import gettext_lazy as _ -from ..AppriseLocale import LazyTranslation +from ..locale import gettext_lazy as _ +from ..locale import LazyTranslation +from ..manager_plugins import NotificationManager + + +# Grant access to our Notification Manager Singleton +N_MGR = NotificationManager() __all__ = [ # Reference @@ -58,101 +58,6 @@ __all__ = [ ] -# Load our Lookup Matrix -def __load_matrix(path=abspath(dirname(__file__)), name='apprise.plugins'): - """ - Dynamically load our schema map; this allows us to gracefully - skip over modules we simply don't have the dependencies for. - - """ - # Used for the detection of additional Notify Services objects - # The .py extension is optional as we support loading directories too - module_re = re.compile(r'^(?PNotify[a-z0-9]+)(\.py)?$', re.I) - - for f in os.listdir(path): - match = module_re.match(f) - if not match: - # keep going - continue - - # Store our notification/plugin name: - plugin_name = match.group('name') - try: - module = __import__( - '{}.{}'.format(name, plugin_name), - globals(), locals(), - fromlist=[plugin_name]) - - except ImportError: - # No problem, we can't use this object - continue - - if not hasattr(module, plugin_name): - # Not a library we can load as it doesn't follow the simple rule - # that the class must bear the same name as the notification - # file itself. - continue - - # Get our plugin - plugin = getattr(module, plugin_name) - if not hasattr(plugin, 'app_id'): - # Filter out non-notification modules - continue - - elif plugin_name in common.NOTIFY_MODULE_MAP: - # we're already handling this object - continue - - # Add our plugin name to our module map - common.NOTIFY_MODULE_MAP[plugin_name] = { - 'plugin': plugin, - 'module': module, - } - - # Add our module name to our __all__ - __all__.append(plugin_name) - - fn = getattr(plugin, 'schemas', None) - schemas = set([]) if not callable(fn) else fn(plugin) - - # map our schema to our plugin - for schema in schemas: - if schema in common.NOTIFY_SCHEMA_MAP: - logger.error( - "Notification schema ({}) mismatch detected - {} to {}" - .format(schema, common.NOTIFY_SCHEMA_MAP[schema], plugin)) - continue - - # Assign plugin - common.NOTIFY_SCHEMA_MAP[schema] = plugin - - return common.NOTIFY_SCHEMA_MAP - - -# Reset our Lookup Matrix -def __reset_matrix(): - """ - Restores the Lookup matrix to it's base setting. This is only used through - testing and should not be directly called. - """ - - # Reset our schema map - common.NOTIFY_SCHEMA_MAP.clear() - - # Iterate over our module map so we can clear out our __all__ and globals - for plugin_name in common.NOTIFY_MODULE_MAP.keys(): - - # Remove element from plugins - __all__.remove(plugin_name) - - # Clear out our module map - common.NOTIFY_MODULE_MAP.clear() - - -# Dynamically build our schema base -__load_matrix() - - def _sanitize_token(tokens, default_delimiter): """ This is called by the details() function and santizes the output by @@ -176,6 +81,10 @@ def _sanitize_token(tokens, default_delimiter): # Do not touch this field continue + elif 'name' not in tokens[key]: + # Default to key + tokens[key]['name'] = key + if 'map_to' not in tokens[key]: # Default type to key tokens[key]['map_to'] = key @@ -538,16 +447,16 @@ def url_to_dict(url, secure_logging=True): # Ensure our schema is always in lower case schema = schema.group('schema').lower() - if schema not in common.NOTIFY_SCHEMA_MAP: + if schema not in N_MGR: # Give the user the benefit of the doubt that the user may be using # one of the URLs provided to them by their notification service. # Before we fail for good, just scan all the plugins that support the # native_url() parse function - results = \ - next((r['plugin'].parse_native_url(_url) - for r in common.NOTIFY_MODULE_MAP.values() - if r['plugin'].parse_native_url(_url) is not None), - None) + results = None + for plugin in N_MGR.plugins(): + results = plugin.parse_native_url(_url) + if results: + break if not results: logger.error('Unparseable URL {}'.format(loggable_url)) @@ -560,14 +469,14 @@ def url_to_dict(url, secure_logging=True): else: # Parse our url details of the server object as dictionary # containing all of the information parsed from our URL - results = common.NOTIFY_SCHEMA_MAP[schema].parse_url(_url) + results = N_MGR[schema].parse_url(_url) if not results: logger.error('Unparseable {} URL {}'.format( - common.NOTIFY_SCHEMA_MAP[schema].service_name, loggable_url)) + N_MGR[schema].service_name, loggable_url)) return None logger.trace('{} URL {} unpacked as:{}{}'.format( - common.NOTIFY_SCHEMA_MAP[schema].service_name, url, + N_MGR[schema].service_name, url, os.linesep, os.linesep.join( ['{}="{}"'.format(k, v) for k, v in results.items()]))) diff --git a/lib/apprise/plugins/NotifyAppriseAPI.py b/lib/apprise/plugins/apprise_api.py similarity index 98% rename from lib/apprise/plugins/NotifyAppriseAPI.py rename to lib/apprise/plugins/apprise_api.py index 3c85b8ac..fd71236b 100644 --- a/lib/apprise/plugins/NotifyAppriseAPI.py +++ b/lib/apprise/plugins/apprise_api.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -31,12 +31,12 @@ import requests from json import dumps import base64 -from .NotifyBase import NotifyBase -from ..URLBase import PrivacyMode +from .base import NotifyBase +from ..url import PrivacyMode from ..common import NotifyType from ..utils import parse_list from ..utils import validate_regex -from ..AppriseLocale import gettext_lazy as _ +from ..locale import gettext_lazy as _ class AppriseAPIMethod: @@ -123,7 +123,7 @@ class NotifyAppriseAPI(NotifyBase): 'type': 'string', 'required': True, 'private': True, - 'regex': (r'^[A-Z0-9_-]{1,32}$', 'i'), + 'regex': (r'^[A-Z0-9_-]{1,128}$', 'i'), }, }) diff --git a/lib/apprise/plugins/aprs.py b/lib/apprise/plugins/aprs.py new file mode 100644 index 00000000..b8adef5a --- /dev/null +++ b/lib/apprise/plugins/aprs.py @@ -0,0 +1,778 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2024, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# +# To use this plugin, you need to be a licensed ham radio operator +# +# Plugin constraints: +# +# - message length = 67 chars max. +# - message content = ASCII 7 bit +# - APRS messages will be sent without msg ID, meaning that +# ham radio operators cannot acknowledge them +# - Bring your own APRS-IS passcode. If you don't know what +# this is or how to get it, then this plugin is not for you +# - Do NOT change the Device/ToCall ID setting UNLESS this +# module is used outside of Apprise. This identifier helps +# the ham radio community with determining the software behind +# a given APRS message. +# - With great (ham radio) power comes great responsibility; do +# not use this plugin for spamming other ham radio operators + +# +# In order to digest text input which is not in plain English, +# users can install the optional 'unidecode' package as part +# of their venv environment. Details: see plugin description +# + +# +# You're done at this point, you only need to know your user/pass that +# you signed up with. + +# The following URLs would be accepted by Apprise: +# - aprs://{user}:{password}@{callsign} +# - aprs://{user}:{password}@{callsign1}/{callsign2} + +# Optional parameters: +# - locale --> APRS-IS target server to connect with +# Default: EURO --> 'euro.aprs2.net' +# Details: https://www.aprs2.net/ + +# +# APRS message format specification: +# http://www.aprs.org/doc/APRS101.PDF +# + +import socket +import sys +from itertools import chain +from .base import NotifyBase +from ..locale import gettext_lazy as _ +from ..url import PrivacyMode +from ..common import NotifyType +from ..utils import is_call_sign +from ..utils import parse_call_sign +from .. import __version__ +import re + +# Fixed APRS-IS server locales +# Default is 'EURO' +# See https://www.aprs2.net/ for details +# Select the rotating server in case you +# don"t care about a specific locale +APRS_LOCALES = { + "NOAM": "noam.aprs2.net", + "SOAM": "soam.aprs2.net", + "EURO": "euro.aprs2.net", + "ASIA": "asia.aprs2.net", + "AUNZ": "aunz.aprs2.net", + "ROTA": "rotate.aprs2.net", +} + +# Identify all unsupported characters +APRS_BAD_CHARMAP = { + r"Ä": "Ae", + r"Ö": "Oe", + r"Ü": "Ue", + r"ä": "ae", + r"ö": "oe", + r"ü": "ue", + r"ß": "ss", +} + +# Our compiled mapping of bad characters +APRS_COMPILED_MAP = re.compile( + r'(' + '|'.join(APRS_BAD_CHARMAP.keys()) + r')') + + +class NotifyAprs(NotifyBase): + """ + A wrapper for APRS Notifications via APRS-IS + """ + + # The default descriptive name associated with the Notification + service_name = "Aprs" + + # The services URL + service_url = "https://www.aprs2.net/" + + # The default secure protocol + secure_protocol = "aprs" + + # A URL that takes you to the setup/help of the specific protocol + setup_url = "https://github.com/caronc/apprise/wiki/Notify_aprs" + + # APRS default port, supported by all core servers + # Details: https://www.aprs-is.net/Connecting.aspx + notify_port = 10152 + + # The maximum length of the APRS message body + body_maxlen = 67 + + # Apprise APRS Device ID / TOCALL ID + # This is a FIXED value which is associated with this plugin. + # Its value MUST NOT be changed. If you use this APRS plugin + # code OUTSIDE of Apprise, please request your own TOCALL ID. + # Details: see https://github.com/aprsorg/aprs-deviceid + # + # Do NOT use the generic "APRS" TOCALL ID !!!!! + # + device_id = "APPRIS" + + # A title can not be used for APRS Messages. Setting this to zero will + # cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + + # Helps to reduce the number of login-related errors where the + # APRS-IS server "isn't ready yet". If we try to receive the rx buffer + # without this grace perid in place, we may receive "incomplete" responses + # where the login response lacks information. In case you receive too many + # "Rx: APRS-IS msg is too short - needs to have at least two lines" error + # messages, you might want to increase this value to a larger time span + # Per previous experience, do not use values lower than 0.5 (seconds) + request_rate_per_sec = 0.8 + + # Encoding of retrieved content + aprs_encoding = 'latin-1' + + # Define object templates + templates = ("{schema}://{user}:{password}@{targets}",) + + # Define our template tokens + template_tokens = dict( + NotifyBase.template_tokens, + **{ + "user": { + "name": _("User Name"), + "type": "string", + "required": True, + }, + "password": { + "name": _("Password"), + "type": "string", + "private": True, + "required": True, + }, + "target_callsign": { + "name": _("Target Callsign"), + "type": "string", + "regex": ( + r"^[a-z0-9]{2,5}(-[a-z0-9]{1,2})?$", + "i", + ), + "map_to": "targets", + }, + "targets": { + "name": _("Targets"), + "type": "list:string", + "required": True, + }, + } + ) + + # Define our template arguments + template_args = dict( + NotifyBase.template_args, + **{ + "to": { + "name": _("Target Callsign"), + "type": "string", + "map_to": "targets", + }, + "delay": { + "name": _("Resend Delay"), + "type": "float", + "min": 0.0, + "max": 5.0, + "default": 0.0, + }, + "locale": { + "name": _("Locale"), + "type": "choice:string", + "values": APRS_LOCALES, + "default": "EURO", + }, + } + ) + + def __init__(self, targets=None, locale=None, delay=None, **kwargs): + """ + Initialize APRS Object + """ + super().__init__(**kwargs) + + # Our (future) socket sobject + self.sock = None + + # Parse our targets + self.targets = list() + + """ + Check if the user has provided credentials + """ + if not (self.user and self.password): + msg = "An APRS user/pass was not provided." + self.logger.warning(msg) + raise TypeError(msg) + + """ + Check if the user tries to use a read-only access + to APRS-IS. We need to send content, meaning that + read-only access will not work + """ + if self.password == "-1": + msg = "APRS read-only passwords are not supported." + self.logger.warning(msg) + raise TypeError(msg) + + """ + Check if the password is numeric + """ + if not self.password.isnumeric(): + msg = "Invalid APRS-IS password" + self.logger.warning(msg) + raise TypeError(msg) + + """ + Convert given user name (FROM callsign) and + device ID to to uppercase + """ + self.user = self.user.upper() + self.device_id = self.device_id.upper() + + """ + Check if the user has provided a locale for the + APRS-IS-server and validate it, if necessary + """ + if locale: + if locale.upper() not in APRS_LOCALES: + msg = ( + "Unsupported APRS-IS server locale. " + "Received: {}. Valid: {}".format( + locale, ", ".join(str(x) for x in APRS_LOCALES.keys()) + ) + ) + self.logger.warning(msg) + raise TypeError(msg) + + # Update our delay + if delay is None: + self.delay = NotifyAprs.template_args["delay"]["default"] + + else: + try: + self.delay = float(delay) + if self.delay < NotifyAprs.template_args["delay"]["min"]: + raise ValueError() + + elif self.delay >= NotifyAprs.template_args["delay"]["max"]: + raise ValueError() + + except (TypeError, ValueError): + msg = "Unsupported APRS-IS delay ({}) specified. ".format( + delay) + self.logger.warning(msg) + raise TypeError(msg) + + # Bump up our request_rate + self.request_rate_per_sec += self.delay + + # Set the transmitter group + self.locale = \ + NotifyAprs.template_args["locale"]["default"] \ + if not locale else locale.upper() + + # Used for URL generation afterwards only + self.invalid_targets = list() + + for target in parse_call_sign(targets): + # Validate targets and drop bad ones + # We just need to know if the call sign (including SSID, if + # provided) is valid and can then process the input as is + result = is_call_sign(target) + if not result: + self.logger.warning( + "Dropping invalid Amateur radio call sign ({}).".format( + target + ), + ) + self.invalid_targets.append(target.upper()) + continue + + # Store entry + self.targets.append(target.upper()) + + return + + def socket_close(self): + """ + Closes the socket connection whereas present + """ + if self.sock: + try: + self.sock.close() + + except Exception: + # No worries if socket exception thrown on close() + pass + + self.sock = None + + def socket_open(self): + """ + Establishes the connection to the APRS-IS + socket server + """ + self.logger.debug( + "Creating socket connection with APRS-IS {}:{}".format( + APRS_LOCALES[self.locale], self.notify_port + ) + ) + + try: + self.sock = socket.create_connection( + (APRS_LOCALES[self.locale], self.notify_port), + self.socket_connect_timeout, + ) + + except ConnectionError as e: + self.logger.debug("Socket Exception socket_open: %s", str(e)) + self.sock = None + return False + + except socket.gaierror as e: + self.logger.debug("Socket Exception socket_open: %s", str(e)) + self.sock = None + return False + + except socket.timeout as e: + self.logger.debug( + "Socket Timeout Exception socket_open: %s", str(e)) + self.sock = None + return False + + except Exception as e: + self.logger.debug("General Exception socket_open: %s", str(e)) + self.sock = None + return False + + # We are connected. + # getpeername() is not supported by every OS. Therefore, + # we MAY receive an exception even though we are + # connected successfully. + try: + # Get the physical host/port of the server + host, port = self.sock.getpeername() + # and create debug info + self.logger.debug("Connected to {}:{}".format(host, port)) + + except ValueError: + # Seens as if we are running on an operating + # system that does not support getpeername() + # Create a minimal log file entry + self.logger.debug("Connected to APRS-IS") + + # Return success + return True + + def aprsis_login(self): + """ + Generate the APRS-IS login string, send it to the server + and parse the response + + Returns True/False wrt whether the login was successful + """ + self.logger.debug("socket_login: init") + + # Check if we are connected + if not self.sock: + self.logger.warning("socket_login: Not connected to APRS-IS") + return False + + # APRS-IS login string, see https://www.aprs-is.net/Connecting.aspx + login_str = "user {0} pass {1} vers apprise {2}\r\n".format( + self.user, self.password, __version__ + ) + + # Send the data & abort in case of error + if not self.socket_send(login_str): + self.logger.warning( + "socket_login: Login to APRS-IS unsuccessful," + " exception occurred" + ) + self.socket_close() + return False + + rx_buf = self.socket_receive(len(login_str) + 100) + # Abort the remaining process in case an error has occurred + if not rx_buf: + self.logger.warning( + "socket_login: Login to APRS-IS " + "unsuccessful, exception occurred" + ) + self.socket_close() + return False + + # APRS-IS sends at least two lines of data + # The data that we need is in line #2 so + # let's split the content and see what we have + rx_lines = rx_buf.splitlines() + if len(rx_lines) < 2: + self.logger.warning( + "socket_login: APRS-IS msg is too short" + " - needs to have at least two lines" + ) + self.socket_close() + return False + + # Now split the 2nd line's content and extract + # both call sign and login status + try: + _, _, callsign, status, _ = rx_lines[1].split(" ", 4) + + except ValueError: + # ValueError is returned if there were not enough elements to + # populate the response + self.logger.warning( + "socket_login: " "received invalid response from APRS-IS" + ) + self.socket_close() + return False + + if callsign != self.user: + self.logger.warning( + "socket_login: " "call signs differ: %s" % callsign + ) + self.socket_close() + return False + + if status.startswith("unverified"): + self.logger.warning( + "socket_login: " + "invalid APRS-IS password for given call sign" + ) + self.socket_close() + return False + + # all validations are successful; we are connected + return True + + def socket_send(self, tx_data): + """ + Generic "Send data to a socket" + """ + self.logger.debug("socket_send: init") + + # Check if we are connected + if not self.sock: + self.logger.warning("socket_send: Not connected to APRS-IS") + return False + + # Encode our data if we are on Python3 or later + payload = ( + tx_data.encode("utf-8") if sys.version_info[0] >= 3 else tx_data + ) + + # Always call throttle before any remote server i/o is made + self.throttle() + + # Try to open the socket + # Send the content to APRS-IS + try: + self.sock.setblocking(True) + self.sock.settimeout(self.socket_connect_timeout) + self.sock.sendall(payload) + + except socket.gaierror as e: + self.logger.warning("Socket Exception socket_send: %s" % str(e)) + self.sock = None + return False + + except socket.timeout as e: + self.logger.warning( + "Socket Timeout Exception " "socket_send: %s" % str(e) + ) + self.sock = None + return False + + except Exception as e: + self.logger.warning( + "General Exception " "socket_send: %s" % str(e) + ) + self.sock = None + return False + + self.logger.debug("socket_send: successful") + + # mandatory on several APRS-IS servers + # helps to reduce the number of errors where + # the server only returns an abbreviated message + return True + + def socket_reset(self): + """ + Resets the socket's buffer + """ + self.logger.debug("socket_reset: init") + _ = self.socket_receive(0) + self.logger.debug("socket_reset: successful") + return True + + def socket_receive(self, rx_len): + """ + Generic "Receive data from a socket" + """ + self.logger.debug("socket_receive: init") + + # Check if we are connected + if not self.sock: + self.logger.warning("socket_receive: not connected to APRS-IS") + return False + + # len is zero in case we intend to + # reset the socket + if rx_len > 0: + self.logger.debug("socket_receive: Receiving data from APRS-IS") + + # Receive content from the socket + try: + self.sock.setblocking(False) + self.sock.settimeout(self.socket_connect_timeout) + rx_buf = self.sock.recv(rx_len) + + except socket.gaierror as e: + self.logger.warning( + "Socket Exception socket_receive: %s" % str(e) + ) + self.sock = None + return False + + except socket.timeout as e: + self.logger.warning( + "Socket Timeout Exception " "socket_receive: %s" % str(e) + ) + self.sock = None + return False + + except Exception as e: + self.logger.warning( + "General Exception " "socket_receive: %s" % str(e) + ) + self.sock = None + return False + + rx_buf = ( + rx_buf.decode(self.aprs_encoding) + if sys.version_info[0] >= 3 else rx_buf + ) + + # There will be no data in case we reset the socket + if rx_len > 0: + self.logger.debug("Received content: {}".format(rx_buf)) + + self.logger.debug("socket_receive: successful") + + return rx_buf.rstrip() + + def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): + """ + Perform APRS Notification + """ + + if not self.targets: + # There is no one to notify; we're done + self.logger.warning( + "There are no amateur radio call signs to notify" + ) + return False + + # prepare payload + payload = body + + # sock object is "None" if we were unable to establish a connection + # In case of errors, the error message has already been sent + # to the logger object + if not self.socket_open(): + return False + + # We have established a successful connection + # to the socket server. Now send the login information + if not self.aprsis_login(): + return False + + # Login & authorization confirmed + # reset what is in our buffer + self.socket_reset() + + # error tracking (used for function return) + has_error = False + + # Create a copy of the targets list + targets = list(self.targets) + + self.logger.debug("Starting Payload setup") + + # Prepare the outgoing message + # Due to APRS's contraints, we need to do + # a lot of filtering before we can send + # the actual message + # + # First remove all characters from the + # payload that would break APRS + # see https://www.aprs.org/doc/APRS101.PDF pg. 71 + payload = re.sub("[{}|~]+", "", payload) + + payload = ( # pragma: no branch + APRS_COMPILED_MAP.sub( + lambda x: APRS_BAD_CHARMAP[x.group()], payload) + ) + + # Finally, constrain output string to 67 characters as + # APRS messages are limited in length + payload = payload[:67] + + # Our outgoing message MUST end with a CRLF so + # let's amend our payload respectively + payload = payload.rstrip("\r\n") + "\r\n" + + self.logger.debug("Payload setup complete: {}".format(payload)) + + # send the message to our target call sign(s) + for index in range(0, len(targets)): + # prepare the output string + # Format: + # Device ID/TOCALL - our call sign - target call sign - body + buffer = "{}>{}::{:9}:{}".format( + self.user, self.device_id, targets[index], payload + ) + + # and send the content to the socket + # Note that there will be no response from APRS and + # that all exceptions are handled within the 'send' method + self.logger.debug("Sending APRS message: {}".format(buffer)) + + # send the content + if not self.socket_send(buffer): + has_error = True + break + + # Finally, reset our socket buffer + # we DO NOT read from the socket as we + # would simply listen to the default APRS-IS stream + self.socket_reset() + + self.logger.debug("Closing socket.") + self.socket_close() + self.logger.info( + "Sent %d/%d APRS-IS notification(s)", index + 1, len(targets)) + 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 = {} + + if self.locale != NotifyAprs.template_args["locale"]["default"]: + # Store our locale if not default + params['locale'] = self.locale + + if self.delay != NotifyAprs.template_args["delay"]["default"]: + # Store our locale if not default + params['delay'] = "{:.2f}".format(self.delay) + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Setup Authentication + auth = "{user}:{password}@".format( + user=NotifyAprs.quote(self.user, safe=""), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe="" + ), + ) + + return "{schema}://{auth}{targets}?{params}".format( + schema=self.secure_protocol, + auth=auth, + targets="/".join(chain( + [self.pprint(x, privacy, safe="") for x in self.targets], + [self.pprint(x, privacy, safe="") + for x in self.invalid_targets], + )), + params=NotifyAprs.urlencode(params), + ) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets > 0 else 1 + + def __del__(self): + """ + Ensure we close any lingering connections + """ + self.socket_close() + + @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 + + # All elements are targets + results["targets"] = [NotifyAprs.unquote(results["host"])] + + # All entries after the hostname are additional targets + results["targets"].extend(NotifyAprs.split_path(results["fullpath"])) + + # Get Delay (if set) + if 'delay' in results['qsd'] and len(results['qsd']['delay']): + results['delay'] = NotifyAprs.unquote(results['qsd']['delay']) + + # Support the 'to' variable so that we can support rooms this way too + # The 'to' makes it easier to use yaml configuration + if "to" in results["qsd"] and len(results["qsd"]["to"]): + results["targets"] += NotifyAprs.parse_list(results["qsd"]["to"]) + + # Set our APRS-IS server locale's key value and convert it to uppercase + if "locale" in results["qsd"] and len(results["qsd"]["locale"]): + results["locale"] = NotifyAprs.unquote( + results["qsd"]["locale"] + ).upper() + + return results diff --git a/lib/apprise/plugins/NotifyBark.py b/lib/apprise/plugins/bark.py similarity index 98% rename from lib/apprise/plugins/NotifyBark.py rename to lib/apprise/plugins/bark.py index edef82bd..e2f5bbfb 100644 --- a/lib/apprise/plugins/NotifyBark.py +++ b/lib/apprise/plugins/bark.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -32,13 +32,13 @@ import requests import json -from .NotifyBase import NotifyBase -from ..URLBase import PrivacyMode +from .base import NotifyBase +from ..url import PrivacyMode from ..common import NotifyImageSize from ..common import NotifyType from ..utils import parse_list from ..utils import parse_bool -from ..AppriseLocale import gettext_lazy as _ +from ..locale import gettext_lazy as _ # Sounds generated off of: https://github.com/Finb/Bark/tree/master/Sounds diff --git a/lib/apprise/plugins/NotifyBase.py b/lib/apprise/plugins/base.py similarity index 68% rename from lib/apprise/plugins/NotifyBase.py rename to lib/apprise/plugins/base.py index 5138c15c..d18f0af0 100644 --- a/lib/apprise/plugins/NotifyBase.py +++ b/lib/apprise/plugins/base.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -30,15 +30,16 @@ import asyncio import re from functools import partial -from ..URLBase import URLBase +from ..url import URLBase from ..common import NotifyType +from ..utils import parse_bool from ..common import NOTIFY_TYPES from ..common import NotifyFormat from ..common import NOTIFY_FORMATS from ..common import OverflowMode from ..common import OVERFLOW_MODES -from ..AppriseLocale import gettext_lazy as _ -from ..AppriseAttachment import AppriseAttachment +from ..locale import gettext_lazy as _ +from ..apprise_attachment import AppriseAttachment class NotifyBase(URLBase): @@ -135,6 +136,9 @@ class NotifyBase(URLBase): # Default Overflow Mode overflow_mode = OverflowMode.UPSTREAM + # Default Emoji Interpretation + interpret_emojis = False + # Support Attachments; this defaults to being disabled. # Since apprise allows you to send attachments without a body or title # defined, by letting Apprise know the plugin won't support attachments @@ -183,8 +187,66 @@ class NotifyBase(URLBase): # runtime. '_lookup_default': 'notify_format', }, + 'emojis': { + 'name': _('Interpret Emojis'), + # SSL Certificate Authority Verification + 'type': 'bool', + # Provide a default + 'default': interpret_emojis, + # look up default using the following parent class value at + # runtime. + '_lookup_default': 'interpret_emojis', + }, }) + # + # Overflow Defaults / Configuration applicable to SPLIT mode only + # + + # Display Count [X/X] + # ^^^^^^ + # \\\\\\ + # 6 characters (space + count) + # Display Count [XX/XX] + # ^^^^^^^^ + # \\\\\\\\ + # 8 characters (space + count) + # Display Count [XXX/XXX] + # ^^^^^^^^^^ + # \\\\\\\\\\ + # 10 characters (space + count) + # Display Count [XXXX/XXXX] + # ^^^^^^^^^^^^ + # \\\\\\\\\\\\ + # 12 characters (space + count) + # + # Given the above + some buffer we come up with the following: + # If this value is exceeded, display counts automatically shut off + overflow_max_display_count_width = 12 + + # The number of characters to reserver for whitespace buffering + # This is detected automatically, but you can enforce a value if + # you desire: + overflow_buffer = 0 + + # the min accepted length of a title to allow for a counter display + overflow_display_count_threshold = 130 + + # Whether or not when over-flow occurs, if the title should be repeated + # each time the message is split up + # - None: Detect + # - True: Always display title once + # - False: Display the title for each occurance + overflow_display_title_once = None + + # If this is set to to True: + # The title_maxlen should be considered as a subset of the body_maxlen + # Hence: len(title) + len(body) should never be greater then body_maxlen + # + # If set to False, then there is no corrorlation between title_maxlen + # restrictions and that of body_maxlen + overflow_amalgamate_title = False + def __init__(self, **kwargs): """ Initialize some general configuration that will keep things consistent @@ -194,6 +256,29 @@ class NotifyBase(URLBase): super().__init__(**kwargs) + # Store our interpret_emoji's setting + # If asset emoji value is set to a default of True and the user + # specifies it to be false, this is accepted and False over-rides. + # + # If asset emoji value is set to a default of None, a user may + # optionally over-ride this and set it to True from the Apprise + # URL. ?emojis=yes + # + # If asset emoji value is set to a default of False, then all emoji's + # are turned off (no user over-rides allowed) + # + + # Take a default + self.interpret_emojis = self.asset.interpret_emojis + if 'emojis' in kwargs: + # possibly over-ride default + self.interpret_emojis = True if self.interpret_emojis \ + in (None, True) and \ + parse_bool( + kwargs.get('emojis', False), + default=NotifyBase.template_args['emojis']['default']) \ + else False + if 'format' in kwargs: # Store the specified format if specified notify_format = kwargs.get('format', '') @@ -279,6 +364,17 @@ class NotifyBase(URLBase): color_type=color_type, ) + def ascii(self, notify_type): + """ + Returns the ascii characters associated with the notify_type + """ + if notify_type not in NOTIFY_TYPES: + return None + + return self.asset.ascii( + notify_type=notify_type, + ) + def notify(self, *args, **kwargs): """ Performs notification @@ -372,6 +468,19 @@ class NotifyBase(URLBase): # Handle situations where the title is None title = '' if not title else title + # Truncate flag set with attachments ensures that only 1 + # attachment passes through. In the event there could be many + # services specified, we only want to do this logic once. + # The logic is only applicable if ther was more then 1 attachment + # specified + overflow = self.overflow_mode if overflow is None else overflow + if attach and len(attach) > 1 and overflow == OverflowMode.TRUNCATE: + # Save first attachment + _attach = AppriseAttachment(attach[0], asset=self.asset) + else: + # reference same attachment + _attach = attach + # Apply our overflow (if defined) for chunk in self._apply_overflow( body=body, title=title, overflow=overflow, @@ -380,7 +489,7 @@ class NotifyBase(URLBase): # Send notification yield dict( body=chunk['body'], title=chunk['title'], - notify_type=notify_type, attach=attach, + notify_type=notify_type, attach=_attach, body_format=body_format ) @@ -400,7 +509,7 @@ class NotifyBase(URLBase): }, { title: 'the title goes here', - body: 'the message body goes here', + body: 'the continued message body goes here', }, ] @@ -417,7 +526,6 @@ class NotifyBase(URLBase): overflow = self.overflow_mode if self.title_maxlen <= 0 and len(title) > 0: - if self.notify_format == NotifyFormat.HTML: # Content is appended to body as html body = '<{open_tag}>{title}' \ @@ -453,29 +561,148 @@ class NotifyBase(URLBase): response.append({'body': body, 'title': title}) return response - elif len(title) > self.title_maxlen: - # Truncate our Title - title = title[:self.title_maxlen] + # a value of '2' allows for the \r\n that is applied when + # amalgamating the title + overflow_buffer = max(2, self.overflow_buffer) \ + if (self.title_maxlen == 0 and len(title)) \ + else self.overflow_buffer - if self.body_maxlen > 0 and len(body) <= self.body_maxlen: + # + # If we reach here in our code, then we're using TRUNCATE, or SPLIT + # actions which require some math to handle the data + # + + # Handle situations where our body and title are amalamated into one + # calculation + title_maxlen = self.title_maxlen \ + if not self.overflow_amalgamate_title \ + else min(len(title) + self.overflow_max_display_count_width, + self.title_maxlen, self.body_maxlen) + + if len(title) > title_maxlen: + # Truncate our Title + title = title[:title_maxlen].rstrip() + + if self.overflow_amalgamate_title and ( + self.body_maxlen - overflow_buffer) >= title_maxlen: + body_maxlen = (self.body_maxlen if not title else ( + self.body_maxlen - title_maxlen)) - overflow_buffer + else: + # status quo + body_maxlen = self.body_maxlen \ + if not self.overflow_amalgamate_title else \ + (self.body_maxlen - overflow_buffer) + + if body_maxlen > 0 and len(body) <= body_maxlen: response.append({'body': body, 'title': title}) return response if overflow == OverflowMode.TRUNCATE: # Truncate our body and return response.append({ - 'body': body[:self.body_maxlen], + 'body': body[:body_maxlen].lstrip('\r\n\x0b\x0c').rstrip(), 'title': title, }) # For truncate mode, we're done now return response + if self.overflow_display_title_once is None: + # Detect if we only display our title once or not: + overflow_display_title_once = \ + True if self.overflow_amalgamate_title and \ + body_maxlen < self.overflow_display_count_threshold \ + else False + else: + # Take on defined value + + overflow_display_title_once = self.overflow_display_title_once + # If we reach here, then we are in SPLIT mode. # For here, we want to split the message as many times as we have to # in order to fit it within the designated limits. - response = [{ - 'body': body[i: i + self.body_maxlen], - 'title': title} for i in range(0, len(body), self.body_maxlen)] + if not overflow_display_title_once and not ( + # edge case that can occur when overflow_display_title_once is + # forced off, but no body exists + self.overflow_amalgamate_title and body_maxlen <= 0): + + show_counter = title and len(body) > body_maxlen and \ + ((self.overflow_amalgamate_title and + body_maxlen >= self.overflow_display_count_threshold) or + (not self.overflow_amalgamate_title and + title_maxlen > self.overflow_display_count_threshold)) and ( + title_maxlen > (self.overflow_max_display_count_width + + overflow_buffer) and + self.title_maxlen >= self.overflow_display_count_threshold) + + count = 0 + template = '' + if show_counter: + # introduce padding + body_maxlen -= overflow_buffer + + count = int(len(body) / body_maxlen) \ + + (1 if len(body) % body_maxlen else 0) + + # Detect padding and prepare template + digits = len(str(count)) + template = ' [{:0%d}/{:0%d}]' % (digits, digits) + + # Update our counter + overflow_display_count_width = 4 + (digits * 2) + if overflow_display_count_width <= \ + self.overflow_max_display_count_width: + if len(title) > \ + title_maxlen - overflow_display_count_width: + # Truncate our title further + title = title[:title_maxlen - + overflow_display_count_width] + + else: # Way to many messages to display + show_counter = False + + response = [{ + 'body': body[i: i + body_maxlen] + .lstrip('\r\n\x0b\x0c').rstrip(), + 'title': title + ( + '' if not show_counter else + template.format(idx, count))} for idx, i in + enumerate(range(0, len(body), body_maxlen), start=1)] + + else: # Display title once and move on + response = [] + try: + i = range(0, len(body), body_maxlen)[0] + + response.append({ + 'body': body[i: i + body_maxlen] + .lstrip('\r\n\x0b\x0c').rstrip(), + 'title': title, + }) + + except (ValueError, IndexError): + # IndexError: + # - This happens if there simply was no body to display + + # ValueError: + # - This happens when body_maxlen < 0 (due to title being + # so large) + + # No worries; send title along + response.append({ + 'body': '', + 'title': title, + }) + + # Ensure our start is set properly + body_maxlen = 0 + + # Now re-calculate based on the increased length + for i in range(body_maxlen, len(body), self.body_maxlen): + response.append({ + 'body': body[i: i + self.body_maxlen] + .lstrip('\r\n\x0b\x0c').rstrip(), + 'title': '', + }) return response @@ -548,6 +775,10 @@ class NotifyBase(URLBase): results['overflow'])) del results['overflow'] + # Allow emoji's override + if 'emojis' in results['qsd']: + results['emojis'] = parse_bool(results['qsd'].get('emojis')) + return results @staticmethod diff --git a/lib/apprise/plugins/NotifyBase.pyi b/lib/apprise/plugins/base.pyi similarity index 100% rename from lib/apprise/plugins/NotifyBase.pyi rename to lib/apprise/plugins/base.pyi diff --git a/lib/apprise/plugins/NotifyBoxcar.py b/lib/apprise/plugins/boxcar.py similarity index 97% rename from lib/apprise/plugins/NotifyBoxcar.py rename to lib/apprise/plugins/boxcar.py index 9d3be6ae..851cdd3d 100644 --- a/lib/apprise/plugins/NotifyBoxcar.py +++ b/lib/apprise/plugins/boxcar.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -33,20 +33,16 @@ from json import dumps from time import time from hashlib import sha1 from itertools import chain -try: - from urlparse import urlparse +from urllib.parse import urlparse -except ImportError: - from urllib.parse import urlparse - -from .NotifyBase import NotifyBase -from ..URLBase import PrivacyMode +from .base import NotifyBase +from ..url import PrivacyMode from ..utils import parse_bool from ..utils import parse_list from ..utils import validate_regex from ..common import NotifyType from ..common import NotifyImageSize -from ..AppriseLocale import gettext_lazy as _ +from ..locale import gettext_lazy as _ # Default to sending to all devices if nothing is specified DEFAULT_TAG = '@all' diff --git a/lib/apprise/plugins/NotifyBulkSMS.py b/lib/apprise/plugins/bulksms.py similarity index 98% rename from lib/apprise/plugins/NotifyBulkSMS.py rename to lib/apprise/plugins/bulksms.py index cf82a87a..29c4d7fa 100644 --- a/lib/apprise/plugins/NotifyBulkSMS.py +++ b/lib/apprise/plugins/bulksms.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -36,13 +36,13 @@ import re import requests import json from itertools import chain -from .NotifyBase import NotifyBase -from ..URLBase import PrivacyMode +from .base import NotifyBase +from ..url import PrivacyMode from ..common import NotifyType from ..utils import is_phone_no from ..utils import parse_phone_no from ..utils import parse_bool -from ..AppriseLocale import gettext_lazy as _ +from ..locale import gettext_lazy as _ IS_GROUP_RE = re.compile( @@ -248,7 +248,7 @@ class NotifyBulkSMS(NotifyBase): if not (self.targets or self.groups): # We have nothing to notify - self.logger.warning('There are no Twist targets to notify') + self.logger.warning('There are no BulkSMS targets to notify') return False # Send in batches if identified to do so diff --git a/lib/apprise/plugins/bulkvs.py b/lib/apprise/plugins/bulkvs.py new file mode 100644 index 00000000..53a36300 --- /dev/null +++ b/lib/apprise/plugins/bulkvs.py @@ -0,0 +1,394 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2024, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +# To use this service you will need a BulkVS account +# You will need credits (new accounts start with a few) +# https://www.bulkvs.com/ + +# API is documented here: +# - https://portal.bulkvs.com/api/v1.0/documentation#/\ +# Messaging/post_messageSend +import requests +import json +from .base import NotifyBase +from ..url import PrivacyMode +from ..common import NotifyType +from ..utils import is_phone_no +from ..utils import parse_phone_no +from ..utils import parse_bool +from ..locale import gettext_lazy as _ + + +class NotifyBulkVS(NotifyBase): + """ + A wrapper for BulkVS Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'BulkVS' + + # The services URL + service_url = 'https://www.bulkvs.com/' + + # All notification requests are secure + secure_protocol = 'bulkvs' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_bulkvs' + + # BulkVS uses the http protocol with JSON requests + notify_url = 'https://portal.bulkvs.com/api/v1.0/messageSend' + + # The maximum length of the body + body_maxlen = 160 + + # The maximum amount of texts that can go out in one batch + default_batch_size = 4000 + + # A title can not be used for SMS Messages. Setting this to zero will + # cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + + # Define object templates + templates = ( + '{schema}://{user}:{password}@{from_phone}/{targets}', + '{schema}://{user}:{password}@{from_phone}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'user': { + 'name': _('User Name'), + 'type': 'string', + 'required': True, + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'from_phone': { + 'name': _('From Phone No'), + 'type': 'string', + 'regex': (r'^\+?[0-9\s)(+-]+$', 'i'), + 'map_to': 'source', + 'required': True, + }, + 'target_phone': { + 'name': _('Target Phone No'), + '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', + }, + 'from': { + 'name': _('From Phone No'), + 'type': 'string', + 'regex': (r'^\+?[0-9\s)(+-]+$', 'i'), + 'map_to': 'source', + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + }) + + def __init__(self, source=None, targets=None, batch=None, **kwargs): + """ + Initialize BulkVS Object + """ + super(NotifyBulkVS, self).__init__(**kwargs) + + if not (self.user and self.password): + msg = 'A BulkVS user/pass was not provided.' + self.logger.warning(msg) + raise TypeError(msg) + + result = is_phone_no(source) + if not result: + msg = 'The Account (From) Phone # specified ' \ + '({}) is invalid.'.format(source) + self.logger.warning(msg) + raise TypeError(msg) + + # Tidy source + self.source = result['full'] + + # Define whether or not we should operate in a batch mode + self.batch = self.template_args['batch']['default'] \ + if batch is None else bool(batch) + + # Parse our targets + self.targets = list() + + has_error = False + for target in parse_phone_no(targets): + # Parse each phone number we found + result = is_phone_no(target) + if result: + self.targets.append(result['full']) + continue + + has_error = True + self.logger.warning( + 'Dropped invalid phone # ({}) specified.'.format(target), + ) + + if not targets and not has_error: + # Default the SMS Message to ourselves + self.targets.append(self.source) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform BulkVS Notification + """ + + if not self.targets: + # We have nothing to notify + self.logger.warning('There are no BulkVS targets to notify') + return False + + # Send in batches if identified to do so + batch_size = 1 if not self.batch else self.default_batch_size + + # error tracking (used for function return) + has_error = False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + } + + # Prepare our payload + payload = { + # The To gets populated in the loop below + 'From': self.source, + 'To': None, + 'Message': body, + } + + # Authentication + auth = (self.user, self.password) + + # Prepare our targets + targets = list(self.targets) if batch_size == 1 else \ + [self.targets[index:index + batch_size] + for index in range(0, len(self.targets), batch_size)] + + while len(targets): + # Get our target to notify + target = targets.pop(0) + + # Prepare our user + payload['To'] = target + + # Printable reference + if isinstance(target, list): + p_target = '{} targets'.format(len(target)) + + else: + p_target = target + + # Some Debug Logging + self.logger.debug('BulkVS POST URL: {} (cert_verify={})'.format( + self.notify_url, self.verify_certificate)) + self.logger.debug('BulkVS Payload: {}' .format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + self.notify_url, + data=json.dumps(payload), + headers=headers, + auth=auth, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + # A Response may look like: + # { + # "RefId": "5a66dee6-ff7a-40ee-8218-5805c074dc01", + # "From": "13109060901", + # "MessageType": "SMS|MMS", + # "Results": [ + # { + # "To": "13105551212", + # "Status": "SUCCESS" + # }, + # { + # "To": "13105551213", + # "Status": "SUCCESS" + # } + # ] + # } + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyBase.http_response_code_lookup(r.status_code) + + # set up our status code to use + status_code = r.status_code + + self.logger.warning( + 'Failed to send BulkVS notification to {}: ' + '{}{}error={}.'.format( + p_target, + status_str, + ', ' if status_str else '', + status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent BulkVS notification to {}.'.format(p_target)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending BulkVS: to %s ', + p_target) + 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)) + + # A nice way of cleaning up the URL length a bit + targets = [] if len(self.targets) == 1 \ + and self.targets[0] == self.source else self.targets + + return '{schema}://{user}:{password}@{source}/{targets}' \ + '?{params}'.format( + schema=self.secure_protocol, + source=self.source, + user=self.pprint(self.user, privacy, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + targets='/'.join([ + NotifyBulkVS.quote('{}'.format(x), safe='+') + for x in targets]), + params=NotifyBulkVS.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + + # + # Factor batch into calculation + # + batch_size = 1 if not self.batch else self.default_batch_size + targets = len(self.targets) if self.targets else 1 + if batch_size > 1: + targets = int(targets / batch_size) + \ + (1 if targets % batch_size else 0) + + return targets + + @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 + + # Support the 'from' and 'source' variable so that we can support + # targets this way too. + # The 'from' makes it easier to use yaml configuration + if 'from' in results['qsd'] and len(results['qsd']['from']): + results['source'] = \ + NotifyBulkVS.unquote(results['qsd']['from']) + + # hostname will also be a target in this case + results['targets'] = [ + *NotifyBulkVS.parse_phone_no(results['host']), + *NotifyBulkVS.split_path(results['fullpath'])] + + else: + # store our source + results['source'] = NotifyBulkVS.unquote(results['host']) + + # store targets + results['targets'] = NotifyBulkVS.split_path(results['fullpath']) + + # 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'] += \ + NotifyBulkVS.parse_phone_no(results['qsd']['to']) + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get( + 'batch', NotifyBulkVS.template_args['batch']['default'])) + + return results diff --git a/lib/apprise/plugins/NotifyBurstSMS.py b/lib/apprise/plugins/burstsms.py similarity index 98% rename from lib/apprise/plugins/NotifyBurstSMS.py rename to lib/apprise/plugins/burstsms.py index 59219b3d..eb19df8e 100644 --- a/lib/apprise/plugins/NotifyBurstSMS.py +++ b/lib/apprise/plugins/burstsms.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -33,14 +33,14 @@ # import requests -from .NotifyBase import NotifyBase -from ..URLBase import PrivacyMode +from .base import NotifyBase +from ..url import PrivacyMode from ..common import NotifyType from ..utils import is_phone_no from ..utils import parse_phone_no from ..utils import parse_bool from ..utils import validate_regex -from ..AppriseLocale import gettext_lazy as _ +from ..locale import gettext_lazy as _ class BurstSMSCountryCode: diff --git a/lib/apprise/plugins/NotifyFaast.py b/lib/apprise/plugins/chantify.py similarity index 55% rename from lib/apprise/plugins/NotifyFaast.py rename to lib/apprise/plugins/chantify.py index be3eff28..d549a59f 100644 --- a/lib/apprise/plugins/NotifyFaast.py +++ b/lib/apprise/plugins/chantify.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -26,118 +26,111 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. +# Chantify +# 1. Visit https://chanify.net/ + +# The API URL will look something like this: +# https://api.chanify.net/v1/sender/token +# + import requests -from .NotifyBase import NotifyBase -from ..common import NotifyImageSize +from .base import NotifyBase from ..common import NotifyType -from ..utils import parse_bool -from ..AppriseLocale import gettext_lazy as _ from ..utils import validate_regex +from ..locale import gettext_lazy as _ -class NotifyFaast(NotifyBase): +class NotifyChantify(NotifyBase): """ - A wrapper for Faast Notifications + A wrapper for Chantify Notifications """ # The default descriptive name associated with the Notification - service_name = 'Faast' + service_name = _('Chantify') # The services URL - service_url = 'http://www.faast.io/' + service_url = 'https://chanify.net/' - # The default protocol (this is secure for faast) - protocol = 'faast' + # The default secure protocol + secure_protocol = 'chantify' # A URL that takes you to the setup/help of the specific protocol - setup_url = 'https://github.com/caronc/apprise/wiki/Notify_faast' + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_chantify' - # Faast uses the http protocol with JSON requests - notify_url = 'https://www.appnotifications.com/account/notifications.json' - - # Allows the user to specify the NotifyImageSize object - image_size = NotifyImageSize.XY_72 + # Notification URL + notify_url = 'https://api.chanify.net/v1/sender/{token}/' # Define object templates templates = ( - '{schema}://{authtoken}', + '{schema}://{token}', ) - # Define our template tokens + # The title is not used + title_maxlen = 0 + + # Define our tokens; these are the minimum tokens required required to + # be passed into this function (as arguments). The syntax appends any + # previously defined in the base package and builds onto them template_tokens = dict(NotifyBase.template_tokens, **{ - 'authtoken': { - 'name': _('Authorization Token'), + 'token': { + 'name': _('Token'), 'type': 'string', 'private': True, 'required': True, + 'regex': (r'^[A-Z0-9_-]+$', 'i'), }, }) # Define our template arguments template_args = dict(NotifyBase.template_args, **{ - 'image': { - 'name': _('Include Image'), - 'type': 'bool', - 'default': True, - 'map_to': 'include_image', + 'token': { + 'alias_of': 'token', }, }) - def __init__(self, authtoken, include_image=True, **kwargs): + def __init__(self, token, **kwargs): """ - Initialize Faast Object + Initialize Chantify Object """ super().__init__(**kwargs) - # Store the Authentication Token - self.authtoken = validate_regex(authtoken) - if not self.authtoken: - msg = 'An invalid Faast Authentication Token ' \ - '({}) was specified.'.format(authtoken) + self.token = validate_regex( + token, *self.template_tokens['token']['regex']) + if not self.token: + msg = 'The Chantify token specified ({}) is invalid.'\ + .format(token) self.logger.warning(msg) raise TypeError(msg) - # Associate an image with our post - self.include_image = include_image - return def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ - Perform Faast Notification + Send our notification """ + # prepare our headers headers = { 'User-Agent': self.app_id, - 'Content-Type': 'multipart/form-data' + 'Content-Type': 'application/x-www-form-urlencoded', } - # prepare JSON Object + # Our Message payload = { - 'user_credentials': self.authtoken, - 'title': title, - 'message': body, + 'text': body } - # Acquire our image if we're configured to do so - image_url = None if not self.include_image \ - else self.image_url(notify_type) - - if image_url: - payload['icon_url'] = image_url - - self.logger.debug('Faast POST URL: %s (cert_verify=%r)' % ( - self.notify_url, self.verify_certificate, - )) - self.logger.debug('Faast Payload: %s' % str(payload)) + self.logger.debug('Chantify GET URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate)) + self.logger.debug('Chantify Payload: %s' % str(payload)) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( - self.notify_url, + self.notify_url.format(token=self.token), data=payload, headers=headers, verify=self.verify_certificate, @@ -146,10 +139,10 @@ class NotifyFaast(NotifyBase): if r.status_code != requests.codes.ok: # We had a problem status_str = \ - NotifyFaast.http_response_code_lookup(r.status_code) + NotifyChantify.http_response_code_lookup(r.status_code) self.logger.warning( - 'Failed to send Faast notification:' + 'Failed to send Chantify notification: ' '{}{}error={}.'.format( status_str, ', ' if status_str else '', @@ -161,12 +154,12 @@ class NotifyFaast(NotifyBase): return False else: - self.logger.info('Sent Faast notification.') + self.logger.info('Sent Chantify notification.') except requests.RequestException as e: self.logger.warning( - 'A Connection error occurred sending Faast notification.', - ) + 'A Connection error occurred sending Chantify ' + 'notification.') self.logger.debug('Socket Exception: %s' % str(e)) # Return; we're done @@ -179,18 +172,13 @@ class NotifyFaast(NotifyBase): Returns the URL built dynamically based on specified arguments. """ - # Define any URL parameters - params = { - 'image': 'yes' if self.include_image else 'no', - } + # Prepare our parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) - # Extend our parameters - params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) - - return '{schema}://{authtoken}/?{params}'.format( - schema=self.protocol, - authtoken=self.pprint(self.authtoken, privacy, safe=''), - params=NotifyFaast.urlencode(params), + return '{schema}://{token}/?{params}'.format( + schema=self.secure_protocol, + token=self.pprint(self.token, privacy, safe=''), + params=NotifyChantify.urlencode(params), ) @staticmethod @@ -200,16 +188,19 @@ class NotifyFaast(NotifyBase): us to re-instantiate this object. """ + + # parse_url already handles getting the `user` and `password` fields + # populated. results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results - # Store our authtoken using the host - results['authtoken'] = NotifyFaast.unquote(results['host']) + # Allow over-ride + if 'token' in results['qsd'] and len(results['qsd']['token']): + results['token'] = NotifyChantify.unquote(results['qsd']['token']) - # Include image with our post - results['include_image'] = \ - parse_bool(results['qsd'].get('image', True)) + else: + results['token'] = NotifyChantify.unquote(results['host']) return results diff --git a/lib/apprise/plugins/NotifyClickSend.py b/lib/apprise/plugins/clicksend.py similarity index 94% rename from lib/apprise/plugins/NotifyClickSend.py rename to lib/apprise/plugins/clicksend.py index 670e74e8..9ade1055 100644 --- a/lib/apprise/plugins/NotifyClickSend.py +++ b/lib/apprise/plugins/clicksend.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -41,15 +41,14 @@ # import requests from json import dumps -from base64 import b64encode -from .NotifyBase import NotifyBase -from ..URLBase import PrivacyMode +from .base import NotifyBase +from ..url import PrivacyMode from ..common import NotifyType from ..utils import is_phone_no from ..utils import parse_phone_no from ..utils import parse_bool -from ..AppriseLocale import gettext_lazy as _ +from ..locale import gettext_lazy as _ # Extend HTTP Error Messages CLICKSEND_HTTP_ERROR_MAP = { @@ -89,7 +88,7 @@ class NotifyClickSend(NotifyBase): # Define object templates templates = ( - '{schema}://{user}:{password}@{targets}', + '{schema}://{user}:{apikey}@{targets}', ) # Define our template tokens @@ -99,11 +98,12 @@ class NotifyClickSend(NotifyBase): 'type': 'string', 'required': True, }, - 'password': { - 'name': _('Password'), + 'apikey': { + 'name': _('API Key'), 'type': 'string', 'private': True, 'required': True, + 'map_to': 'password', }, 'target_phone': { 'name': _('Target Phone No'), @@ -124,6 +124,9 @@ class NotifyClickSend(NotifyBase): 'to': { 'alias_of': 'targets', }, + 'key': { + 'alias_of': 'apikey', + }, 'batch': { 'name': _('Batch Mode'), 'type': 'bool', @@ -174,9 +177,6 @@ class NotifyClickSend(NotifyBase): headers = { 'User-Agent': self.app_id, 'Content-Type': 'application/json; charset=utf-8', - 'Authorization': 'Basic {}'.format( - b64encode('{}:{}'.format( - self.user, self.password).encode('utf-8'))), } # error tracking (used for function return) @@ -208,6 +208,7 @@ class NotifyClickSend(NotifyBase): r = requests.post( self.notify_url, data=dumps(payload), + auth=(self.user, self.password), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, @@ -322,6 +323,12 @@ class NotifyClickSend(NotifyBase): results['batch'] = \ parse_bool(results['qsd'].get('batch', False)) + # API Key + if 'key' in results['qsd'] and len(results['qsd']['key']): + # Extract the API Key from an argument + results['password'] = \ + NotifyClickSend.unquote(results['qsd']['key']) + # Support the 'to' variable so that we can support rooms this way too # The 'to' makes it easier to use yaml configuration if 'to' in results['qsd'] and len(results['qsd']['to']): diff --git a/lib/apprise/plugins/NotifyForm.py b/lib/apprise/plugins/custom_form.py similarity index 99% rename from lib/apprise/plugins/NotifyForm.py rename to lib/apprise/plugins/custom_form.py index 066f299b..0f36643f 100644 --- a/lib/apprise/plugins/NotifyForm.py +++ b/lib/apprise/plugins/custom_form.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -29,11 +29,11 @@ import re import requests -from .NotifyBase import NotifyBase -from ..URLBase import PrivacyMode +from .base import NotifyBase +from ..url import PrivacyMode from ..common import NotifyImageSize from ..common import NotifyType -from ..AppriseLocale import gettext_lazy as _ +from ..locale import gettext_lazy as _ class FORMPayloadField: diff --git a/lib/apprise/plugins/NotifyJSON.py b/lib/apprise/plugins/custom_json.py similarity index 98% rename from lib/apprise/plugins/NotifyJSON.py rename to lib/apprise/plugins/custom_json.py index a8ab7adc..e0d7a675 100644 --- a/lib/apprise/plugins/NotifyJSON.py +++ b/lib/apprise/plugins/custom_json.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -30,11 +30,11 @@ import requests import base64 from json import dumps -from .NotifyBase import NotifyBase -from ..URLBase import PrivacyMode +from .base import NotifyBase +from ..url import PrivacyMode from ..common import NotifyImageSize from ..common import NotifyType -from ..AppriseLocale import gettext_lazy as _ +from ..locale import gettext_lazy as _ class JSONPayloadField: diff --git a/lib/apprise/plugins/NotifyXML.py b/lib/apprise/plugins/custom_xml.py similarity index 98% rename from lib/apprise/plugins/NotifyXML.py rename to lib/apprise/plugins/custom_xml.py index 20eeb114..b7928fce 100644 --- a/lib/apprise/plugins/NotifyXML.py +++ b/lib/apprise/plugins/custom_xml.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -30,11 +30,11 @@ import re import requests import base64 -from .NotifyBase import NotifyBase -from ..URLBase import PrivacyMode +from .base import NotifyBase +from ..url import PrivacyMode from ..common import NotifyImageSize from ..common import NotifyType -from ..AppriseLocale import gettext_lazy as _ +from ..locale import gettext_lazy as _ class XMLPayloadField: diff --git a/lib/apprise/plugins/NotifyD7Networks.py b/lib/apprise/plugins/d7networks.py similarity index 99% rename from lib/apprise/plugins/NotifyD7Networks.py rename to lib/apprise/plugins/d7networks.py index 3e7787da..ad55e219 100644 --- a/lib/apprise/plugins/NotifyD7Networks.py +++ b/lib/apprise/plugins/d7networks.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -39,13 +39,13 @@ import requests from json import dumps from json import loads -from .NotifyBase import NotifyBase +from .base import NotifyBase from ..common import NotifyType from ..utils import is_phone_no from ..utils import parse_phone_no from ..utils import validate_regex from ..utils import parse_bool -from ..AppriseLocale import gettext_lazy as _ +from ..locale import gettext_lazy as _ # Extend HTTP Error Messages D7NETWORKS_HTTP_ERROR_MAP = { diff --git a/lib/apprise/plugins/NotifyDapnet.py b/lib/apprise/plugins/dapnet.py similarity index 98% rename from lib/apprise/plugins/NotifyDapnet.py rename to lib/apprise/plugins/dapnet.py index 5848b688..60a18acd 100644 --- a/lib/apprise/plugins/NotifyDapnet.py +++ b/lib/apprise/plugins/dapnet.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -51,9 +51,9 @@ from json import dumps import requests from requests.auth import HTTPBasicAuth -from .NotifyBase import NotifyBase -from ..AppriseLocale import gettext_lazy as _ -from ..URLBase import PrivacyMode +from .base import NotifyBase +from ..locale import gettext_lazy as _ +from ..url import PrivacyMode from ..common import NotifyType from ..utils import is_call_sign from ..utils import parse_call_sign diff --git a/lib/apprise/plugins/NotifyDBus.py b/lib/apprise/plugins/dbus.py similarity index 98% rename from lib/apprise/plugins/NotifyDBus.py rename to lib/apprise/plugins/dbus.py index 46f8b9d0..6be4fc2d 100644 --- a/lib/apprise/plugins/NotifyDBus.py +++ b/lib/apprise/plugins/dbus.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -26,15 +26,12 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -from __future__ import absolute_import -from __future__ import print_function - import sys -from .NotifyBase import NotifyBase +from .base import NotifyBase from ..common import NotifyImageSize from ..common import NotifyType from ..utils import parse_bool -from ..AppriseLocale import gettext_lazy as _ +from ..locale import gettext_lazy as _ # Default our global support flag NOTIFY_DBUS_SUPPORT_ENABLED = False diff --git a/lib/apprise/plugins/NotifyDingTalk.py b/lib/apprise/plugins/dingtalk.py similarity index 98% rename from lib/apprise/plugins/NotifyDingTalk.py rename to lib/apprise/plugins/dingtalk.py index 91bfcd6f..2ca1bc55 100644 --- a/lib/apprise/plugins/NotifyDingTalk.py +++ b/lib/apprise/plugins/dingtalk.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -34,13 +34,13 @@ import base64 import requests from json import dumps -from .NotifyBase import NotifyBase -from ..URLBase import PrivacyMode +from .base import NotifyBase +from ..url import PrivacyMode from ..common import NotifyFormat from ..common import NotifyType from ..utils import parse_list from ..utils import validate_regex -from ..AppriseLocale import gettext_lazy as _ +from ..locale import gettext_lazy as _ # Register at https://dingtalk.com # - Download their PC based software as it is the only way you can create diff --git a/lib/apprise/plugins/NotifyDiscord.py b/lib/apprise/plugins/discord.py similarity index 93% rename from lib/apprise/plugins/NotifyDiscord.py rename to lib/apprise/plugins/discord.py index f87b6694..14c6152b 100644 --- a/lib/apprise/plugins/NotifyDiscord.py +++ b/lib/apprise/plugins/discord.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -50,14 +50,19 @@ from datetime import timedelta from datetime import datetime from datetime import timezone -from .NotifyBase import NotifyBase +from .base import NotifyBase from ..common import NotifyImageSize from ..common import NotifyFormat from ..common import NotifyType from ..utils import parse_bool from ..utils import validate_regex -from ..AppriseLocale import gettext_lazy as _ -from ..attachment.AttachBase import AttachBase +from ..locale import gettext_lazy as _ +from ..attachment.base import AttachBase + + +# Used to detect user/role IDs +USER_ROLE_DETECTION_RE = re.compile( + r'\s*(?:<@(?P&?)(?P[0-9]+)>|@(?P[a-z0-9]+))', re.I) class NotifyDiscord(NotifyBase): @@ -100,6 +105,10 @@ class NotifyDiscord(NotifyBase): # The maximum allowable characters allowed in the body per message body_maxlen = 2000 + # The 2000 characters above defined by the body_maxlen include that of the + # title. Setting this to True ensures overflow options behave properly + overflow_amalgamate_title = True + # Discord has a limit of the number of fields you can include in an # embeds message. This value allows the discord message to safely # break into multiple messages to handle these cases. @@ -336,6 +345,33 @@ class NotifyDiscord(NotifyBase): payload['content'] = \ body if not title else "{}\r\n{}".format(title, body) + # parse for user id's <@123> and role IDs <@&456> + results = USER_ROLE_DETECTION_RE.findall(body) + if results: + payload['allow_mentions'] = { + 'parse': [], + 'users': [], + 'roles': [], + } + + _content = [] + for (is_role, no, value) in results: + if value: + payload['allow_mentions']['parse'].append(value) + _content.append(f'@{value}') + + elif is_role: + payload['allow_mentions']['roles'].append(no) + _content.append(f'<@&{no}>') + + else: # is_user + payload['allow_mentions']['users'].append(no) + _content.append(f'<@{no}>') + + if self.notify_format == NotifyFormat.MARKDOWN: + # Add pingable elements to content field + payload['content'] = '👉 ' + ' '.join(_content) + if not self._send(payload, params=params): # We failed to post our message return False @@ -360,16 +396,21 @@ class NotifyDiscord(NotifyBase): 'wait': True, }) + # # Remove our text/title based content for attachment use + # if 'embeds' in payload: - # Markdown del payload['embeds'] if 'content' in payload: - # Markdown del payload['content'] + if 'allow_mentions' in payload: + del payload['allow_mentions'] + + # # Send our attachments + # for attachment in attach: self.logger.info( 'Posting Discord Attachment {}'.format(attachment.name)) diff --git a/lib/apprise/plugins/NotifyEmail.py b/lib/apprise/plugins/email.py similarity index 94% rename from lib/apprise/plugins/NotifyEmail.py rename to lib/apprise/plugins/email.py index db70c8ef..142c93cf 100644 --- a/lib/apprise/plugins/NotifyEmail.py +++ b/lib/apprise/plugins/email.py @@ -2,7 +2,7 @@ # BSD 2-Clause License # # Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron +# Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -41,12 +41,12 @@ from socket import error as SocketError from datetime import datetime from datetime import timezone -from .NotifyBase import NotifyBase -from ..URLBase import PrivacyMode +from .base import NotifyBase +from ..url import PrivacyMode from ..common import NotifyFormat, NotifyType from ..conversion import convert_between -from ..utils import is_email, parse_emails -from ..AppriseLocale import gettext_lazy as _ +from ..utils import is_ipaddr, is_email, parse_emails, is_hostname +from ..locale import gettext_lazy as _ from ..logger import logger # Globally Default encoding mode set to Quoted Printable. @@ -295,6 +295,21 @@ EMAIL_TEMPLATES = ( }, ), + # Comcast.net + ( + 'Comcast.net', + re.compile( + r'^((?P