mirror of https://github.com/caronc/apprise
Signal API Attachment and Group Support Added (#580)
parent
ca0c8460f1
commit
9c145a842e
|
@ -1,6 +1,6 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
#
|
#
|
||||||
# Copyright (C) 2020 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2022 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
#
|
#
|
||||||
# This code is licensed under the MIT License.
|
# This code is licensed under the MIT License.
|
||||||
|
@ -23,8 +23,10 @@
|
||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
|
|
||||||
|
import re
|
||||||
import requests
|
import requests
|
||||||
from json import dumps
|
from json import dumps
|
||||||
|
import base64
|
||||||
|
|
||||||
from .NotifyBase import NotifyBase
|
from .NotifyBase import NotifyBase
|
||||||
from ..common import NotifyType
|
from ..common import NotifyType
|
||||||
|
@ -35,6 +37,10 @@ from ..URLBase import PrivacyMode
|
||||||
from ..AppriseLocale import gettext_lazy as _
|
from ..AppriseLocale import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
GROUP_REGEX = re.compile(
|
||||||
|
r'^\s*((\@|\%40)?(group\.)|\@|\%40)(?P<group>[a-z0-9_-]+)', re.I)
|
||||||
|
|
||||||
|
|
||||||
class NotifySignalAPI(NotifyBase):
|
class NotifySignalAPI(NotifyBase):
|
||||||
"""
|
"""
|
||||||
A wrapper for SignalAPI Notifications
|
A wrapper for SignalAPI Notifications
|
||||||
|
@ -113,6 +119,13 @@ class NotifySignalAPI(NotifyBase):
|
||||||
'regex': (r'^[0-9\s)(+-]+$', 'i'),
|
'regex': (r'^[0-9\s)(+-]+$', 'i'),
|
||||||
'map_to': 'targets',
|
'map_to': 'targets',
|
||||||
},
|
},
|
||||||
|
'target_channel': {
|
||||||
|
'name': _('Target Group ID'),
|
||||||
|
'type': 'string',
|
||||||
|
'prefix': '@',
|
||||||
|
'regex': (r'^[a-z0-9_-]+$', 'i'),
|
||||||
|
'map_to': 'targets',
|
||||||
|
},
|
||||||
'targets': {
|
'targets': {
|
||||||
'name': _('Targets'),
|
'name': _('Targets'),
|
||||||
'type': 'list:string',
|
'type': 'list:string',
|
||||||
|
@ -173,23 +186,33 @@ class NotifySignalAPI(NotifyBase):
|
||||||
for target in parse_phone_no(targets):
|
for target in parse_phone_no(targets):
|
||||||
# Validate targets and drop bad ones:
|
# Validate targets and drop bad ones:
|
||||||
result = is_phone_no(target)
|
result = is_phone_no(target)
|
||||||
if not result:
|
if result:
|
||||||
self.logger.warning(
|
# store valid phone number
|
||||||
'Dropped invalid phone # '
|
self.targets.append('+{}'.format(result['full']))
|
||||||
'({}) specified.'.format(target),
|
|
||||||
)
|
|
||||||
self.invalid_targets.append(target)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# store valid phone number
|
result = GROUP_REGEX.match(target)
|
||||||
self.targets.append('+{}'.format(result['full']))
|
if result:
|
||||||
|
# Just store group information
|
||||||
|
self.targets.append(
|
||||||
|
'group.{}'.format(result.group('group')))
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.logger.warning(
|
||||||
|
'Dropped invalid phone/group '
|
||||||
|
'({}) specified.'.format(target),
|
||||||
|
)
|
||||||
|
self.invalid_targets.append(target)
|
||||||
|
continue
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Send a message to ourselves
|
# Send a message to ourselves
|
||||||
self.targets.append(self.source)
|
self.targets.append(self.source)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
|
||||||
|
**kwargs):
|
||||||
"""
|
"""
|
||||||
Perform Signal API Notification
|
Perform Signal API Notification
|
||||||
"""
|
"""
|
||||||
|
@ -203,12 +226,50 @@ class NotifySignalAPI(NotifyBase):
|
||||||
# error tracking (used for function return)
|
# error tracking (used for function return)
|
||||||
has_error = False
|
has_error = False
|
||||||
|
|
||||||
|
attachments = []
|
||||||
|
if attach:
|
||||||
|
for attachment in attach:
|
||||||
|
# Perform some simple error checking
|
||||||
|
if not attachment:
|
||||||
|
# We could not access the attachment
|
||||||
|
self.logger.error(
|
||||||
|
'Could not access attachment {}.'.format(
|
||||||
|
attachment.url(privacy=True)))
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(attachment.path, 'rb') as f:
|
||||||
|
# Prepare our Attachment in Base64
|
||||||
|
attachments.append(
|
||||||
|
base64.b64encode(f.read()).decode('utf-8'))
|
||||||
|
|
||||||
|
except (OSError, IOError) as e:
|
||||||
|
self.logger.warning(
|
||||||
|
'An I/O error occurred while reading {}.'.format(
|
||||||
|
attachment.name if attachment else 'attachment'))
|
||||||
|
self.logger.debug('I/O Exception: %s' % str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
# Prepare our headers
|
# Prepare our headers
|
||||||
headers = {
|
headers = {
|
||||||
'User-Agent': self.app_id,
|
'User-Agent': self.app_id,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Format defined here:
|
||||||
|
# https://bbernhard.github.io/signal-cli-rest-api\
|
||||||
|
# /#/Messages/post_v2_send
|
||||||
|
# Example:
|
||||||
|
# {
|
||||||
|
# "base64_attachments": [
|
||||||
|
# "string"
|
||||||
|
# ],
|
||||||
|
# "message": "string",
|
||||||
|
# "number": "string",
|
||||||
|
# "recipients": [
|
||||||
|
# "string"
|
||||||
|
# ]
|
||||||
|
# }
|
||||||
# Prepare our payload
|
# Prepare our payload
|
||||||
payload = {
|
payload = {
|
||||||
'message': "{}{}".format(
|
'message': "{}{}".format(
|
||||||
|
@ -218,6 +279,10 @@ class NotifySignalAPI(NotifyBase):
|
||||||
"recipients": []
|
"recipients": []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if attachments:
|
||||||
|
# Store our attachments
|
||||||
|
payload['base64_attachments'] = attachments
|
||||||
|
|
||||||
# Determine Authentication
|
# Determine Authentication
|
||||||
auth = None
|
auth = None
|
||||||
if self.user:
|
if self.user:
|
||||||
|
@ -339,7 +404,11 @@ class NotifySignalAPI(NotifyBase):
|
||||||
targets = self.invalid_targets
|
targets = self.invalid_targets
|
||||||
|
|
||||||
else:
|
else:
|
||||||
targets = list(self.targets)
|
# append @ to non-phone number entries as they are groups
|
||||||
|
# Remove group. prefix as well
|
||||||
|
targets = \
|
||||||
|
['@{}'.format(x[6:]) if x[0] != '+'
|
||||||
|
else x for x in self.targets]
|
||||||
|
|
||||||
return '{schema}://{auth}{hostname}{port}/{src}/{dst}?{params}'.format(
|
return '{schema}://{auth}{hostname}{port}/{src}/{dst}?{params}'.format(
|
||||||
schema=self.secure_protocol if self.secure else self.protocol,
|
schema=self.secure_protocol if self.secure else self.protocol,
|
||||||
|
@ -350,7 +419,7 @@ class NotifySignalAPI(NotifyBase):
|
||||||
else ':{}'.format(self.port),
|
else ':{}'.format(self.port),
|
||||||
src=self.source,
|
src=self.source,
|
||||||
dst='/'.join(
|
dst='/'.join(
|
||||||
[NotifySignalAPI.quote(x, safe='') for x in targets]),
|
[NotifySignalAPI.quote(x, safe='@+') for x in targets]),
|
||||||
params=NotifySignalAPI.urlencode(params),
|
params=NotifySignalAPI.urlencode(params),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -52,7 +52,7 @@ from ..AppriseLocale import gettext_lazy as _
|
||||||
# specified. If not, we use the user of the person sending the notification
|
# specified. If not, we use the user of the person sending the notification
|
||||||
# Finally the channel identifier is detected
|
# Finally the channel identifier is detected
|
||||||
CHANNEL_REGEX = re.compile(
|
CHANNEL_REGEX = re.compile(
|
||||||
r'^\s*(#|%23)?((@|%40)?(?P<user>[a-z0-9_]+)([/\\]|%2F))?'
|
r'^\s*(\#|\%23)?((\@|\%40)?(?P<user>[a-z0-9_]+)([/\\]|\%2F))?'
|
||||||
r'(?P<channel>[a-z0-9_-]+)\s*$', re.I)
|
r'(?P<channel>[a-z0-9_-]+)\s*$', re.I)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -284,8 +284,7 @@ class NotifyXML(NotifyBase):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(attachment.path, 'rb') as f:
|
with open(attachment.path, 'rb') as f:
|
||||||
# Output must be in a DataURL format (that's what
|
# Prepare our Attachment in Base64
|
||||||
# PushSafer calls it):
|
|
||||||
entry = \
|
entry = \
|
||||||
'<Attachment filename="{}" mimetype="{}">'.format(
|
'<Attachment filename="{}" mimetype="{}">'.format(
|
||||||
NotifyXML.escape_html(
|
NotifyXML.escape_html(
|
||||||
|
|
|
@ -22,6 +22,8 @@
|
||||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
# 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
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
from json import loads
|
from json import loads
|
||||||
import mock
|
import mock
|
||||||
import pytest
|
import pytest
|
||||||
|
@ -29,11 +31,16 @@ import requests
|
||||||
from apprise import plugins
|
from apprise import plugins
|
||||||
from apprise import Apprise
|
from apprise import Apprise
|
||||||
from helpers import AppriseURLTester
|
from helpers import AppriseURLTester
|
||||||
|
from apprise import AppriseAttachment
|
||||||
|
from apprise import NotifyType
|
||||||
|
|
||||||
# Disable logging for a cleaner testing output
|
# Disable logging for a cleaner testing output
|
||||||
import logging
|
import logging
|
||||||
logging.disable(logging.CRITICAL)
|
logging.disable(logging.CRITICAL)
|
||||||
|
|
||||||
|
# Attachment Directory
|
||||||
|
TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), 'var')
|
||||||
|
|
||||||
# Our Testing URLs
|
# Our Testing URLs
|
||||||
apprise_url_tests = (
|
apprise_url_tests = (
|
||||||
('signal://', {
|
('signal://', {
|
||||||
|
@ -70,6 +77,18 @@ apprise_url_tests = (
|
||||||
'instance': plugins.NotifySignalAPI,
|
'instance': plugins.NotifySignalAPI,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
('signal://localhost:8082/+{}/@group.abcd/'.format('2' * 11), {
|
||||||
|
# a valid group
|
||||||
|
'instance': plugins.NotifySignalAPI,
|
||||||
|
# Our expected url(privacy=True) startswith() response:
|
||||||
|
'privacy_url': 'signal://localhost:8082/+{}/@abcd'.format('2' * 11),
|
||||||
|
}),
|
||||||
|
('signal://localhost:8080/+{}/group.abcd/'.format('1' * 11), {
|
||||||
|
# another valid group (without @ symbol)
|
||||||
|
'instance': plugins.NotifySignalAPI,
|
||||||
|
# Our expected url(privacy=True) startswith() response:
|
||||||
|
'privacy_url': 'signal://localhost:8080/+{}/@abcd'.format('1' * 11),
|
||||||
|
}),
|
||||||
('signal://localhost:8080/?from={}&to={},{}'.format(
|
('signal://localhost:8080/?from={}&to={},{}'.format(
|
||||||
'1' * 11, '2' * 11, '3' * 11), {
|
'1' * 11, '2' * 11, '3' * 11), {
|
||||||
# use get args to acomplish the same thing
|
# use get args to acomplish the same thing
|
||||||
|
@ -203,10 +222,13 @@ def test_plugin_signal_test_based_on_feedback(mock_post):
|
||||||
title = "My Title"
|
title = "My Title"
|
||||||
|
|
||||||
aobj = Apprise()
|
aobj = Apprise()
|
||||||
aobj.add('signal://10.0.0.112:8080/+12512222222/+12513333333')
|
aobj.add(
|
||||||
|
'signal://10.0.0.112:8080/+12512222222/+12513333333/'
|
||||||
|
'12514444444?batch=yes')
|
||||||
|
|
||||||
assert aobj.notify(title=title, body=body)
|
assert aobj.notify(title=title, body=body)
|
||||||
|
|
||||||
|
# If a batch, there is only 1 post
|
||||||
assert mock_post.call_count == 1
|
assert mock_post.call_count == 1
|
||||||
|
|
||||||
details = mock_post.call_args_list[0]
|
details = mock_post.call_args_list[0]
|
||||||
|
@ -214,4 +236,139 @@ def test_plugin_signal_test_based_on_feedback(mock_post):
|
||||||
payload = loads(details[1]['data'])
|
payload = loads(details[1]['data'])
|
||||||
assert payload['message'] == 'My Title\r\ntest body'
|
assert payload['message'] == 'My Title\r\ntest body'
|
||||||
assert payload['number'] == "+12512222222"
|
assert payload['number'] == "+12512222222"
|
||||||
assert payload['recipients'] == ["+12513333333"]
|
assert len(payload['recipients']) == 2
|
||||||
|
assert "+12513333333" in payload['recipients']
|
||||||
|
# The + is appended
|
||||||
|
assert "+12514444444" in payload['recipients']
|
||||||
|
|
||||||
|
# Reset our test and turn batch mode off
|
||||||
|
mock_post.reset_mock()
|
||||||
|
|
||||||
|
aobj = Apprise()
|
||||||
|
aobj.add(
|
||||||
|
'signal://10.0.0.112:8080/+12512222222/+12513333333/'
|
||||||
|
'12514444444?batch=no')
|
||||||
|
|
||||||
|
assert aobj.notify(title=title, body=body)
|
||||||
|
|
||||||
|
# If a batch, there is only 1 post
|
||||||
|
assert mock_post.call_count == 2
|
||||||
|
|
||||||
|
details = mock_post.call_args_list[0]
|
||||||
|
assert details[0][0] == 'http://10.0.0.112:8080/v2/send'
|
||||||
|
payload = loads(details[1]['data'])
|
||||||
|
assert payload['message'] == 'My Title\r\ntest body'
|
||||||
|
assert payload['number'] == "+12512222222"
|
||||||
|
assert len(payload['recipients']) == 1
|
||||||
|
assert "+12513333333" in payload['recipients']
|
||||||
|
|
||||||
|
details = mock_post.call_args_list[1]
|
||||||
|
assert details[0][0] == 'http://10.0.0.112:8080/v2/send'
|
||||||
|
payload = loads(details[1]['data'])
|
||||||
|
assert payload['message'] == 'My Title\r\ntest body'
|
||||||
|
assert payload['number'] == "+12512222222"
|
||||||
|
assert len(payload['recipients']) == 1
|
||||||
|
|
||||||
|
# The + is appended
|
||||||
|
assert "+12514444444" in payload['recipients']
|
||||||
|
|
||||||
|
mock_post.reset_mock()
|
||||||
|
|
||||||
|
# Test group names
|
||||||
|
aobj = Apprise()
|
||||||
|
aobj.add(
|
||||||
|
'signal://10.0.0.112:8080/+12513333333/@group1/@group2/'
|
||||||
|
'12514444444?batch=yes')
|
||||||
|
|
||||||
|
assert aobj.notify(title=title, body=body)
|
||||||
|
|
||||||
|
# If a batch, there is only 1 post
|
||||||
|
assert mock_post.call_count == 1
|
||||||
|
|
||||||
|
details = mock_post.call_args_list[0]
|
||||||
|
assert details[0][0] == 'http://10.0.0.112:8080/v2/send'
|
||||||
|
payload = loads(details[1]['data'])
|
||||||
|
assert payload['message'] == 'My Title\r\ntest body'
|
||||||
|
assert payload['number'] == "+12513333333"
|
||||||
|
assert len(payload['recipients']) == 3
|
||||||
|
assert "+12514444444" in payload['recipients']
|
||||||
|
# our groups
|
||||||
|
assert "group.group1" in payload['recipients']
|
||||||
|
assert "group.group2" in payload['recipients']
|
||||||
|
# Groups are stored properly
|
||||||
|
assert '/@group1' in aobj[0].url()
|
||||||
|
assert '/@group2' in aobj[0].url()
|
||||||
|
# Our target phone number is also in the path
|
||||||
|
assert '/+12514444444' in aobj[0].url()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch('requests.post')
|
||||||
|
def test_notify_signal_plugin_attachments(mock_post):
|
||||||
|
"""
|
||||||
|
NotifySignalAPI() Attachments
|
||||||
|
|
||||||
|
"""
|
||||||
|
# Disable Throttling to speed testing
|
||||||
|
plugins.NotifyBase.request_rate_per_sec = 0
|
||||||
|
|
||||||
|
okay_response = requests.Request()
|
||||||
|
okay_response.status_code = requests.codes.ok
|
||||||
|
okay_response.content = ""
|
||||||
|
|
||||||
|
# Assign our mock object our return value
|
||||||
|
mock_post.return_value = okay_response
|
||||||
|
|
||||||
|
obj = Apprise.instantiate(
|
||||||
|
'signal://10.0.0.112:8080/+12512222222/+12513333333/'
|
||||||
|
'12514444444?batch=no')
|
||||||
|
assert isinstance(obj, plugins.NotifySignalAPI)
|
||||||
|
|
||||||
|
# Test Valid Attachment
|
||||||
|
path = os.path.join(TEST_VAR_DIR, 'apprise-test.gif')
|
||||||
|
attach = AppriseAttachment(path)
|
||||||
|
assert obj.notify(
|
||||||
|
body='body', title='title', notify_type=NotifyType.INFO,
|
||||||
|
attach=attach) is True
|
||||||
|
|
||||||
|
# Test invalid attachment
|
||||||
|
path = os.path.join(TEST_VAR_DIR, '/invalid/path/to/an/invalid/file.jpg')
|
||||||
|
assert obj.notify(
|
||||||
|
body='body', title='title', notify_type=NotifyType.INFO,
|
||||||
|
attach=path) is False
|
||||||
|
|
||||||
|
# Get a appropriate "builtin" module name for pythons 2/3.
|
||||||
|
if sys.version_info.major >= 3:
|
||||||
|
builtin_open_function = 'builtins.open'
|
||||||
|
|
||||||
|
else:
|
||||||
|
builtin_open_function = '__builtin__.open'
|
||||||
|
|
||||||
|
# Test Valid Attachment (load 3)
|
||||||
|
path = (
|
||||||
|
os.path.join(TEST_VAR_DIR, 'apprise-test.gif'),
|
||||||
|
os.path.join(TEST_VAR_DIR, 'apprise-test.gif'),
|
||||||
|
os.path.join(TEST_VAR_DIR, 'apprise-test.gif'),
|
||||||
|
)
|
||||||
|
attach = AppriseAttachment(path)
|
||||||
|
|
||||||
|
# Return our good configuration
|
||||||
|
mock_post.side_effect = None
|
||||||
|
mock_post.return_value = okay_response
|
||||||
|
with mock.patch(builtin_open_function, side_effect=OSError()):
|
||||||
|
# We can't send the message we can't open the attachment for reading
|
||||||
|
assert obj.notify(
|
||||||
|
body='body', title='title', notify_type=NotifyType.INFO,
|
||||||
|
attach=attach) is False
|
||||||
|
|
||||||
|
# test the handling of our batch modes
|
||||||
|
obj = Apprise.instantiate(
|
||||||
|
'signal://10.0.0.112:8080/+12512222222/+12513333333/'
|
||||||
|
'12514444444?batch=yes')
|
||||||
|
assert isinstance(obj, plugins.NotifySignalAPI)
|
||||||
|
|
||||||
|
# Now send an attachment normally without issues
|
||||||
|
mock_post.reset_mock()
|
||||||
|
assert obj.notify(
|
||||||
|
body='body', title='title', notify_type=NotifyType.INFO,
|
||||||
|
attach=attach) is True
|
||||||
|
assert mock_post.call_count == 1
|
||||||
|
|
Loading…
Reference in New Issue