pax_global_header00006660000000000000000000000064144160470450014517gustar00rootroot0000000000000052 comment=c8da46edb31302b5ed3a83273684dfcf17ab02c0 simplepush-python-2.2.3/000077500000000000000000000000001441604704500152335ustar00rootroot00000000000000simplepush-python-2.2.3/LICENSE.txt000066400000000000000000000020671441604704500170630ustar00rootroot00000000000000The MIT License (MIT) Copyright (c) 2023 simplepush.io 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. simplepush-python-2.2.3/README.md000066400000000000000000000033641441604704500165200ustar00rootroot00000000000000Python module to send push notifications via [Simplepush](https://simplepush.io/). # Installation ```bash pip3 install simplepush ``` # Examples All examples can be made asynchronous by using `async_send` instead of `send`. * Send a push notification to the Simplepush key `YourKey`: ```python import simplepush simplepush.send(key='YourKey', title='Notification title', message='Notification message') ``` * Send a push notification with actions and a callback function that will print the selected action: ```python import simplepush def callback(action_selected, action_selected_at, action_delivered_at, feedback_id): print(action_selected) simplepush.send(key='YourKey', title='Title', message='Actionable notification', actions=['yes', 'no', 'maybe'], feedback_callback=callback) ``` * Send an end-to-end encrypted push notification with actions and a callback function that will print the selected action and times out after 120 seconds: ```python import simplepush def callback(action_selected, action_selected_at, action_delivered_at, feedback_id): print(action_selected) simplepush.send(key='YourKey', password='password', salt='salt', message='Actionable notification', actions=['yes', 'no', 'maybe'], feedback_callback=callback, feedback_callback_timeout=120) ``` * Send an end-to-end encrypted push notification with an image and a video file: ```python import simplepush simplepush.send(key='YourKey', message='Attachments', password='password', salt='salt', attachments=['https://upload.wikimedia.org/wikipedia/commons/e/ee/Sample_abc.jpg', {'video': 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4', 'thumbnail': 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerEscapes.jpg'}]) ```simplepush-python-2.2.3/pyproject.toml000066400000000000000000000011751441604704500201530ustar00rootroot00000000000000[build-system] requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" [project] name = "simplepush" version = "2.2.3" authors = [ { name="Timm Schaeuble", email="contact@simplepush.io" }, ] description = "Simplepush Python Library" readme = "README.md" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] dependencies = [ "requests", "cryptography", "aiohttp" ] [project.urls] "Homepage" = "https://github.com/simplepush/simplepush-python" "Bug Tracker" = "https://github.com/simplepush/simplepush-python/issues" simplepush-python-2.2.3/src/000077500000000000000000000000001441604704500160225ustar00rootroot00000000000000simplepush-python-2.2.3/src/simplepush/000077500000000000000000000000001441604704500202135ustar00rootroot00000000000000simplepush-python-2.2.3/src/simplepush/__init__.py000066400000000000000000000002031441604704500223170ustar00rootroot00000000000000from .simplepush import send, async_send, BadRequest, UnknownError, FeedbackActionError, FeedbackActionTimeout, FeedbackUnavailablesimplepush-python-2.2.3/src/simplepush/simplepush.py000066400000000000000000000317301441604704500227620ustar00rootroot00000000000000"""Library to interact with the Simplepush notification service.""" import base64 import os from cryptography.hazmat.primitives import padding from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.backends import default_backend import hashlib import requests import aiohttp import asyncio import time from typing import Dict DEFAULT_TIMEOUT = 5 SALT = '1789F0B8C4A051E5' SIMPLEPUSH_URL = 'https://simplepu.sh' class BadRequest(Exception): """Raised when API thinks that title or message are too long.""" pass class UnknownError(Exception): """Raised for invalid responses.""" pass class FeedbackActionError(Exception): """Raised when feedback API is not reachable.""" pass class FeedbackActionTimeout(Exception): """Raised when a feedback action timed out.""" pass class FeedbackUnavailable(Exception): """Raised when feedback doesn't exist.""" pass def send(key, message, title=None, password=None, salt=None, attachments = None, event=None, actions=None, feedback_callback=None, feedback_callback_timeout=60, ignore_connection_errors=True): """Send a plain-text message.""" if not key or not message: raise ValueError("Key and message argument must be set") if password and not salt: raise ValueError("Salt is missing") if not password and salt: raise ValueError("Password is missing") _check_actions(actions) _check_attachments(attachments) payload, actions, actions_encrypted = _generate_payload(key, title, message, attachments, event, actions, password, salt) r = requests.post(SIMPLEPUSH_URL + '/send', json=payload, timeout=DEFAULT_TIMEOUT) _handle_response(r, actions, actions_encrypted, feedback_callback, feedback_callback_timeout, ignore_connection_errors) async def async_send(key, message, title=None, password=None, salt=None, attachments=None, event=None, actions=None, feedback_callback=None, feedback_callback_timeout=60, ignore_connection_errors=True, aiohttp_session=None): """Send a plain-text message.""" if not key or not message: raise ValueError("Key and message argument must be set") if password and not salt: raise ValueError("Salt is missing") if not password and salt: raise ValueError("Password is missing") _check_actions(actions) _check_attachments(attachments) payload, actions, actions_encrypted = _generate_payload(key, title, message, attachments, event, actions, password, salt) if aiohttp_session: async with aiohttp_session.post(SIMPLEPUSH_URL + '/send', json=payload) as resp: return await _async_handle_response(await resp.json(), actions, actions_encrypted, feedback_callback, feedback_callback_timeout,ignore_connection_errors, aiohttp_session) else: async with aiohttp.ClientSession(raise_for_status=True) as session: async with session.post(SIMPLEPUSH_URL + '/send', json=payload) as resp: return await _async_handle_response(await resp.json(), actions, actions_encrypted, feedback_callback, feedback_callback_timeout, ignore_connection_errors, session) def _handle_response(response, actions, actions_encrypted, feedback_callback, feedback_callback_timeout, ignore_connection_errors): """Raise error if message was not successfully sent.""" if response.json()['status'] == 'BadRequest' and response.json()['message'] == 'Title or message too long': raise BadRequest if response.json()['status'] != 'OK': raise UnknownError if 'feedbackId' in response.json() and feedback_callback is not None: feedback_id = response.json()['feedbackId'] _query_feedback_endpoint(feedback_id, actions, actions_encrypted, feedback_callback, feedback_callback_timeout, ignore_connection_errors) response.raise_for_status() async def _async_handle_response(json_response, actions, actions_encrypted, feedback_callback, feedback_callback_timeout, ignore_connection_errors, aiohttp_session): """Raise error if message was not successfully sent.""" if json_response['status'] == 'BadRequest' and json_response['message'] == 'Title or message too long': raise BadRequest if json_response['status'] != 'OK': raise UnknownError if 'feedbackId' in json_response and feedback_callback is not None: feedback_id = json_response['feedbackId'] await _async_query_feedback_endpoint(feedback_id, actions, actions_encrypted, feedback_callback, feedback_callback_timeout, ignore_connection_errors, aiohttp_session) def _generate_payload(key, title, message, attachments=None, event=None, actions=None, password=None, salt=None): """Generator for the payload.""" payload = {'key': key} actions_encrypted = None if not password: payload.update({'msg': message}) if title: payload.update({'title': title}) if event: payload.update({'event': event}) if actions: payload.update({'actions': actions}) if attachments: payload.update({'attachments': attachments}) else: encryption_key = _generate_encryption_key(password, salt) iv = _generate_iv() iv_hex = "" for c_idx in range(len(iv)): iv_hex += "{:02x}".format(ord(iv[c_idx:c_idx+1])) iv_hex = iv_hex.upper() payload.update({'encrypted': 'true', 'iv': iv_hex}) if title: title = _encrypt(encryption_key, iv, title) payload.update({'title': title}) if event: payload.update({'event': event}) message = _encrypt(encryption_key, iv, message) payload.update({'msg': message}) if actions: actions_encrypted = [] for action in actions: if isinstance(action, str): # Feedback Action actions_encrypted.append(_encrypt(encryption_key, iv, action)) elif isinstance(action, Dict) and 'name' in action.keys() and 'url' in action.keys(): # GET Action actions_encrypted.append({'name' : _encrypt(encryption_key, iv, action['name']), 'url' : _encrypt(encryption_key, iv, action['url'])}) payload.update({'actions': actions_encrypted}) if attachments: attachments_encrypted = [] for attachment in attachments: if isinstance(attachment, Dict) and 'thumbnail' in attachment.keys() and 'video' in attachment.keys(): attachments_encrypted.append({'thumbnail' : _encrypt(encryption_key, iv, attachment['thumbnail']), 'video' : _encrypt(encryption_key, iv, attachment['video'])}) elif isinstance(attachment, str): attachments_encrypted.append(_encrypt(encryption_key, iv, attachment)) payload.update({'attachments': attachments_encrypted}) return payload, actions, actions_encrypted def _generate_iv(): """Generator for the initialization vector.""" return os.urandom(algorithms.AES.block_size // 8) def _generate_encryption_key(password, salt=None): """Create the encryption key.""" if salt: salted_password = password + salt else: # Compatibility for older versions salted_password = password + SALT hex_str = hashlib.sha1(salted_password.encode('utf-8')).hexdigest()[0:32] byte_str = bytearray.fromhex(hex_str) return bytes(byte_str) def _encrypt(encryption_key, iv, data): """Encrypt the payload.""" padder = padding.PKCS7(algorithms.AES.block_size).padder() data = padder.update(data.encode()) + padder.finalize() encryptor = Cipher(algorithms.AES(encryption_key), modes.CBC(iv), default_backend()).encryptor() return base64.urlsafe_b64encode(encryptor.update(data) + encryptor.finalize()).decode('ascii') def _check_actions(actions): """Raise error if actions can't be parsed""" if not isinstance(actions, list) and actions is not None: raise ValueError("Actions malformed") if isinstance(actions, list) and len(actions) > 0: if isinstance(actions[0], str): if not all(isinstance(el, str) for el in actions): raise ValueError("Feedback actions malformed") else: if not all('name' in el.keys() and 'url' in el.keys() for el in actions): raise ValueError("Get actions malformed") def _check_attachments(attachments): if not isinstance(attachments, list) and attachments is not None: raise ValueError("Attachments malformed") def _query_feedback_endpoint(feedback_id, actions, actions_encrypted, callback, timeout, ignore_connection_errors): stop = False n = 0 start = time.time() while not stop: try: resp = requests.get(SIMPLEPUSH_URL + '/1/feedback/' + feedback_id) json = resp.json() if resp.ok and json['success']: if json['action_selected']: stop = True if actions_encrypted is None: callback(json['action_selected'], json['action_selected_at'], json['action_delivered_at'], feedback_id) else: encrypted_action_selected = json['action_selected'] idx = actions_encrypted.index(encrypted_action_selected) action_selected = actions[idx] callback(action_selected, json['action_selected_at'], json['action_delivered_at'], feedback_id) else: if timeout: now = time.time() if now > start + timeout: stop = True raise FeedbackActionTimeout("Feedback Action ID: " + feedback_id) if n < 60: # In the first minute query every second time.sleep(1) elif n < 260: # In the ten minutes after the first minute query every two seconds time.sleep(2) else: # After 11 minutes query every three seconds time.sleep(3) else: if not ignore_connection_errors: stop = True raise FeedbackActionError("Failed to reach feedback API.") else: time.sleep(5) except requests.exceptions.RequestException as e: if not ignore_connection_errors: stop = True raise FeedbackActionError("Failed to reach feedback API: " + str(e)) else: time.sleep(5) async def _async_query_feedback_endpoint(feedback_id, actions, actions_encrypted, callback, timeout, ignore_connection_errors, aiohttp_session): stop = False n = 0 start = time.time() while not stop: try: async with aiohttp_session.get(SIMPLEPUSH_URL + '/1/feedback/' + feedback_id) as resp: json = await resp.json() if resp.ok and json['success']: if json['action_selected']: stop = True if actions_encrypted is None: callback(json['action_selected'], json['action_selected_at'], json['action_delivered_at'], feedback_id) else: encrypted_action_selected = json['action_selected'] idx = actions_encrypted.index(encrypted_action_selected) action_selected = actions[idx] callback(action_selected, json['action_selected_at'], json['action_delivered_at'], feedback_id) else: if timeout: now = time.time() if now > start + timeout: stop = True raise FeedbackActionTimeout("Feedback Action ID: " + feedback_id) if n < 60: # In the first minute query every second await asyncio.sleep(1) elif n < 260: # In the ten minutes after the first minute query every two seconds await asyncio.sleep(2) else: # After 11 minutes query every three seconds await asyncio.sleep(3) else: if not ignore_connection_errors: stop = True raise FeedbackActionError("Failed to reach feedback API.") else: time.sleep(5) except (aiohttp.ClientConnectionError, asyncio.TimeoutError) as e: if not ignore_connection_errors: stop = True raise FeedbackActionError("Failed to reach feedback API: " + str(e)) else: await asyncio.sleep(5)