Add Voip.ms support (#814)

This commit is contained in:
Phea Duch 2023-02-10 22:47:32 -05:00 committed by GitHub
parent 429470d45e
commit 4f7b23a7c1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 637 additions and 1 deletions

View File

@ -84,4 +84,5 @@ Twitter
Vonage Vonage
Webex Webex
Windows Windows
Voipms
XBMC XBMC

View File

@ -139,6 +139,7 @@ The table below identifies the services this tool supports and some example serv
| [Sinch](https://github.com/caronc/apprise/wiki/Notify_sinch) | sinch:// | (TCP) 443 | sinch://ServicePlanId:ApiToken@FromPhoneNo<br/>sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo<br/>sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/<br/>sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo<br/>sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Sinch](https://github.com/caronc/apprise/wiki/Notify_sinch) | sinch:// | (TCP) 443 | sinch://ServicePlanId:ApiToken@FromPhoneNo<br/>sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo<br/>sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/<br/>sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo<br/>sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
| [SMSEagle](https://github.com/caronc/apprise/wiki/Notify_smseagle) | smseagle:// or smseagles:// | (TCP) 80 or 443 | smseagles://hostname:port/ToPhoneNo<br/>smseagles://hostname:port/@ToContact<br/>smseagles://hostname:port/#ToGroup<br/>smseagles://hostname:port/ToPhoneNo1/#ToGroup/@ToContact/ | [SMSEagle](https://github.com/caronc/apprise/wiki/Notify_smseagle) | smseagle:// or smseagles:// | (TCP) 80 or 443 | smseagles://hostname:port/ToPhoneNo<br/>smseagles://hostname:port/@ToContact<br/>smseagles://hostname:port/#ToGroup<br/>smseagles://hostname:port/ToPhoneNo1/#ToGroup/@ToContact/
| [Twilio](https://github.com/caronc/apprise/wiki/Notify_twilio) | twilio:// | (TCP) 443 | twilio://AccountSid:AuthToken@FromPhoneNo<br/>twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo<br/>twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/<br/>twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo?apikey=Key<br/>twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo<br/>twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Twilio](https://github.com/caronc/apprise/wiki/Notify_twilio) | twilio:// | (TCP) 443 | twilio://AccountSid:AuthToken@FromPhoneNo<br/>twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo<br/>twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/<br/>twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo?apikey=Key<br/>twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo<br/>twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
| [Voipms](https://github.com/caronc/apprise/wiki/Notify_voipms) | voipms:// | (TCP) 80 or 443 | voipms://email:password/FromPhoneNo<br/>voipms://email:password/FromPhoneNo/ToPhoneNo<br/>voipms://email:password/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
| [Vonage](https://github.com/caronc/apprise/wiki/Notify_nexmo) (formerly Nexmo) | nexmo:// | (TCP) 443 | nexmo://ApiKey:ApiSecret@FromPhoneNo<br/>nexmo://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo<br/>nexmo://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Vonage](https://github.com/caronc/apprise/wiki/Notify_nexmo) (formerly Nexmo) | nexmo:// | (TCP) 443 | nexmo://ApiKey:ApiSecret@FromPhoneNo<br/>nexmo://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo<br/>nexmo://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
## Desktop Notifications ## Desktop Notifications

View File

@ -0,0 +1,377 @@
# -*- coding: utf-8 -*-
# BSD 3-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
#
# 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.
#
# 3. Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# 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.
# Create an account https://voip.ms/ if you don't already have one
#
# Enable API and set an API password here:
# - https://voip.ms/m/api.php
#
# Read more about VoIP.ms API here:
# - https://voip.ms/m/apidocs.php
import requests
from json import loads
from .NotifyBase import NotifyBase
from ..common import NotifyType
from ..utils import is_phone_no
from ..utils import is_email
from ..utils import parse_phone_no
from ..AppriseLocale import gettext_lazy as _
class NotifyVoipms(NotifyBase):
"""
A wrapper for Voipms Notifications
"""
# The default descriptive name associated with the Notification
service_name = 'VoIPms'
# The services URL
service_url = 'https://voip.ms'
# The default protocol
secure_protocol = 'voipms'
# A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_voipms'
# Voipms uses the http protocol with JSON requests
notify_url = 'https://voip.ms/api/v1/rest.php'
# 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}://{password}:{email}',
'{schema}://{password}:{email}/{from_phone}/{targets}',
)
# Define our template tokens
template_tokens = dict(NotifyBase.template_tokens, **{
'email': {
'name': _('User Email'),
'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',
},
'target_phone': {
'name': _('Target Phone No'),
'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',
},
'from': {
'alias_of': 'from_phone',
},
})
def __init__(self, email, source=None, targets=None, **kwargs):
"""
Initialize Voipms Object
"""
super().__init__(**kwargs)
# Validate our params here.
if self.password is None:
msg = 'Password has to be specified.'
self.logger.warning(msg)
raise TypeError(msg)
# User is the email associated with the account
result = is_email(email)
if not result:
msg = 'An invalid Voipms user email: ' \
'({}) was specified.'.format(email)
self.logger.warning(msg)
raise TypeError(msg)
self.email = result['full_email']
# Validate our source Phone #
result = is_phone_no(source)
if not result:
msg = 'An invalid Voipms source phone # ' \
'({}) was specified.'.format(source)
self.logger.warning(msg)
raise TypeError(msg)
# Source Phone # only supports +1 country code
# Allow 7 digit phones (presume they're local with +1 country code)
if result['country'] and result['country'] != '1':
msg = 'Voipms only supports +1 country code ' \
'({}) was specified.'.format(source)
self.logger.warning(msg)
raise TypeError(msg)
# Store our source phone number (without country code)
self.source = result['area'] + result['line']
# Parse our targets
self.targets = list()
if targets:
for target in parse_phone_no(targets):
# Validate targets and drop bad ones:
result = is_phone_no(target)
# Target Phone # only supports +1 country code
if result['country'] != '1':
self.logger.warning(
'Dropped invalid phone # '
'({}) specified.'.format(target),
)
continue
# store valid phone number
self.targets.append(result['area'] + result['line'])
else:
# Send a message to ourselves
self.targets.append(self.source)
return
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
"""
Perform Voipms Notification
"""
if len(self.targets) == 0:
# There were no services to notify
self.logger.warning('There were no Voipms targets to notify.')
return False
# error tracking (used for function return)
has_error = False
# Prepare our headers
headers = {
'User-Agent': self.app_id,
'Content-Type': 'application/x-www-form-urlencoded',
}
# Prepare our payload
payload = {
'api_username': self.email,
'api_password': self.password,
'did': self.source,
'message': body,
'method': 'sendSMS',
# Gets filled in the loop below
'dst': None
}
# Create a copy of the targets list
targets = list(self.targets)
while len(targets):
# Get our target to notify
target = targets.pop(0)
# Add target Phone #
payload['dst'] = target
# Some Debug Logging
self.logger.debug('Voipms GET URL: {} (cert_verify={})'.format(
self.notify_url, self.verify_certificate))
self.logger.debug('Voipms Payload: {}' .format(payload))
# Always call throttle before any remote server i/o is made
self.throttle()
response = {'status': 'unknown', 'message': ''}
try:
r = requests.get(
self.notify_url,
params=payload,
headers=headers,
verify=self.verify_certificate,
timeout=self.request_timeout,
)
try:
response = loads(r.content)
except (AttributeError, TypeError, ValueError):
# ValueError = r.content is Unparsable
# TypeError = r.content is None
# AttributeError = r is None
pass
if r.status_code != requests.codes.ok:
# We had a problem
status_str = \
NotifyVoipms.http_response_code_lookup(
r.status_code)
self.logger.warning(
'Failed to send Voipms 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
# Voipms sends 200 OK even if there is an error
# check if status in response and if it is not success
if response is not None and response['status'] != 'success':
self.logger.warning(
'Failed to send Voipms notification to {}: '
'status: {}, message: {}'.format(
target, response['status'], response['message'])
)
# Mark our failure
has_error = True
continue
else:
self.logger.info(
'Sent Voipms notification to %s' % target)
except requests.RequestException as e:
self.logger.warning(
'A Connection error occurred sending Voipms:%s '
'notification.' % 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 = {
'method': 'sendSMS'
}
# Extend our parameters
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
schemaStr = \
'{schema}://{password}:{email}/{from_phone}/{targets}/?{params}'
return schemaStr.format(
schema=self.secure_protocol,
email=self.email,
password=self.pprint(self.password, privacy, safe=''),
from_phone='1' + self.pprint(self.source, privacy, safe=''),
targets='/'.join(
['1' + NotifyVoipms.quote(x, safe='') for x in self.targets]),
params=NotifyVoipms.urlencode(params))
@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
results['targets'] = \
NotifyVoipms.split_path(results['fullpath'])
if 'from' in results['qsd'] and len(results['qsd']['from']):
results['source'] = \
NotifyVoipms.unquote(results['qsd']['from'])
elif results['targets']:
# The from phone no is the first entry in the list otherwise
results['source'] = results['targets'].pop(0)
# Swap user for pass since our input is: password:email
# where email is user@hostname (or user@domain)
user = results['password']
password = results['user']
results['password'] = password
results['user'] = user
results['email'] = '{}@{}'.format(
NotifyVoipms.unquote(user),
NotifyVoipms.unquote(results['host']),
)
if 'to' in results['qsd'] and len(results['qsd']['to']):
results['targets'] += \
NotifyVoipms.parse_phone_no(results['qsd']['to'])
return results

View File

@ -54,7 +54,7 @@ Opsgenie, PagerDuty, ParsePlatform, PopcornNotify, Prowl, Pushalot, PushBullet,
Pushjet, Pushover, PushSafer, Reddit, Rocket.Chat, SendGrid, ServerChan, Signal, Pushjet, Pushover, PushSafer, Reddit, Rocket.Chat, SendGrid, ServerChan, Signal,
SimplePush, Sinch, Slack, SMSEagle, SMTP2Go, Spontit, SparkPost, Super Toasty, SimplePush, Sinch, Slack, SMSEagle, SMTP2Go, Spontit, SparkPost, Super Toasty,
Streamlabs, Stride, Syslog, Techulus Push, Telegram, Twilio, Twitter, Twist, Streamlabs, Stride, Syslog, Techulus Push, Telegram, Twilio, Twitter, Twist,
XBMC, Vonage, Webex Teams} XBMC, Voipms, Vonage, Webex Teams}
Name: python-%{pypi_name} Name: python-%{pypi_name}
Version: 1.2.1 Version: 1.2.1

257
test/test_plugin_voipms.py Normal file
View File

@ -0,0 +1,257 @@
# -*- coding: utf-8 -*-
# BSD 3-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
#
# 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.
#
# 3. Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# 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 unittest import mock
import pytest
import requests
from json import dumps
from apprise import Apprise
from apprise.plugins.NotifyVoipms import NotifyVoipms
from helpers import AppriseURLTester
# Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL)
# Our Testing URLs
apprise_url_tests = (
('voipms://', {
# No email/password specified
'instance': TypeError,
}),
('voipms://@:', {
# Invalid url
'instance': TypeError,
}),
('voipms://{}/{}'.format('user@example.com', '1' * 11), {
# No password specified
'instance': TypeError,
}),
('voipms://:{}'.format('password'), {
# No email specified
'instance': TypeError,
}),
('voipms://{}:{}/{}'.format('user@', 'pass', '1' * 11), {
# Check valid email
'instance': TypeError,
}),
('voipms://{password}:{email}'.format(
email='user@example.com',
password='password'), {
# No from_phone specified
'instance': TypeError,
}),
# Invalid phone number test
('voipms://{password}:{email}/1613'.format(
email='user@example.com',
password='password'), {
# Invalid phone number
'instance': TypeError,
}),
# Invalid country code phone number test
('voipms://{password}:{email}/01133122446688'.format(
email='user@example.com',
password='password'), {
# Non North American phone number
'instance': TypeError,
}),
('voipms://{password}:{email}/{from_phone}/{targets}/'.format(
email='user@example.com',
password='password',
from_phone='16134448888',
targets='/'.join(['26134442222'])), {
# Invalid target phone number
'instance': NotifyVoipms,
'response': False,
'requests_response_code': 999,
}),
('voipms://{password}:{email}/{from_phone}'.format(
email='user@example.com',
password='password',
from_phone='16138884444'), {
'instance': NotifyVoipms,
# No targets specified
'response': False,
'requests_response_code': 999,
}),
('voipms://{password}:{email}/?from={from_phone}'.format(
email='user@example.com',
password='password',
from_phone='16138884444'), {
'instance': NotifyVoipms,
# No targets specified
'response': False,
'requests_response_code': 999,
}),
('voipms://{password}:{email}/{from_phone}/{targets}/'.format(
email='user@example.com',
password='password',
from_phone='16138884444',
targets='/'.join(['16134442222'])), {
# Valid
'instance': NotifyVoipms,
'response': True,
'privacy_url': 'voipms://p...d:user@example.com/16...4',
}),
('voipms://{password}:{email}/{from_phone}/{targets}/'.format(
email='user@example.com',
password='password',
from_phone='16138884444',
targets='/'.join(['16134442222', '16134443333'])), {
# Valid multiple targets
'instance': NotifyVoipms,
'response': True,
'privacy_url': 'voipms://p...d:user@example.com/16...4',
}),
('voipms://{password}:{email}/?from={from_phone}&to={targets}'.format(
email='user@example.com',
password='password',
from_phone='16138884444',
targets='16134448888'), {
# Valid
'instance': NotifyVoipms,
}),
('voipms://{password}:{email}/{from_phone}/{targets}/'.format(
email='user@example.com',
password='password',
from_phone='16138884444',
targets='16134442222'), {
'instance': NotifyVoipms,
# Throws a series of errors
'test_requests_exceptions': True,
}),
)
def test_plugin_voipms():
"""
NotifyVoipms() Apprise URLs
"""
# Run our general tests
AppriseURLTester(tests=apprise_url_tests).run_all()
@ mock.patch('requests.get')
def test_plugin_voipms_edge_cases(mock_get):
"""
NotifyVoipms() Edge Cases
"""
# Prepare our response
response = requests.Request()
response.status_code = requests.codes.ok
# Prepare Mock
mock_get.return_value = response
# Initialize some generic (but valid) tokens
email = 'user@example.com'
password = 'password'
source = '+1 (555) 123-3456'
targets = '+1 (555) 123-9876'
# No email specified
with pytest.raises(TypeError):
NotifyVoipms(email=None, source=source)
# a error response is returned
response.status_code = 400
response.content = dumps({
'code': 21211,
'message': "Unable to process your request.",
})
mock_get.return_value = response
# Initialize our object
obj = Apprise.instantiate(
'voipms://{password}:{email}/{source}/{targets}'.format(
email=email,
password=password,
source=source,
targets=targets))
assert isinstance(obj, NotifyVoipms)
# We will fail with the above error code
assert obj.notify('title', 'body', 'info') is False
@mock.patch('requests.get')
def test_plugin_voipms_non_success_status(mock_get):
"""
NotifyVoipms() Non Success Status
"""
# Prepare our response
response = requests.Request()
response.status_code = requests.codes.ok
# Prepare Mock
mock_get.return_value = response
# A 200 response is returned but non-success message
response.status_code = 200
response.content = dumps({
'status': 'invalid_credentials',
'message': 'Username or Password is incorrect',
})
obj = Apprise.instantiate(
'voipms://{password}:{email}/{source}/{targets}'.format(
email='user@example.com',
password='badpassword',
source='16134448888',
targets='16134442222'))
assert isinstance(obj, NotifyVoipms)
# We will fail with the above error code
assert obj.notify('title', 'body', 'info') is False
response.content = '{'
assert obj.send('title', 'body') is False