diff --git a/KEYWORDS b/KEYWORDS index 69a8502d..ef340a9a 100644 --- a/KEYWORDS +++ b/KEYWORDS @@ -93,6 +93,7 @@ Signal SimplePush Sinch Slack +SMPP SMSEagle SMS Manager SMTP2Go diff --git a/README.md b/README.md index 3d111c97..f1eb8841 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,7 @@ SMS Notifications for the most part do not have a both a `title` and `body`. Th | [Société Française du Radiotéléphone (SFR)](https://github.com/caronc/apprise/wiki/Notify_sfr) | sfr:// | (TCP) 443 | sfr://user:password>@spaceId/ToPhoneNo
sfr://user:password>@spaceId/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Signal API](https://github.com/caronc/apprise/wiki/Notify_signal) | signal:// or signals:// | (TCP) 80 or 443 | signal://hostname:port/FromPhoneNo
signal://hostname:port/FromPhoneNo/ToPhoneNo
signal://hostname:port/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Sinch](https://github.com/caronc/apprise/wiki/Notify_sinch) | sinch:// | (TCP) 443 | sinch://ServicePlanId:ApiToken@FromPhoneNo
sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo
sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo
sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ +| [SMPP](https://github.com/caronc/apprise/wiki/Notify_SMPP) | smpp:// or smpps:// | (TCP) 443 | smpp://user:password@hostname:port/FromPhoneNo/ToPhoneNo
smpps://user:password@hostname:port/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [SMSEagle](https://github.com/caronc/apprise/wiki/Notify_smseagle) | smseagle:// or smseagles:// | (TCP) 80 or 443 | smseagles://hostname:port/ToPhoneNo
smseagles://hostname:port/@ToContact
smseagles://hostname:port/#ToGroup
smseagles://hostname:port/ToPhoneNo1/#ToGroup/@ToContact/ | [SMS Manager](https://github.com/caronc/apprise/wiki/Notify_sms_manager) | smsmgr:// | (TCP) 443 | smsmgr://ApiKey@ToPhoneNo
smsmgr://ApiKey@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Threema Gateway](https://github.com/caronc/apprise/wiki/Notify_threema) | threema:// | (TCP) 443 | threema://GatewayID@secret/ToPhoneNo
threema://GatewayID@secret/ToEmail
threema://GatewayID@secret/ToThreemaID/
threema://GatewayID@secret/ToEmail/ToThreemaID/ToPhoneNo/... diff --git a/all-plugin-requirements.txt b/all-plugin-requirements.txt index 826901be..e69da784 100644 --- a/all-plugin-requirements.txt +++ b/all-plugin-requirements.txt @@ -14,3 +14,6 @@ paho-mqtt != 2.0.* # Pretty Good Privacy (PGP) Provides mailto:// and deltachat:// support PGPy + +# Provides smpp:// support +smpplib diff --git a/apprise/plugins/smpp.py b/apprise/plugins/smpp.py new file mode 100644 index 00000000..19ff6ff1 --- /dev/null +++ b/apprise/plugins/smpp.py @@ -0,0 +1,342 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2025, 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. + +from itertools import chain + +try: + import smpplib + import smpplib.consts + import smpplib.gsm + + # We're good to go! + NOTIFY_SMPP_ENABLED = True + +except ImportError: + # cryptography is required in order for this package to work + NOTIFY_SMPP_ENABLED = False + + +from .base import NotifyBase +from ..common import NotifyType +from ..locale import gettext_lazy as _ +from ..utils.parse import is_phone_no, parse_phone_no + + +class NotifySMPP(NotifyBase): + """ + A wrapper for SMPP Notifications + """ + + # Set our global enabled flag + enabled = NOTIFY_SMPP_ENABLED + + requirements = { + # Define our required packaging in order to work + 'packages_required': 'smpplib' + } + + # The default descriptive name associated with the Notification + service_name = _('SMPP') + + # The services URL + service_url = 'https://smpp.org/' + + # The default protocol + protocol = 'smpp' + + # The default secure protocol + secure_protocol = 'smpps' + + # Default port setup + default_port = 2775 + default_secure_port = 3550 + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_SMPP' + + # 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 + + templates = ( + '{schema}://{user}:{password}@{host}/{from_phone}/{targets}', + '{schema}://{user}:{password}@{host}:{port}/{from_phone}/{targets}', + ) + + template_tokens = dict(NotifyBase.template_tokens, **{ + 'user': { + 'name': _('Username'), + 'type': 'string', + 'required': True, + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'host': { + 'name': _('Host'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'from_phone': { + 'name': _('From Phone No'), + 'type': 'string', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + 'required': True, + 'map_to': 'source', + }, + '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, + }, + }) + + def __init__(self, source=None, targets=None, **kwargs): + """ + Initialize SMPP Object + """ + + super().__init__(**kwargs) + + self.source = None + + if not (self.user and self.password): + msg = 'No SMPP user/pass combination was 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'] + + # Used for URL generation afterwards only + self._invalid_targets = list() + + # Parse our targets + self.targets = list() + + for target in parse_phone_no(targets, prefix=True): + # Validate targets and drop bad ones: + result = is_phone_no(target) + if not result: + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) + self._invalid_targets.append(target) + continue + + # store valid phone number + self.targets.append(result['full']) + + @property + def url_identifier(self): + """ + Returns all the identifiers that make this URL unique from + another similar one. Targets or end points should never be identified + here. + """ + return ( + self.secure_protocol if self.secure else self.protocol, + self.user, self.password, self.host, self.port, self.source, + ) + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + return ('{schema}://{user}:{password}@{host}/{source}/{targets}' + '/?{params}').format( + schema=self.secure_protocol if self.secure else self.protocol, + user=self.user, + password=self.password, + host='{}:{}'.format(self.host, self.port) + if self.port else self.host, + source=self.source, + targets='/'.join( + [NotifySMPP.quote(t, safe='') + for t in chain(self.targets, self._invalid_targets)]), + params=self.urlencode(params), + ) + + def __len__(self): + """ + Returns the number of targets associated with this notification + + Always return 1 at least + """ + return len(self.targets) if self.targets else 1 + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform SMPP Notification + """ + + if not self.targets: + # There were no targets to notify + self.logger.warning( + 'There were no SMPP targets to notify') + return False + + # error tracking (used for function return) + has_error = False + + port = self.default_port if not self.secure \ + else self.default_secure_port + + client = smpplib.client.Client( + self.host, port, allow_unknown_opt_params=True) + try: + client.connect() + client.bind_transmitter( + system_id=self.user, password=self.password) + + except smpplib.exceptions.ConnectionError as e: + self.logger.warning( + 'Failed to establish connection to SMPP server {}: {}'.format( + self.host, e)) + return False + + for target in self.targets: + parts, encoding, msg_type = smpplib.gsm.make_parts(body) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + for payload in parts: + client.send_message( + source_addr_ton=smpplib.consts.SMPP_TON_INTL, + source_addr=self.source, + dest_addr_ton=smpplib.consts.SMPP_TON_INTL, + destination_addr=target, + short_message=payload, + data_coding=encoding, + esm_class=msg_type, + registered_delivery=True, + ) + except Exception as e: + self.logger.warning( + 'Failed to send SMPP notification: {}'.format(e)) + # Mark our failure + has_error = True + continue + + self.logger.info('Sent SMPP notification to %s', target) + + client.unbind() + client.disconnect() + return not has_error + + @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'] = \ + NotifySMPP.unquote(results['qsd']['from']) + + # hostname will also be a target in this case + results['targets'] = [ + *NotifySMPP.parse_phone_no(results['host']), + *NotifySMPP.split_path(results['fullpath'])] + + else: + # store our source + results['source'] = NotifySMPP.unquote(results['host']) + + # store targets + results['targets'] = NotifySMPP.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'] += \ + NotifySMPP.parse_phone_no(results['qsd']['to']) + + results['targets'] = NotifySMPP.split_path(results['fullpath']) + + # Support the 'to' variable so that we can support targets this way too + # 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifySMPP.parse_phone_no(results['qsd']['to']) + + # store any additional payload extras defined + results['payload'] = {NotifySMPP.unquote(x): NotifySMPP.unquote(y) + for x, y in results['qsd:'].items()} + + # Add our GET parameters in the event the user wants to pass them + results['params'] = {NotifySMPP.unquote(x): NotifySMPP.unquote(y) + for x, y in results['qsd-'].items()} + + # Support the 'from' and 'source' variable so that we can support + # targets this way too. + # 'from' makes it easier to use yaml configuration + if 'from' in results['qsd'] and len(results['qsd']['from']): + results['source'] = NotifySMPP.unquote(results['qsd']['from']) + elif results['targets']: + # from phone number is the first entry in the list otherwise + results['source'] = results['targets'].pop(0) + + return results diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec index 1a1f078c..95a51aec 100644 --- a/packaging/redhat/python-apprise.spec +++ b/packaging/redhat/python-apprise.spec @@ -49,7 +49,7 @@ NextcloudTalk, Notica, Notifiarr, Notifico, ntfy, Office365, OneSignal, Opsgenie, PagerDuty, PagerTree, ParsePlatform, Plivo, PopcornNotify, Prowl, Pushalot, PushBullet, Pushjet, PushMe, Pushover, PushSafer, Pushy, PushDeer, Revolt, Reddit, Resend, Rocket.Chat, RSyslog, SendGrid, ServerChan, Seven, SFR, -Signal, SimplePush, Sinch, Slack, SMSEagle, SMS Manager, SMTP2Go, SparkPost, +Signal, SimplePush, Sinch, Slack, SMPP, SMSEagle, SMS Manager, SMTP2Go, SparkPost, Splunk, Super Toasty, Streamlabs, Stride, Synology Chat, Syslog, Techulus Push, Telegram, Threema Gateway, Twilio, Twitter, Twist, Vapid, VictorOps, Voipms, Vonage, WebPush, WeCom Bot, WhatsApp, Webex Teams, Workflows, WxPusher, XBMC} diff --git a/test/test_plugin_smpp.py b/test/test_plugin_smpp.py new file mode 100644 index 00000000..a7fec0bb --- /dev/null +++ b/test/test_plugin_smpp.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2025, 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 logging +import sys +from unittest import mock +import pytest +from apprise import Apprise +from apprise.plugins.smpp import NotifySMPP +from apprise import NotifyType +from helpers import AppriseURLTester + +try: + import smpplib + +except ImportError: + # No problem; there is no smpplib support + pass + +logging.disable(logging.CRITICAL) + +# Our Testing URLs +apprise_url_tests = ( + ('smpp://', { + 'instance': TypeError, + }), + ('smpp:///', { + 'instance': TypeError, + }), + ('smpp://@/', { + 'instance': TypeError, + }), + ('smpp://user@/', { + 'instance': TypeError, + }), + ('smpp://user:pass/', { + 'instance': TypeError, + }), + ('smpp://user:pass@/', { + 'instance': TypeError, + }), + ('smpp://user@hostname', { + 'instance': TypeError, + }), + ('smpp://user:pass@host:/', { + 'instance': TypeError, + }), + ('smpp://user:pass@host:2775/', { + 'instance': TypeError, + }), + ('smpp://user:pass@host:2775/{}/{}'.format('1' * 10, 'a' * 32), { + # valid everything but target numbers + 'instance': NotifySMPP, + # We have no one to notify + 'notify_response': False, + }), + ('smpp://user:pass@host:2775/{}'.format('1' * 10), { + # everything valid + 'instance': NotifySMPP, + # We have no one to notify + 'notify_response': False, + }), + ('smpp://user:pass@host/{}/{}'.format('1' * 10, '1' * 10), { + 'instance': NotifySMPP, + }), + ('smpps://_?&from={}&to={},{}&user=user&password=pw'.format( + '1' * 10, '1' * 10, '1' * 10), { + # use get args to accomplish the same thing + 'instance': NotifySMPP, + }), +) + + +@pytest.mark.skipif( + 'smpplib' in sys.modules, + reason="Requires that smpplib NOT be installed") +def test_plugin_smpplib_import_error(): + """ + NotifySMPP() smpplib loading failure + """ + + # Attempt to instantiate our object + obj = Apprise.instantiate( + 'smpp://user:pass@host/{}/{}'.format('1' * 10, '1' * 10)) + + # It's not possible because our cryptography depedancy is missing + assert obj is None + + +@pytest.mark.skipif( + 'smpplib' not in sys.modules, reason="Requires smpplib") +def test_plugin_smpp_urls(): + """ + NotifySMPP() Apprise URLs + """ + # mock nested inside of outside function to avoid failing + # when smpplib is unavailable + with mock.patch('smpplib.client.Client') as mock_client_class: + mock_client_instance = mock.Mock() + mock_client_class.return_value = mock_client_instance + + # Raise exception on connect + mock_client_instance.connect.return_value = True + mock_client_instance.bind_transmitter.return_value = True + mock_client_instance.send_message.return_value = True + + # Run our general tests + AppriseURLTester(tests=apprise_url_tests).run_all() + + +@pytest.mark.skipif( + 'smpplib' not in sys.modules, reason="Requires smpplib") +def test_plugin_smpp_edge_case(): + """ + NotifySMPP() Apprise Edge Case + """ + + # mock nested inside of outside function to avoid failing + # when smpplib is unavailable + with mock.patch('smpplib.client.Client') as mock_client_class: + mock_client_instance = mock.Mock() + mock_client_class.return_value = mock_client_instance + + # Raise exception on connect + mock_client_instance.connect.side_effect = \ + smpplib.exceptions.ConnectionError + mock_client_instance.bind_transmitter.return_value = True + mock_client_instance.send_message.return_value = True + + # Instantiate our object + obj = Apprise.instantiate( + 'smpp://user:pass@host/{}/{}'.format('1' * 10, '1' * 10)) + + # Well fail to establish a connection + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is False + + # Raise exception on connect + mock_client_instance.connect.side_effect = None + mock_client_instance.bind_transmitter.return_value = True + mock_client_instance.send_message.side_effect = \ + smpplib.exceptions.ConnectionError + + # Well fail to deliver our message + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is False