Added OctoPush Support

octopush-support
Chris Caron 2022-12-18 16:45:18 -05:00
parent 91faed0c6d
commit 050ffd82ed
5 changed files with 629 additions and 14 deletions

View File

@ -59,6 +59,7 @@ Notica
Notifiarr
Notifico
Ntfy
Octopush
Office365
OneSignal
Opsgenie

View File

@ -95,6 +95,7 @@ The table below identifies the services this tool supports and some example serv
| [Notifiarr](https://github.com/caronc/apprise/wiki/Notify_notifiarr) | notifiarr:// | (TCP) 443 | notifiarr://apikey/#channel<br />notifiarr://apikey/#channel1/#channel2/#channeln
| [Notifico](https://github.com/caronc/apprise/wiki/Notify_notifico) | notifico:// | (TCP) 443 | notifico://ProjectID/MessageHook/
| [ntfy](https://github.com/caronc/apprise/wiki/Notify_ntfy) | ntfy:// | (TCP) 80 or 443 | ntfy://topic/<br/>ntfys://topic/
| [Octopush](https://github.com/caronc/apprise/wiki/Notify_octopush) | octopush:// | (TCP) 443 | octopush://APILogin/APIKey/TargetPhoneNo<br />octopush://Sender:APILogin/APIKey/TargetPhoneNo<br />octopush://Sender:APILogin/APIKey/TargetPhoneNo1/TargetPhoneNo2/TargetPhoneNoN
| [Office 365](https://github.com/caronc/apprise/wiki/Notify_office365) | o365:// | (TCP) 443 | o365://TenantID:AccountEmail/ClientID/ClientSecret<br />o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail<br />o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail1/TargetEmail2/TargetEmailN
| [OneSignal](https://github.com/caronc/apprise/wiki/Notify_onesignal) | onesignal:// | (TCP) 443 | onesignal://AppID@APIKey/PlayerID<br/>onesignal://TemplateID:AppID@APIKey/UserID<br/>onesignal://AppID@APIKey/#IncludeSegment<br/>onesignal://AppID@APIKey/Email
| [Opsgenie](https://github.com/caronc/apprise/wiki/Notify_opsgenie) | opsgenie:// | (TCP) 443 | opsgenie://APIKey<br/>opsgenie://APIKey/UserID<br/>opsgenie://APIKey/#Team<br/>opsgenie://APIKey/\*Schedule<br/>opsgenie://APIKey/^Escalation

485
apprise/plugins/octopush.py Normal file
View File

@ -0,0 +1,485 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2022 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files(the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions :
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
# API Docs for sending a notification
# Soure: https://dev.octopush.com/en/sms-gateway-api-documentation/send-sms/
#
import requests
from .NotifyBase import NotifyBase
from ..URLBase import PrivacyMode
from ..common import NotifyType
from ..utils import is_phone_no
from ..utils import is_email
from ..utils import parse_phone_no
from ..utils import parse_bool
from ..utils import validate_regex
from ..AppriseLocale import gettext_lazy as _
# Octopush Message Types
class OctopushType(object):
PREMIUM = 'sms_premium'
LOW_COST = 'sms_low_cost'
OCTOPUSH_TYPE_MAP = {
# Maps against string 'sms_premium'
'p': OctopushType.PREMIUM,
'sms_p': OctopushType.PREMIUM,
'smsp': OctopushType.PREMIUM,
'+': OctopushType.PREMIUM,
# Maps against string 'sms_low_cost'
'l': OctopushType.LOW_COST,
'sms_l': OctopushType.LOW_COST,
'smsl': OctopushType.LOW_COST,
'-': OctopushType.LOW_COST,
}
OCTOPUSH_TYPES = (
OctopushType.PREMIUM,
OctopushType.LOW_COST,
)
# Purpose
class OctopushPurpose(object):
ALERT = 'alert'
WHOLESALE = 'wholesale'
# A List of our Octopush Purposes we can use for verification
OCTOPUSH_PURPOSES = (
OctopushPurpose.ALERT,
OctopushPurpose.WHOLESALE,
)
class NotifyOctopush(NotifyBase):
"""
A wrapper for Octopush
"""
# The default descriptive name associated with the Notification
service_name = 'Octopush Notification Service'
# The services URL
service_url = 'https://octopush.com'
# The default secure protocol
secure_protocol = 'octopush'
# A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_octopush'
# Notification URLs
v1_notify_url = 'https://api.octopush.com/v1/public/sms-campaign/send'
# The maximum length of the body
body_maxlen = 1224
# The maximum amount of phone numbers that can reside within a single
# batch/frame transfer
default_batch_size = 500
# 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}://{api_login}/{api_key}/{targets}',
'{schema}://{sender}:{api_login}/{api_key}/{targets}',
)
# Define our template tokens
template_tokens = dict(NotifyBase.template_tokens, **{
'api_login': {
'name': _('API Login'),
'type': 'string',
'private': True,
'required': True,
},
'api_key': {
'name': _('API Key'),
'type': 'string',
'private': True,
'required': True,
},
'sender': {
'name': _('Sender'),
'type': 'string',
},
'target_phone_no': {
'name': _('Target Phone No'),
'type': 'string',
'map_to': 'targets',
'regex': (r'^[0-9\s)(+-]+$', 'i')
},
'targets': {
'name': _('Targets'),
'type': 'list:string',
},
})
# Define our template arguments
template_args = dict(NotifyBase.template_args, **{
'to': {
'alias_of': 'targets',
},
'login': {
'alias_of': 'api_login',
},
'key': {
'alias_of': 'api_key',
},
'sender': {
'alias_of': 'sender',
},
'batch': {
'name': _('Batch Mode'),
'type': 'bool',
'default': False,
},
'replies': {
'name': _('Accept Replies'),
'type': 'bool',
'default': False,
},
'purpose': {
'name': _('Purpose'),
'type': 'choice:string',
'values': OCTOPUSH_PURPOSES,
'default': OctopushPurpose.ALERT,
},
'type': {
'name': _('Type'),
'type': 'choice:string',
'values': OCTOPUSH_TYPES,
'default': OctopushType.PREMIUM,
'map_to': 'mtype',
},
})
def __init__(self, api_login, api_key, targets=None, batch=False,
sender=None, purpose=None, mtype=None, replies=False,
**kwargs):
"""
Initialize Notify Octopush Object
"""
super(NotifyOctopush, self).__init__(**kwargs)
# Store our API Login
self.api_login = validate_regex(api_login)
if not self.api_login or not is_email(self.api_login):
msg = 'An invalid Octopush API Login ({}) was specified.' \
.format(api_login)
self.logger.warning(msg)
raise TypeError(msg)
# Store our API Key
self.api_key = validate_regex(api_key)
if not self.api_key:
msg = 'An invalid Octopush API Key ' \
'({}) was specified.'.format(api_key)
self.logger.warning(msg)
raise TypeError(msg)
# Prepare Batch Mode Flag
self.batch = True if batch else False
# Prepare Replies Mode Flag
self.replies = True if replies else False
# The Type of the message
self.mtype = NotifyOctopush.template_args['type']['default'] \
if not mtype else \
next((
v for k, v in OCTOPUSH_TYPE_MAP.items()
if str(mtype).lower().startswith(k)), None)
if self.mtype is None:
# Invalid purpose specified
msg = 'The Octopush type specified ({}) is invalid.' \
.format(mtype)
self.logger.warning(msg)
raise TypeError(msg)
# Store our purpose
try:
self.purpose = \
NotifyOctopush.template_args['purpose']['default'] \
if purpose is None else purpose.lower()
if self.purpose not in OCTOPUSH_PURPOSES:
# allow the outer except to handle this common response
raise
except:
# Invalid purpose specified
msg = 'The Octopush purpose specified ({}) is invalid.' \
.format(purpose)
self.logger.warning(msg)
raise TypeError(msg)
self.sender = None
if sender:
self.sender = validate_regex(sender)
if not self.sender:
msg = 'An invalid Octopush sender ({}) was specified.' \
.format(sender)
self.logger.warning(msg)
raise TypeError(msg)
# Initialize numbers list
self.targets = list()
# Validate targets and drop bad ones:
for target in parse_phone_no(targets):
result = is_phone_no(target)
if result:
# store valid phone number in E.164 format
self.targets.append('+{}'.format(result['full']))
continue
self.logger.warning(
'Dropped invalid phone '
'(%s) specified.' % target,
)
return
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
"""
wrapper to send_notification since we can alert more then one channel
"""
if not self.targets:
# We have a bot token and no target(s) to message
self.logger.warning('No Octopush targets to notify.')
return False
# error tracking (used for function return)
has_error = False
# Send in batches if identified to do so
batch_size = 1 if not self.batch else self.default_batch_size
# Create a copy of our phone #'s to notify against
targets = list(self.targets)
# Prepare our headers
headers = {
'User-Agent': self.app_id,
'Accept': 'application/json',
'api-key': self.api_key,
'api-login': self.api_login,
'cache-control': 'no-cache',
}
# Prepare Octopush Message Payload
payload = {
# Recipients are populated prior to message xfer
"recipients": [],
"text": body,
"type": self.mtype,
"purpose": self.purpose,
"sender": self.app_id if not self.sender else self.sender,
"with_replies": self.replies,
}
for index in range(0, len(targets), batch_size):
# Prepare our batch
payload['recipients'] = \
[{'phone_number': phone_no} for phone_no
in self.targets[index:index + batch_size]]
# Always call throttle before any remote server i/o is made
self.throttle()
# Some Debug Logging
self.logger.debug('Octopush POST URL: {} (cert_verify={})'.format(
self.v1_notify_url, self.verify_certificate))
self.logger.debug('Octopush Payload: {}' .format(payload))
# For logging output of success and errors; we get a head count
# of our outbound details:
verbose_dest = ', '.join(
[x[1] for x in self.targets[index:index + batch_size]]) \
if len(self.targets[index:index + batch_size]) <= 3 \
else '{} recipients'.format(
len(self.targets[index:index + batch_size]))
try:
r = requests.post(
self.v1_notify_url,
data=payload,
headers=headers,
verify=self.verify_certificate,
timeout=self.request_timeout,
)
if r.status_code != requests.codes.ok:
# We had a problem
status_str = \
NotifyBase.http_response_code_lookup(
r.status_code)
self.logger.warning(
'Failed to send Octopush notification to {}: '
'{}{}error={}.'.format(
verbose_dest,
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
else:
self.logger.info(
'Sent Octopush notification to {}.'.format(
verbose_dest))
except requests.RequestException as e:
self.logger.warning(
'A Connection error occurred sending Octopush:%s ' % (
verbose_dest) + 'notification.'
)
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 = {
'batch': 'yes' if self.batch else 'no',
'replies': 'yes' if self.replies else 'no',
'type': self.mtype,
'purpose': self.purpose,
}
# Extend our parameters
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
return '{schema}://{sender}{api_login}/{api_key}/{targets}'\
'?{params}'.format(
schema=self.secure_protocol,
sender='{}:'.format(NotifyOctopush.quote(self.sender))
if self.sender else '',
api_login=self.pprint(self.api_login, privacy, safe='@'),
api_key=self.pprint(
self.api_key, privacy,
mode=PrivacyMode.Secret, safe=''),
targets='/'.join(
[NotifyOctopush.quote(x, safe='+') for x in self.targets]),
params=NotifyOctopush.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
tokens = NotifyOctopush.split_path(results['fullpath'])
if 'key' in results['qsd'] and len(results['qsd']['key']):
results['api_key'] = \
NotifyOctopush.unquote(results['qsd']['key'])
elif tokens:
# The first target is the api_key
results['api_key'] = tokens.pop(0)
# The remaining elements are the phone numbers we want to contact
results['targets'] = tokens
# 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'] += \
NotifyOctopush.parse_phone_no(results['qsd']['to'])
if 'login' in results['qsd'] and len(results['qsd']['login']):
results['api_login'] = \
NotifyOctopush.unquote(results['qsd']['login'])
elif results['user'] or results['password']:
# The Octopush API Login is extracted from the head of our URL
results['api_login'] = '{}@{}'.format(
NotifyOctopush.unquote(results['user'])
if not results['password']
else NotifyOctopush.unquote(results['password']),
NotifyOctopush.unquote(results['host']),
)
# Get Batch Mode Flag
results['batch'] = \
parse_bool(results['qsd'].get(
'batch', NotifyOctopush.template_args['batch']['default']))
# Get Replies Mode
results['replies'] = \
parse_bool(results['qsd'].get(
'replies', NotifyOctopush.template_args['replies']['default']))
if 'type' in results['qsd'] and len(results['qsd']['type']):
# Extract Type
results['mtype'] = \
NotifyOctopush.unquote(results['qsd']['type'])
if 'purpose' in results['qsd'] and len(results['qsd']['purpose']):
# Extract Purpose
results['purpose'] = \
NotifyOctopush.unquote(results['qsd']['purpose'])
if 'sender' in results['qsd'] and len(results['qsd']['sender']):
# Extract Sender
results['sender'] = \
NotifyOctopush.unquote(results['qsd']['sender'])
elif results['user'] and results['password']:
results['sender'] = \
NotifyOctopush.unquote(results['user'])
# Return our result set
return results

View File

@ -40,20 +40,21 @@ notification services that are out there. Apprise opens the door and makes
it easy to access:
Africas Talking, Apprise API, APRS, AWS SES, AWS SNS, Bark, BlueSky, Burst SMS,
BulkSMS, BulkVS, Chanify, Clickatell, ClickSend, DAPNET, DingTalk, Discord, E-Mail, Emby,
FCM, Feishu, Flock, Free Mobile, Google Chat, Gotify, Growl, Guilded, Home
Assistant, httpSMS, IFTTT, Join, Kavenegar, KODI, Kumulos, LaMetric, Lark, Line,
MacOSX, Mailgun, Mastodon, Mattermost, Matrix, MessageBird, Microsoft
Windows, Microsoft Teams, Misskey, MQTT, MSG91, MyAndroid, Nexmo, Nextcloud,
NextcloudTalk, Notica, Notifiarr, Notifico, ntfy, Office365, OneSignal,
Opsgenie, PagerDuty, PagerTree, ParsePlatform, Plivo, PopcornNotify, Prowl,
Pushalot, PushBullet, Pushjet, PushMe, Pushover, Pushplus, PushSafer, Pushy,
PushDeer, QQ Push, Revolt, Reddit, Resend, Rocket.Chat, RSyslog, SendGrid,
ServerChan, Seven, SFR, Signal, SimplePush, Sinch, Slack, SMPP, SMSEagle,
SMS Manager, SMTP2Go, SparkPost, Splunk, Spike, Spug Push, 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}
BulkSMS, BulkVS, Chanify, Clickatell, ClickSend, DAPNET, DingTalk, Discord,
E-Mail, Emby, FCM, Feishu, Flock, Free Mobile, Google Chat, Gotify, Growl,
Guilded, Home Assistant, httpSMS, IFTTT, Join, Kavenegar, KODI, Kumulos, LaMetric,
Lark, Line, MacOSX, Mailgun, Mastodon, Mattermost, Matrix, MessageBird,
Microsoft Windows, Microsoft Teams, Misskey, MQTT, MSG91, MyAndroid, Nexmo,
Nextcloud, NextcloudTalk, Notica, Notifiarr, Notifico, ntfy, Office365,
OneSignal, Octopush, Opsgenie, PagerDuty, PagerTree, ParsePlatform, Plivo,
PopcornNotify, Prowl, Pushalot, PushBullet, Pushjet, PushMe, Pushover,
Pushplus, PushSafer, Pushy, PushDeer, QQ Push, Revolt, Reddit, Resend,
Rocket.Chat, RSyslog, SendGrid, ServerChan, Seven, SFR, Signal, SimplePush,
Sinch, Slack, SMPP, SMSEagle, SMS Manager, SMTP2Go, SparkPost, Splunk, Spike,
Spug Push, 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}
Name: python-%{pypi_name}
Version: 1.9.3

View File

@ -0,0 +1,127 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2022 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files(the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions :
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
from apprise import plugins
from helpers import AppriseURLTester
# Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL)
# Our Testing URLs
apprise_url_tests = (
('octopush://', {
# No API Login or API Key specified
'instance': TypeError,
}),
('octopush://:@/', {
# invalid API Login
'instance': TypeError,
}),
('octopush://user@myaccount.com', {
# Valid API Login, but no API Key provided
'instance': TypeError,
}),
('octopush://_/apikey?login=invalid', {
# Invalid login
'instance': TypeError,
}),
('octopush://user@myaccount.com/%20', {
# Valid API Login, but invalid API Key provided
'instance': TypeError,
}),
('octopush://%20:user@myaccount.com/apikey', {
# All valid entries, but invalid sender
'instance': TypeError,
}),
('octopush://user@myaccount.com/apikey', {
# All valid entries, but no target phone numbers defined
'instance': plugins.NotifyOctopush,
'response': False,
}),
('octopush://user@myaccount.com/apikey/+0987654321', {
# A valid url
'instance': plugins.NotifyOctopush,
# Our expected url(privacy=True) startswith() response:
'privacy_url': 'octopush://u...m/****/+0987654321',
}),
('octopush://sender:user@myaccount.com/apikey/+1111111111', {
# A valid url with sender
'instance': plugins.NotifyOctopush,
# Our expected url(privacy=True) startswith() response:
'privacy_url': 'octopush://sender:u...m/****/+1111111111',
}),
('octopush://?login=user@myaccount.com&key=key&to=9999999999'
'&purpose=wholesale', {
# Testing valid purpose change
'instance': plugins.NotifyOctopush}),
('octopush://?login=user@myaccount.com&key=key&to=9999999999'
'&purpose=invalid', {
# Testing invalid purpose change
'instance': TypeError}),
('octopush://?login=user@myaccount.com&key=key&to=9999999999'
'&type=premium', {
# Testing valid type change
'instance': plugins.NotifyOctopush}),
('octopush://?login=user@myaccount.com&key=key&to=9999999999'
'&type=invalid', {
# Testing invalid type change
'instance': TypeError}),
('octopush://user@myaccount.com/apikey/+3333333333?replies=yes', {
# Test replies
'instance': plugins.NotifyOctopush,
}),
('octopush://sender:user@myaccount.com/apikey/{}/{}/{}/?batch=yes'.format(
'1' * 10, '2' * 3, '3' * 11), {
# batch mode, 2 valid targets (1 is invalid and skipped)
'instance': plugins.NotifyOctopush}),
('octopush://_?key=abc123&login=user@myaccount&sender=abc&to=2222222222', {
# use get args to acomplish the same thing
'instance': plugins.NotifyOctopush,
# Our expected url(privacy=True) startswith() response:
'privacy_url': 'octopush://abc:u...t/****/+2222222222',
}),
('octopush://user@myaccount.com/apikey/1234567890', {
'instance': plugins.NotifyOctopush,
# throw a bizzare code forcing us to fail to look it up
'response': False,
'requests_response_code': 999,
}),
('octopush://user@myaccount.com/apikey/1234567890', {
'instance': plugins.NotifyOctopush,
# Throws a series of connection and transfer exceptions when this flag
# is set and tests that we gracfully handle them
'test_requests_exceptions': True,
}),
)
def test_plugin_octopush_urls():
"""
NotifyOctopush() Apprise URLs
"""
# Run our general tests
AppriseURLTester(tests=apprise_url_tests).run_all()