1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
4# All rights reserved.
5#
6# This code is licensed under the MIT License.
7#
8# Permission is hereby granted, free of charge, to any person obtaining a copy
9# of this software and associated documentation files(the "Software"), to deal
10# in the Software without restriction, including without limitation the rights
11# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12# copies of the Software, and to permit persons to whom the Software is
13# furnished to do so, subject to the following conditions :
14#
15# The above copyright notice and this permission notice shall be included in
16# all copies or substantial portions of the Software.
17#
18# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24# THE SOFTWARE.
25
26# Create an account https://msg91.com/ if you don't already have one
27#
28# Get your (authkey) from the dashboard here:
29#   - https://world.msg91.com/user/index.php#api
30#
31# Get details on the API used in this plugin here:
32#   - https://world.msg91.com/apidoc/textsms/send-sms.php
33
34import requests
35
36from .NotifyBase import NotifyBase
37from ..common import NotifyType
38from ..utils import is_phone_no
39from ..utils import parse_phone_no
40from ..utils import validate_regex
41from ..AppriseLocale import gettext_lazy as _
42
43
44class MSG91Route(object):
45    """
46    Transactional SMS Routes
47    route=1 for promotional, route=4 for transactional SMS.
48    """
49    PROMOTIONAL = 1
50    TRANSACTIONAL = 4
51
52
53# Used for verification
54MSG91_ROUTES = (
55    MSG91Route.PROMOTIONAL,
56    MSG91Route.TRANSACTIONAL,
57)
58
59
60class MSG91Country(object):
61    """
62    Optional value that can be specified on the MSG91 api
63    """
64    INTERNATIONAL = 0
65    USA = 1
66    INDIA = 91
67
68
69# Used for verification
70MSG91_COUNTRIES = (
71    MSG91Country.INTERNATIONAL,
72    MSG91Country.USA,
73    MSG91Country.INDIA,
74)
75
76
77class NotifyMSG91(NotifyBase):
78    """
79    A wrapper for MSG91 Notifications
80    """
81
82    # The default descriptive name associated with the Notification
83    service_name = 'MSG91'
84
85    # The services URL
86    service_url = 'https://msg91.com'
87
88    # The default protocol
89    secure_protocol = 'msg91'
90
91    # A URL that takes you to the setup/help of the specific protocol
92    setup_url = 'https://github.com/caronc/apprise/wiki/Notify_msg91'
93
94    # MSG91 uses the http protocol with JSON requests
95    notify_url = 'https://world.msg91.com/api/sendhttp.php'
96
97    # The maximum length of the body
98    body_maxlen = 160
99
100    # A title can not be used for SMS Messages.  Setting this to zero will
101    # cause any title (if defined) to get placed into the message body.
102    title_maxlen = 0
103
104    # Define object templates
105    templates = (
106        '{schema}://{authkey}/{targets}',
107        '{schema}://{sender}@{authkey}/{targets}',
108    )
109
110    # Define our template tokens
111    template_tokens = dict(NotifyBase.template_tokens, **{
112        'authkey': {
113            'name': _('Authentication Key'),
114            'type': 'string',
115            'required': True,
116            'private': True,
117            'regex': (r'^[a-z0-9]+$', 'i'),
118        },
119        'target_phone': {
120            'name': _('Target Phone No'),
121            'type': 'string',
122            'prefix': '+',
123            'regex': (r'^[0-9\s)(+-]+$', 'i'),
124            'map_to': 'targets',
125        },
126        'targets': {
127            'name': _('Targets'),
128            'type': 'list:string',
129        },
130        'sender': {
131            'name': _('Sender ID'),
132            'type': 'string',
133        },
134    })
135
136    # Define our template arguments
137    template_args = dict(NotifyBase.template_args, **{
138        'to': {
139            'alias_of': 'targets',
140        },
141        'route': {
142            'name': _('Route'),
143            'type': 'choice:int',
144            'values': MSG91_ROUTES,
145            'default': MSG91Route.TRANSACTIONAL,
146        },
147        'country': {
148            'name': _('Country'),
149            'type': 'choice:int',
150            'values': MSG91_COUNTRIES,
151        },
152    })
153
154    def __init__(self, authkey, targets=None, sender=None, route=None,
155                 country=None, **kwargs):
156        """
157        Initialize MSG91 Object
158        """
159        super(NotifyMSG91, self).__init__(**kwargs)
160
161        # Authentication Key (associated with project)
162        self.authkey = validate_regex(
163            authkey, *self.template_tokens['authkey']['regex'])
164        if not self.authkey:
165            msg = 'An invalid MSG91 Authentication Key ' \
166                  '({}) was specified.'.format(authkey)
167            self.logger.warning(msg)
168            raise TypeError(msg)
169
170        if route is None:
171            self.route = self.template_args['route']['default']
172
173        else:
174            try:
175                self.route = int(route)
176                if self.route not in MSG91_ROUTES:
177                    # Let outer except catch thi
178                    raise ValueError()
179
180            except (ValueError, TypeError):
181                msg = 'The MSG91 route specified ({}) is invalid.'\
182                    .format(route)
183                self.logger.warning(msg)
184                raise TypeError(msg)
185
186        if country:
187            try:
188                self.country = int(country)
189                if self.country not in MSG91_COUNTRIES:
190                    # Let outer except catch thi
191                    raise ValueError()
192
193            except (ValueError, TypeError):
194                msg = 'The MSG91 country specified ({}) is invalid.'\
195                    .format(country)
196                self.logger.warning(msg)
197                raise TypeError(msg)
198        else:
199            self.country = country
200
201        # Store our sender
202        self.sender = sender
203
204        # Parse our targets
205        self.targets = list()
206
207        for target in parse_phone_no(targets):
208            # Validate targets and drop bad ones:
209            result = is_phone_no(target)
210            if not result:
211                self.logger.warning(
212                    'Dropped invalid phone # '
213                    '({}) specified.'.format(target),
214                )
215                continue
216
217            # store valid phone number
218            self.targets.append(result['full'])
219
220        return
221
222    def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
223        """
224        Perform MSG91 Notification
225        """
226
227        if len(self.targets) == 0:
228            # There were no services to notify
229            self.logger.warning('There were no MSG91 targets to notify.')
230            return False
231
232        # Prepare our headers
233        headers = {
234            'User-Agent': self.app_id,
235            'Content-Type': 'application/x-www-form-urlencoded',
236        }
237
238        # Prepare our payload
239        payload = {
240            'sender': self.sender if self.sender else self.app_id,
241            'authkey': self.authkey,
242            'message': body,
243            'response': 'json',
244            # target phone numbers are sent with a comma delimiter
245            'mobiles': ','.join(self.targets),
246            'route': str(self.route),
247        }
248
249        if self.country:
250            payload['country'] = str(self.country)
251
252        # Some Debug Logging
253        self.logger.debug('MSG91 POST URL: {} (cert_verify={})'.format(
254            self.notify_url, self.verify_certificate))
255        self.logger.debug('MSG91 Payload: {}' .format(payload))
256
257        # Always call throttle before any remote server i/o is made
258        self.throttle()
259
260        try:
261            r = requests.post(
262                self.notify_url,
263                data=payload,
264                headers=headers,
265                verify=self.verify_certificate,
266                timeout=self.request_timeout,
267            )
268
269            if r.status_code != requests.codes.ok:
270                # We had a problem
271                status_str = \
272                    NotifyMSG91.http_response_code_lookup(
273                        r.status_code)
274
275                self.logger.warning(
276                    'Failed to send MSG91 notification to {}: '
277                    '{}{}error={}.'.format(
278                        ','.join(self.targets),
279                        status_str,
280                        ', ' if status_str else '',
281                        r.status_code))
282
283                self.logger.debug(
284                    'Response Details:\r\n{}'.format(r.content))
285                return False
286
287            else:
288                self.logger.info(
289                    'Sent MSG91 notification to %s.' % ','.join(self.targets))
290
291        except requests.RequestException as e:
292            self.logger.warning(
293                'A Connection error occurred sending MSG91:%s '
294                'notification.' % ','.join(self.targets)
295            )
296            self.logger.debug('Socket Exception: %s' % str(e))
297
298            return False
299
300        return True
301
302    def url(self, privacy=False, *args, **kwargs):
303        """
304        Returns the URL built dynamically based on specified arguments.
305        """
306
307        # Define any URL parameters
308        params = {
309            'route': str(self.route),
310        }
311
312        # Extend our parameters
313        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
314
315        if self.country:
316            params['country'] = str(self.country)
317
318        return '{schema}://{authkey}/{targets}/?{params}'.format(
319            schema=self.secure_protocol,
320            authkey=self.pprint(self.authkey, privacy, safe=''),
321            targets='/'.join(
322                [NotifyMSG91.quote(x, safe='') for x in self.targets]),
323            params=NotifyMSG91.urlencode(params))
324
325    @staticmethod
326    def parse_url(url):
327        """
328        Parses the URL and returns enough arguments that can allow
329        us to re-instantiate this object.
330
331        """
332
333        results = NotifyBase.parse_url(url, verify_host=False)
334        if not results:
335            # We're done early as we couldn't load the results
336            return results
337
338        # Get our entries; split_path() looks after unquoting content for us
339        # by default
340        results['targets'] = NotifyMSG91.split_path(results['fullpath'])
341
342        # The hostname is our authentication key
343        results['authkey'] = NotifyMSG91.unquote(results['host'])
344
345        if 'route' in results['qsd'] and len(results['qsd']['route']):
346            results['route'] = results['qsd']['route']
347
348        if 'country' in results['qsd'] and len(results['qsd']['country']):
349            results['country'] = results['qsd']['country']
350
351        # Support the 'to' variable so that we can support targets this way too
352        # The 'to' makes it easier to use yaml configuration
353        if 'to' in results['qsd'] and len(results['qsd']['to']):
354            results['targets'] += \
355                NotifyMSG91.parse_phone_no(results['qsd']['to'])
356
357        return results
358