# -*- 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. # For this to work correctly you need to have a valid SFR DMC service account # to whicthe API password can be generated. A "space" is also necessary # (space = a logical separation between clients), which will give you a # specific spaceId # # Expected credentials looks a little like this: # serviceId: 84920958892 - Random numbers # servicePassword: XxXXxXXx - Random characters # spaceId: 984348 - Random numbers # # 1. Visit https://www.sfr.fr/ # # 2. Url will look like this # https://www.dmc.sfr-sh.fr/DmcWS/1.5.8/JsonService// import requests import json from .base import NotifyBase from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import is_phone_no, parse_phone_no from ..url import PrivacyMode class NotifySFR(NotifyBase): """ A wrapper for SFR French Telecom DMC API """ # The default descriptive name associated with the Notification service_name = _('Société Française du Radiotéléphone') # The services URL service_url = 'https://www.sfr.fr/' # The default protocol protocol = 'sfr' # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_sfr' # SFR api notify_url = ( 'https://www.dmc.sfr-sh.fr/DmcWS/1.5.8/JsonService/' 'MessagesUnitairesWS/addSingleCall' # this is the actual api call ) # The maximum length of the body body_maxlen = 160 # 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}@{space_id}/{targets}', ) # Define our tokens template_tokens = dict( NotifyBase.template_tokens, **{ 'user': { 'name': _('Service ID'), 'type': 'string', 'required': True, }, 'password': { 'name': _('Service Password'), 'type': 'string', 'private': True, 'required': True, }, 'space_id': { 'name': _('Space ID'), 'type': 'string', 'private': True, 'required': True, }, 'target': { 'name': _('Recipient Phone Number'), 'type': 'string', '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, **{ 'lang': { 'name': _('Language'), 'type': 'string', 'default': 'fr_FR', 'required': True, }, 'sender': { 'name': _('Sender Name'), 'type': 'string', 'required': True, 'default': '', }, 'from': { 'alias_of': 'sender' }, 'media': { 'name': _('Media Type'), 'type': 'string', 'required': True, 'default': 'SMSUnicode', 'values': ['SMS', 'SMSLong', 'SMSUnicode', 'SMSUnicodeLong'], }, 'timeout': { 'name': _('Timeout'), 'type': 'int', 'default': 2880, 'required': False, }, 'voice': { 'name': _('TTS Voice'), 'type': 'string', 'default': 'claire08s', 'values': ['claire08s', 'laura8k'], 'required': False, }, 'to': { 'alias_of': 'targets', }, } ) def __init__(self, space_id=None, targets=None, lang=None, sender=None, media=None, timeout=None, voice=None, **kwargs): """ Initialize SFR Object """ super().__init__(**kwargs) if not (self.user and self.password): msg = 'A SFR user (serviceId) and password (servicePassword) ' \ 'combination was not provided.' self.logger.warning(msg) raise TypeError(msg) self.space_id = space_id if not self.space_id: msg = 'A SFR Space ID is required.' self.logger.warning(msg) raise TypeError(msg) self.voice = voice \ if voice else self.template_args['voice']['default'] self.lang = lang \ if lang else self.template_args['lang']['default'] self.media = media \ if media else self.template_args['media']['default'] self.sender = sender \ if sender else self.template_args['sender']['default'] # Set our Time to Live Flag self.timeout = self.template_args['timeout']['default'] try: self.timeout = int(timeout) except (ValueError, TypeError): # set default timeout self.timeout = 2880 pass # Parse our targets self.targets = list() for target in parse_phone_no(targets): # Validate targets and drop bad ones: result = is_phone_no(target) if not result: self.logger.warning( 'Dropped invalid phone # ' '({}) specified.'.format(target), ) continue # store valid phone number self.targets.append(result['full']) if not self.targets: msg = ('No receiver phone number has been provided. Please ' 'provide as least one valid phone number.') self.logger.warning(msg) raise TypeError(msg) return def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform the SFR notification """ # error tracking (used for function return) has_error = False # Create a copy of the targets list targets = list(self.targets) # Construct the authentication JSON auth_payload = json.dumps({ 'serviceId': self.user, 'servicePassword': self.password, 'spaceId': self.space_id, 'lang': self.lang, }) base_payload = { # Can be 'SMS', 'SMSLong', 'SMSUnicode', or 'SMSUnicodeLong' 'media': self.media, # Content of the message 'textMsg': body, # Receiver's phone number (set below) 'to': None, # Optional, default to '' 'from': self.sender, # Optional, default 2880 minutes 'timeout': self.timeout, # Optional, default to French voice 'ttsVoice': self.voice, } while len(targets): # Get our target to notify target = targets.pop(0) # Prepare our target phone no base_payload['to'] = target # Always call throttle before any remote server i/o is made self.throttle() # Finalize our payload payload = { 'authenticate': auth_payload, 'messageUnitaire': json.dumps(base_payload, ensure_ascii=True) } # Some Debug Logging self.logger.debug('SFR POST URL: {} (cert_verify={})'.format( self.notify_url, self.verify_certificate)) self.logger.debug('SFR Payload: {}' .format(payload)) try: r = requests.post( self.notify_url, params=payload, verify=self.verify_certificate, timeout=self.request_timeout, ) try: content = json.loads(r.content) except (AttributeError, TypeError, ValueError): # ValueError = r.content is Unparsable # TypeError = r.content is None # AttributeError = r is None content = {} # Check if the request was successfull if r.status_code not in ( requests.codes.ok, requests.codes.no_content, ): # We had a problem status_str = \ NotifySFR.http_response_code_lookup( r.status_code) self.logger.warning( 'Failed to send SFR 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 # SFR returns a code 200 even if the authentication fails # It then indicates in the content['success'] field the # Actual state of the transaction if not content.get('success', False): self.logger.warning( 'SFR Notification to {} was not sent by the server: ' 'server_error={}, fatal={}.'.format( target, content.get('errorCode', 'UNKNOWN'), content.get('fatal', 'True'), )) # Mark our failure has_error = True continue self.logger.info( 'Sent SFR notification to %s.' % target) except requests.RequestException as e: self.logger.warning( 'A Connection error occurred sending SFR:%s ' 'notification.' % target ) self.logger.debug('Socket Exception: %s' % str(e)) # Mark our failure has_error = True continue return not has_error @property def url_identifier(self): """ Returns all of the identifiers that make this URL unique from another simliar 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.space_id, ) def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ # Define any URL parameters params = { 'from': self.sender, 'timeout': str(self.timeout), 'voice': self.voice, 'lang': self.lang, 'media': self.media, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return '{schema}://{user}:{password}@{sid}/{targets}?{params}'.format( schema=self.secure_protocol if self.secure else self.protocol, user=self.user, password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe='', ), sid=self.pprint(self.space_id, privacy, safe=''), targets='/'.join( [NotifySFR.quote(x, safe='') for x in self.targets]), params=self.urlencode(params), ) def __len__(self): """ Returns the number of targets associated with this notification """ return len(self.targets) @staticmethod def parse_url(url): """ Parse the URL and return arguments required to initialize this plugin """ # NotifyBase.parse_url() will make the initial parsing of your string # very easy to use. It will tokenize the entire URL for you. The # tokens are then passed into your __init__() function you defined to # generate you're 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 # Extract user and password results['space_id'] = results.get('host') results['targets'] = NotifySFR.split_path(results['fullpath']) # Extract additional parameters qsd = results.get('qsd', {}) results['sender'] = \ NotifySFR.unquote(qsd.get('sender', qsd.get('from'))) results['timeout'] = NotifySFR.unquote(qsd.get('timeout')) results['voice'] = NotifySFR.unquote(qsd.get('voice')) results['lang'] = NotifySFR.unquote(qsd.get('lang')) results['media'] = NotifySFR.unquote(qsd.get('media')) # 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'] += \ NotifySFR.parse_phone_no(results['qsd']['to']) return results