mirror of https://github.com/caronc/apprise
Added Zulip Chat Support (#129)
parent
78d5465ff6
commit
f102b52248
|
@ -63,6 +63,7 @@ The table below identifies the services this tool supports and some example serv
|
|||
| [XMPP](https://github.com/caronc/apprise/wiki/Notify_xmpp) | xmpp:// or xmpps:// | (TCP) 5222 or 5223 | xmpp://password@hostname<br />xmpp://user:password@hostname<br />xmpps://user:password@hostname:port?jid=user@hostname/resource<br/>xmpps://password@hostname/target@myhost, target2@myhost/resource
|
||||
| [Windows Notification](https://github.com/caronc/apprise/wiki/Notify_windows) | windows:// | n/a | windows://
|
||||
| [Webex Teams (Cisco)](https://github.com/caronc/apprise/wiki/Notify_wxteams) | wxteams:// | (TCP) 443 | wxteams://Token
|
||||
| [Zulip Chat](https://github.com/caronc/apprise/wiki/Notify_zulip) | zulip:// | (TCP) 443 | zulip://botname@Organization/Token<br />zulip://botname@Organization/Token/Channel<br />zulip://botname@Organization/Token/Email
|
||||
|
||||
|
||||
### SMS Notification Support
|
||||
|
|
|
@ -0,0 +1,398 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2019 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.
|
||||
|
||||
# To use this plugin, you must have a ZulipChat bot defined; See here:
|
||||
# https://zulipchat.com/help/add-a-bot-or-integration
|
||||
#
|
||||
# At the time of writing this plugin the instructions were:
|
||||
# 1. From your desktop, click on the gear icon in the upper right corner.
|
||||
# 2. Select Settings.
|
||||
# 3. On the left, click Your bots.
|
||||
# 4. Click Add a new bot.
|
||||
# 5. Fill out the fields, and click Create bot.
|
||||
|
||||
# If you know your organization {ID} (as it's part of the zulipchat.com url
|
||||
# after you signup, then you can also access your bot information by visting:
|
||||
# https://ID.zulipchat.com/#settings/your-bots
|
||||
|
||||
# For example, I create an organization called apprise. Thus my URL would be
|
||||
# https://apprise.zulipchat.com/#settings/your-bots
|
||||
|
||||
# When you're done and have a bot, it's important to remember the username
|
||||
# you provided the bot and the API key generated.
|
||||
#
|
||||
# If your {user} was : goober-bot@apprise.zulipchat.com
|
||||
# and your {apikey} was: lqn6mpwpam6VZzbCW0o7olmk3hwbQSK
|
||||
#
|
||||
# Then the following URLs would be accepted by Apprise:
|
||||
# - zulip://goober-bot@apprise.zulipchat.com/lqn6mpwpam6VZzbCW0o7olmk3hwbQSK
|
||||
# - zulip://goober-bot@apprise/lqn6mpwpam6VZzbCW0o7olmk3hwbQSK
|
||||
# - zulip://goober@apprise/lqn6mpwpam6VZzbCW0o7olmk3hwbQSK
|
||||
# - zulip://goober@apprise.zulipchat.com/lqn6mpwpam6VZzbCW0o7olmk3hwbQSK
|
||||
|
||||
# The API reference used to build this plugin was documented here:
|
||||
# https://zulipchat.com/api/send-message
|
||||
#
|
||||
import re
|
||||
import requests
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..common import NotifyType
|
||||
from ..utils import parse_list
|
||||
from ..utils import GET_EMAIL_RE
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
|
||||
# A Valid Bot Name
|
||||
VALIDATE_BOTNAME = re.compile(r'(?P<name>[A-Z0-9_]{1,32})(-bot)?', re.I)
|
||||
|
||||
# A Valid Bot Token is 32 characters of alpha/numeric
|
||||
VALIDATE_TOKEN = re.compile(r'[A-Z0-9]{32}', re.I)
|
||||
|
||||
# Organization required as part of the API request
|
||||
VALIDATE_ORG = re.compile(
|
||||
r'(?P<org>[A-Z0-9_-]{1,32})(\.(?P<hostname>[^\s]+))?', re.I)
|
||||
|
||||
# Extend HTTP Error Messages
|
||||
ZULIP_HTTP_ERROR_MAP = {
|
||||
401: 'Unauthorized - Invalid Token.',
|
||||
}
|
||||
|
||||
# Used to break path apart into list of channels
|
||||
TARGET_LIST_DELIM = re.compile(r'[ \t\r\n,#\\/]+')
|
||||
|
||||
# Used to detect a channel
|
||||
IS_VALID_TARGET_RE = re.compile(
|
||||
r'#?(?P<channel>[A-Z0-9_]{1,32})', re.I)
|
||||
|
||||
|
||||
class NotifyZulip(NotifyBase):
|
||||
"""
|
||||
A wrapper for Zulip Notifications
|
||||
"""
|
||||
|
||||
# The default descriptive name associated with the Notification
|
||||
service_name = 'Zulip'
|
||||
|
||||
# The services URL
|
||||
service_url = 'https://zulipchat.com/'
|
||||
|
||||
# The default secure protocol
|
||||
secure_protocol = 'zulip'
|
||||
|
||||
# A URL that takes you to the setup/help of the specific protocol
|
||||
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_zulip'
|
||||
|
||||
# Zulip uses the http protocol with JSON requests
|
||||
notify_url = 'https://{org}.{hostname}/api/v1/messages'
|
||||
|
||||
# The maximum allowable characters allowed in the title per message
|
||||
title_maxlen = 60
|
||||
|
||||
# The maximum allowable characters allowed in the body per message
|
||||
body_maxlen = 10000
|
||||
|
||||
# Define object templates
|
||||
templates = (
|
||||
'{schema}://{botname}@{organization}/{token}',
|
||||
'{schema}://{botname}@{organization}/{token}/{targets}',
|
||||
)
|
||||
|
||||
# Define our template tokens
|
||||
template_tokens = dict(NotifyBase.template_tokens, **{
|
||||
'botname': {
|
||||
'name': _('Bot Name'),
|
||||
'type': 'string',
|
||||
},
|
||||
'organization': {
|
||||
'name': _('Organization'),
|
||||
'type': 'string',
|
||||
'required': True,
|
||||
},
|
||||
'token': {
|
||||
'name': _('Token'),
|
||||
'type': 'string',
|
||||
'required': True,
|
||||
'private': True,
|
||||
'regex': (r'[A-Z0-9]{32}', 'i'),
|
||||
},
|
||||
'target_user': {
|
||||
'name': _('Target User'),
|
||||
'type': 'string',
|
||||
'map_to': 'targets',
|
||||
},
|
||||
'target_channel': {
|
||||
'name': _('Target Channel'),
|
||||
'type': 'string',
|
||||
'map_to': 'targets',
|
||||
},
|
||||
'targets': {
|
||||
'name': _('Targets'),
|
||||
'type': 'list:string',
|
||||
},
|
||||
})
|
||||
|
||||
# Define our template arguments
|
||||
template_args = dict(NotifyBase.template_args, **{
|
||||
'to': {
|
||||
'alias_of': 'targets',
|
||||
},
|
||||
})
|
||||
|
||||
# The default hostname to append to a defined organization
|
||||
# if one isn't defined in the apprise url
|
||||
default_hostname = 'zulipchat.com'
|
||||
|
||||
# The default channel to notify if no targets are specified
|
||||
default_notification_channel = 'general'
|
||||
|
||||
def __init__(self, botname, organization, token, targets=None, **kwargs):
|
||||
"""
|
||||
Initialize Zulip Object
|
||||
"""
|
||||
super(NotifyZulip, self).__init__(**kwargs)
|
||||
|
||||
# our default hostname
|
||||
self.hostname = self.default_hostname
|
||||
|
||||
try:
|
||||
match = VALIDATE_BOTNAME.match(botname.strip())
|
||||
if not match:
|
||||
# let outer exception handle this
|
||||
raise TypeError
|
||||
|
||||
# The botname
|
||||
self.botname = match.group('name')
|
||||
|
||||
except (TypeError, AttributeError):
|
||||
msg = 'The Zulip botname specified ({}) is invalid.'\
|
||||
.format(botname)
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
try:
|
||||
match = VALIDATE_ORG.match(organization.strip())
|
||||
if not match:
|
||||
# let outer exception handle this
|
||||
raise TypeError
|
||||
|
||||
# The organization
|
||||
self.organization = match.group('org')
|
||||
if match.group('hostname'):
|
||||
self.hostname = match.group('hostname')
|
||||
|
||||
except (TypeError, AttributeError):
|
||||
msg = 'The Zulip organization specified ({}) is invalid.'\
|
||||
.format(organization)
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
try:
|
||||
if not VALIDATE_TOKEN.match(token.strip()):
|
||||
# let outer exception handle this
|
||||
raise TypeError
|
||||
|
||||
except (TypeError, AttributeError):
|
||||
msg = 'The Zulip token specified ({}) is invalid.'\
|
||||
.format(token)
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
# The token associated with the account
|
||||
self.token = token.strip()
|
||||
|
||||
self.targets = parse_list(targets)
|
||||
if len(self.targets) == 0:
|
||||
# No channels identified, use default
|
||||
self.targets.append(self.default_notification_channel)
|
||||
|
||||
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
||||
"""
|
||||
Perform Zulip Notification
|
||||
"""
|
||||
|
||||
headers = {
|
||||
'User-Agent': self.app_id,
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
|
||||
}
|
||||
|
||||
# error tracking (used for function return)
|
||||
has_error = False
|
||||
|
||||
# Prepare our notification URL
|
||||
url = self.notify_url.format(
|
||||
org=self.organization,
|
||||
hostname=self.hostname,
|
||||
)
|
||||
|
||||
# prepare JSON Object
|
||||
payload = {
|
||||
'subject': title,
|
||||
'content': body,
|
||||
}
|
||||
|
||||
# Determine Authentication
|
||||
auth = (
|
||||
'{botname}-bot@{org}.{hostname}'.format(
|
||||
botname=self.botname,
|
||||
org=self.organization,
|
||||
hostname=self.hostname,
|
||||
),
|
||||
self.token,
|
||||
)
|
||||
|
||||
# Create a copy of the target list
|
||||
targets = list(self.targets)
|
||||
while len(targets):
|
||||
target = targets.pop(0)
|
||||
if GET_EMAIL_RE.match(target):
|
||||
# Send a private message
|
||||
payload['type'] = 'private'
|
||||
else:
|
||||
# Send a stream message
|
||||
payload['type'] = 'stream'
|
||||
|
||||
# Set our target
|
||||
payload['to'] = target
|
||||
|
||||
self.logger.debug('Zulip POST URL: %s (cert_verify=%r)' % (
|
||||
url, self.verify_certificate,
|
||||
))
|
||||
self.logger.debug('Zulip Payload: %s' % str(payload))
|
||||
|
||||
# Always call throttle before any remote server i/o is made
|
||||
self.throttle()
|
||||
try:
|
||||
r = requests.post(
|
||||
url,
|
||||
data=payload,
|
||||
headers=headers,
|
||||
auth=auth,
|
||||
verify=self.verify_certificate,
|
||||
)
|
||||
if r.status_code != requests.codes.ok:
|
||||
# We had a problem
|
||||
status_str = \
|
||||
NotifyZulip.http_response_code_lookup(
|
||||
r.status_code, ZULIP_HTTP_ERROR_MAP)
|
||||
|
||||
self.logger.warning(
|
||||
'Failed to send Zulip 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
|
||||
|
||||
else:
|
||||
self.logger.info(
|
||||
'Sent Zulip notification to {}.'.format(target))
|
||||
|
||||
except requests.RequestException as e:
|
||||
self.logger.warning(
|
||||
'A Connection error occured sending Zulip '
|
||||
'notification to {}.'.format(target))
|
||||
self.logger.debug('Socket Exception: %s' % str(e))
|
||||
|
||||
# Mark our failure
|
||||
has_error = True
|
||||
continue
|
||||
|
||||
return not has_error
|
||||
|
||||
def url(self):
|
||||
"""
|
||||
Returns the URL built dynamically based on specified arguments.
|
||||
"""
|
||||
|
||||
# Define any arguments set
|
||||
args = {
|
||||
'format': self.notify_format,
|
||||
'overflow': self.overflow_mode,
|
||||
'verify': 'yes' if self.verify_certificate else 'no',
|
||||
}
|
||||
|
||||
# simplify our organization in our URL if we can
|
||||
organization = '{}{}'.format(
|
||||
self.organization,
|
||||
'.{}'.format(self.hostname)
|
||||
if self.hostname != self.default_hostname else '')
|
||||
|
||||
return '{schema}://{botname}@{org}/{token}/' \
|
||||
'{targets}?{args}'.format(
|
||||
schema=self.secure_protocol,
|
||||
botname=self.botname,
|
||||
org=NotifyZulip.quote(organization, safe=''),
|
||||
token=NotifyZulip.quote(self.token, safe=''),
|
||||
targets='/'.join(
|
||||
[NotifyZulip.quote(x, safe='') for x in self.targets]),
|
||||
args=NotifyZulip.urlencode(args),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def parse_url(url):
|
||||
"""
|
||||
Parses the URL and returns enough arguments that can allow
|
||||
us to substantiate this object.
|
||||
|
||||
"""
|
||||
results = NotifyBase.parse_url(url)
|
||||
|
||||
if not results:
|
||||
# We're done early as we couldn't load the results
|
||||
return results
|
||||
|
||||
# The botname
|
||||
results['botname'] = NotifyZulip.unquote(results['user'])
|
||||
|
||||
# The first token is stored in the hostname
|
||||
results['organization'] = NotifyZulip.unquote(results['host'])
|
||||
|
||||
# Now fetch the remaining tokens
|
||||
try:
|
||||
results['token'] = \
|
||||
NotifyZulip.split_path(results['fullpath'])[0]
|
||||
|
||||
except IndexError:
|
||||
# no token
|
||||
results['token'] = None
|
||||
|
||||
# Get unquoted entries
|
||||
results['targets'] = NotifyZulip.split_path(results['fullpath'])[1:]
|
||||
|
||||
# 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'] += [x for x in filter(
|
||||
bool, TARGET_LIST_DELIM.split(
|
||||
NotifyZulip.unquote(results['qsd']['to'])))]
|
||||
|
||||
return results
|
|
@ -2338,6 +2338,76 @@ TEST_URLS = (
|
|||
('xml://localhost:8080/path?-HeaderKey=HeaderValue', {
|
||||
'instance': plugins.NotifyXML,
|
||||
}),
|
||||
|
||||
##################################
|
||||
# NotifyZulip
|
||||
##################################
|
||||
('zulip://', {
|
||||
'instance': None,
|
||||
}),
|
||||
('zulip://:@/', {
|
||||
'instance': None,
|
||||
}),
|
||||
('zulip://apprise', {
|
||||
# Just org provided (no token or botname)
|
||||
'instance': TypeError,
|
||||
}),
|
||||
('zulip://botname@apprise', {
|
||||
# Just org and botname provided (no token)
|
||||
'instance': TypeError,
|
||||
}),
|
||||
# invalid token
|
||||
('zulip://botname@apprise/{}'.format('a' * 24), {
|
||||
'instance': TypeError,
|
||||
}),
|
||||
# invalid botname
|
||||
('zulip://....@apprise/{}'.format('a' * 32), {
|
||||
'instance': TypeError,
|
||||
}),
|
||||
# Valid everything - no target so default is used
|
||||
('zulip://botname@apprise/{}'.format('a' * 32), {
|
||||
'instance': plugins.NotifyZulip,
|
||||
}),
|
||||
# Valid everything - organization as hostname
|
||||
('zulip://botname@apprise.zulipchat.com/{}'.format('a' * 32), {
|
||||
'instance': plugins.NotifyZulip,
|
||||
}),
|
||||
# Valid everything - 2 channels specified
|
||||
('zulip://botname@apprise/{}/channel1/channel2'.format('a' * 32), {
|
||||
'instance': plugins.NotifyZulip,
|
||||
}),
|
||||
# Valid everything - 2 channels specified (using to=)
|
||||
('zulip://botname@apprise/{}/?to=channel1/channel2'.format('a' * 32), {
|
||||
'instance': plugins.NotifyZulip,
|
||||
}),
|
||||
# Valid everything - 2 emails specified
|
||||
('zulip://botname@apprise/{}/user@example.com/user2@example.com'.format(
|
||||
'a' * 32), {
|
||||
'instance': plugins.NotifyZulip,
|
||||
}),
|
||||
('zulip://botname@apprise/{}'.format('a' * 32), {
|
||||
'instance': plugins.NotifyZulip,
|
||||
# don't include an image by default
|
||||
'include_image': False,
|
||||
}),
|
||||
('zulip://botname@apprise/{}'.format('a' * 32), {
|
||||
'instance': plugins.NotifyZulip,
|
||||
# force a failure
|
||||
'response': False,
|
||||
'requests_response_code': requests.codes.internal_server_error,
|
||||
}),
|
||||
('zulip://botname@apprise/{}'.format('a' * 32), {
|
||||
'instance': plugins.NotifyZulip,
|
||||
# throw a bizzare code forcing us to fail to look it up
|
||||
'response': False,
|
||||
'requests_response_code': 999,
|
||||
}),
|
||||
('zulip://botname@apprise/{}'.format('a' * 32), {
|
||||
'instance': plugins.NotifyZulip,
|
||||
# Throws a series of connection and transfer exceptions when this flag
|
||||
# is set and tests that we gracfully handle them
|
||||
'test_requests_exceptions': True,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
|
@ -3399,6 +3469,28 @@ def test_notify_ryver_plugin():
|
|||
assert True
|
||||
|
||||
|
||||
def test_notify_zulip_plugin():
|
||||
"""
|
||||
API: NotifyZulip() Extra Checks
|
||||
|
||||
"""
|
||||
# Disable Throttling to speed testing
|
||||
plugins.NotifyBase.request_rate_per_sec = 0
|
||||
|
||||
# must be 32 characters long
|
||||
token = 'a' * 32
|
||||
|
||||
# Invalid organization
|
||||
try:
|
||||
plugins.NotifyZulip(
|
||||
botname='test', organization='#', token=token)
|
||||
assert False
|
||||
|
||||
except TypeError:
|
||||
# we'll thrown because an empty list of channels was provided
|
||||
assert True
|
||||
|
||||
|
||||
@mock.patch('requests.get')
|
||||
@mock.patch('requests.post')
|
||||
def test_notify_slack_plugin(mock_post, mock_get):
|
||||
|
|
Loading…
Reference in New Issue