From 875c8349fe22a6ad8c29bce17b94de77aa1da078 Mon Sep 17 00:00:00 2001 From: Diego Pedregal Date: Thu, 26 Jun 2025 14:11:53 +0200 Subject: [PATCH] Adds SMPP support --- KEYWORDS | 1 + README.md | 1 + apprise/plugins/smpp.py | 277 +++++++++++++++++++++++++++ packaging/redhat/python-apprise.spec | 2 +- requirements.txt | 1 + test/test_plugin_smpp.py | 144 ++++++++++++++ 6 files changed, 425 insertions(+), 1 deletion(-) create mode 100644 apprise/plugins/smpp.py create mode 100644 test/test_plugin_smpp.py diff --git a/KEYWORDS b/KEYWORDS index 52ca10a2..71457235 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 6059039e..bceea1bf 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,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/apprise/plugins/smpp.py b/apprise/plugins/smpp.py new file mode 100644 index 00000000..39f9ccd4 --- /dev/null +++ b/apprise/plugins/smpp.py @@ -0,0 +1,277 @@ +# -*- 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 + +import smpplib +import smpplib.consts +import smpplib.gsm + +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 + """ + + # The default descriptive name associated with the Notification + service_name = _('SMPP') + + # The services URL + service_url = 'https://smpp.org/' + + protocol = 'smpp' + secure_protocol = 'smpps' + + # 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}:{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, + 'required': True, + }, + 'from_phone': { + 'name': _('From Phone No'), + 'type': 'string', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + '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, + }, + }) + + def __init__(self, source=None, targets=None, **kwargs): + """ + Initialize SMPP Object + """ + + super().__init__(**kwargs) + + self.source = None + if source: + 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}:{port}/{source}/{targets}' + '/?{params}').format( + schema=self.secure_protocol if self.secure else self.protocol, + user=self.user, + password=self.password, + host=self.host, + port=self.port, + 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 + + client = smpplib.client.Client(self.host, self.port, + allow_unknown_opt_params=True) + client.connect() + client.bind_transmitter(system_id=self.user, password=self.password) + + 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) + if not results: + # We're done early as we couldn't load the results + return results + + 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 a396da89..2f0e9177 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, VictorOps, Voipms, Vonage, WeCom Bot, WhatsApp, Webex Teams, Workflows, WxPusher, XBMC} diff --git a/requirements.txt b/requirements.txt index 7d3b570d..ea9e5c1e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ requests-oauthlib click >= 5.0 markdown PyYAML +smpplib diff --git a/test/test_plugin_smpp.py b/test/test_plugin_smpp.py new file mode 100644 index 00000000..5cab7461 --- /dev/null +++ b/test/test_plugin_smpp.py @@ -0,0 +1,144 @@ +# -*- 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 +from json import dumps +from unittest import mock + +import pytest +import requests + +from apprise.plugins.smpp import NotifySmpp +from helpers import AppriseURLTester + +logging.disable(logging.CRITICAL) + +# Our Testing URLs +apprise_url_tests = ( + ('smpp://', { + 'instance': None, + }), + ('smpp:///', { + 'instance': None, + }), + ('smpp://@/', { + 'instance': None, + }), + ('smpp://user@/', { + 'instance': None, + }), + ('smpp://user:pass/', { + 'instance': None, + }), + ('smpp://user:pass@/', { + 'instance': None, + }), + ('smpp://user:pass@host:/', { + 'instance': None, + }), + ('smpp://user:pass@host:port/', { + 'instance': None, + }), + ('smpp://user:pass@host:port/{}/{}'.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:port/{}'.format('1' * 10), { + # everything valid + 'instance': NotifySmpp, + # We have no one to notify + 'notify_response': False, + }), + ('smpp://user:pass@host:port/{}/{}'.format('1' * 10, '1' * 10), { + 'instance': NotifySmpp, + }), + ('smpp://_?&from={}&to={},{}'.format( + '1' * 10, '1' * 10, '1' * 10), { + # use get args to accomplish the same thing + 'instance': NotifySmpp, + }), + ('smpp://user:pass@host:port/{}/{}'.format('1' * 10, '1' * 10), { + 'instance': NotifySmpp, + # throw a bizarre code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('smpp://user:pass@host:port/{}/{}'.format('1' * 10, '1' * 10), { + 'instance': NotifySmpp, + # Throws a series of connection and transfer exceptions when this flag + # is set and tests that we gracefully handle them + 'test_requests_exceptions': True, + }), +) + + +def test_plugin_smpp_urls(): + """ + NotifySmpp() Apprise URLs + """ + + # Run our general tests + AppriseURLTester(tests=apprise_url_tests).run_all() + + +@mock.patch('requests.post') +def test_plugin_smpp_edge_cases(mock_post): + """ + NotifySmpp() Edge Cases + """ + + # Prepare our response + response = requests.Request() + response.status_code = requests.codes.ok + + # Prepare Mock + mock_post.return_value = response + + # Initialize some generic (but valid) apikeys + apikey = 'b' * 32 + source = '+1 (555) 123-3456' + + # No apikey specified + with pytest.raises(TypeError): + NotifySmpp(source=source) + + # a error response + response.status_code = 400 + response.content = dumps({ + 'code': 21211, + 'message': "The 'To' number +1234567 is not a valid phone number.", + }) + mock_post.return_value = response + + # Initialize our object + obj = NotifySmpp(source=source) + + # We will fail with the above error code + assert obj.notify('title', 'body', 'info') is False