From 91faed0c6d22b49e7f31bc9dec1c8742f52a0aa3 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 6 Jul 2025 21:34:12 -0400 Subject: [PATCH] Added QQ Push Support (#1366) --- KEYWORDS | 1 + README.md | 1 + apprise/plugins/qq.py | 178 +++++++++++++++++++++++++++ packaging/redhat/python-apprise.spec | 12 +- test/test_plugin_qq.py | 73 +++++++++++ 5 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 apprise/plugins/qq.py create mode 100644 test/test_plugin_qq.py diff --git a/KEYWORDS b/KEYWORDS index dfd0795d..8856b0d3 100644 --- a/KEYWORDS +++ b/KEYWORDS @@ -79,6 +79,7 @@ Pushplus PushSafer Pushy PushDeer +QQ Push Reddit Resend Revolt diff --git a/README.md b/README.md index b6e0cd95..7dfff3b7 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ The table below identifies the services this tool supports and some example serv | [PushSafer](https://github.com/caronc/apprise/wiki/Notify_pushsafer) | psafer:// or psafers:// | (TCP) 80 or 443 | psafer://privatekey
psafers://privatekey/DEVICE
psafer://privatekey/DEVICE1/DEVICE2/DEVICEN | [Pushy](https://github.com/caronc/apprise/wiki/Notify_pushy) | pushy:// | (TCP) 443 | pushy://apikey/DEVICE
pushy://apikey/DEVICE1/DEVICE2/DEVICEN
pushy://apikey/TOPIC
pushy://apikey/TOPIC1/TOPIC2/TOPICN | [PushDeer](https://github.com/caronc/apprise/wiki/Notify_pushdeer) | pushdeer:// or pushdeers:// | (TCP) 80 or 443 | pushdeer://pushKey
pushdeer://hostname/pushKey
pushdeer://hostname:port/pushKey +| [QQ Push](https://github.com/caronc/apprise/wiki/Notify_qq) | qq:// | (TCP) 443 | qq://Token | [Reddit](https://github.com/caronc/apprise/wiki/Notify_reddit) | reddit:// | (TCP) 443 | reddit://user:password@app_id/app_secret/subreddit
reddit://user:password@app_id/app_secret/sub1/sub2/subN | [Resend](https://github.com/caronc/apprise/wiki/Notify_resend) | resend:// | (TCP) 443 | resend://APIToken:FromEmail/
resend://APIToken:FromEmail/ToEmail
resend://APIToken:FromEmail/ToEmail1/ToEmail2/ToEmailN/ | [Revolt](https://github.com/caronc/apprise/wiki/Notify_Revolt) | revolt:// | (TCP) 443 | revolt://bottoken/ChannelID
revolt://bottoken/ChannelID1/ChannelID2/ChannelIDN | diff --git a/apprise/plugins/qq.py b/apprise/plugins/qq.py new file mode 100644 index 00000000..7a605f69 --- /dev/null +++ b/apprise/plugins/qq.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- +# +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2025, Chris Caron +# +# 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. +# +# 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. + +# Assumes QQ Push API provided by third-party bridge like message-pusher + +import re +import requests + +from ..utils.parse import validate_regex +from ..url import PrivacyMode +from .base import NotifyBase +from ..locale import gettext_lazy as _ +from ..common import NotifyType + + +class NotifyQQ(NotifyBase): + """ + A wrapper for QQ Push Notifications + """ + + # The default descriptive name associated with the Notification + service_name = _('QQ Push') + + # The services URL + service_url = 'https://github.com/songquanpeng/message-pusher' + + # The default secure protocol + secure_protocol = 'qq' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_qq' + + # URL used to send notifications with + notify_url = 'https://qmsg.zendee.cn/send/' + + templates = ( + '{schema}://{token}', + ) + + template_tokens = dict(NotifyBase.template_tokens, **{ + 'token': { + 'name': _('User Token'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[a-z0-9]{24,64}$', 'i'), + }, + }) + + def __init__(self, token, **kwargs): + """ + Initialize QQ Push Object + + Args: + token (str): User push token from QQ Push provider (e.g., Qmsg) + """ + super().__init__(**kwargs) + + self.token = validate_regex( + token, *self.template_tokens['token']['regex'] + ) + if not self.token: + msg = 'The QQ Push token ({}) is invalid.'.format(token) + self.logger.warning(msg) + raise TypeError(msg) + + self.webhook_url = f'{self.notify_url}{self.token}' + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + params = self.url_parameters(privacy=privacy, *args, **kwargs) + return '{schema}://{token}/?{params}'.format( + schema=self.secure_protocol, + token=self.pprint(self.token, privacy, mode=PrivacyMode.Secret), + params=self.urlencode(params), + ) + + @property + def url_identifier(self): + """ + Returns a unique identifier for this plugin instance + """ + return (self.secure_protocol, self.token) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Send a QQ Push Notification + """ + payload = { + 'msg': f'{title}\n{body}' if title else body + } + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/x-www-form-urlencoded', + } + + self.throttle() + try: + response = requests.post( + self.webhook_url, + headers=headers, + data=payload, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + if response.status_code != requests.codes.ok: + self.logger.warning( + 'QQ Push notification failed: %d - %s', + response.status_code, response.text) + return False + + except requests.RequestException as e: + self.logger.warning(f'QQ Push Exception: {e}') + return False + + self.logger.info('QQ Push notification sent successfully.') + return True + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns arguments to re-instantiate the object + """ + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + return results + + if 'token' in results['qsd'] and results['qsd']['token']: + results['token'] = NotifyQQ.unquote(results['qsd']['token']) + else: + results['token'] = NotifyQQ.unquote(results['host']) + + return results + + @staticmethod + def parse_native_url(url): + """ + Parse native QQ push-style URL into Apprise format + """ + match = re.match( + r'^https://qmsg\.zendee\.cn/send/([a-z0-9]+)$', url, re.I) + if not match: + return None + + return NotifyQQ.parse_url( + '{schema}://{token}'.format( + schema=NotifyQQ.secure_protocol, + token=match.group(1))) diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec index 7aa7f700..54dc054a 100644 --- a/packaging/redhat/python-apprise.spec +++ b/packaging/redhat/python-apprise.spec @@ -48,12 +48,12 @@ 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, 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} +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 diff --git a/test/test_plugin_qq.py b/test/test_plugin_qq.py new file mode 100644 index 00000000..b5dcedfd --- /dev/null +++ b/test/test_plugin_qq.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2025, Chris Caron +# +# 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. +# +# 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. + +import requests +from apprise.plugins.qq import NotifyQQ +from helpers import AppriseURLTester + +import logging +logging.disable(logging.CRITICAL) + +apprise_url_tests = ( + ('qq://', { + 'instance': TypeError, + }), + ('qq://invalid!', { + 'instance': TypeError, + }), + ('qq://abc123def456ghi789jkl012mno345pq', { + 'instance': NotifyQQ, + 'privacy_url': 'qq://****/', + }), + ('qq://?token=abc123def456ghi789jkl012mno345pq', { + 'instance': NotifyQQ, + 'privacy_url': 'qq://****/', + }), + ('https://qmsg.zendee.cn/send/abc123def456ghi789jkl012mno345pq', { + 'instance': NotifyQQ, + }), + ('qq://abc123def456ghi789jkl012mno345pq', { + 'instance': NotifyQQ, + 'response': False, + 'requests_response_code': requests.codes.internal_server_error, + }), + ('qq://abc123def456ghi789jkl012mno345pq', { + 'instance': NotifyQQ, + 'response': False, + 'requests_response_code': 999, + }), + ('qq://ffffffffffffffffffffffffffffffff', { + 'instance': NotifyQQ, + 'test_requests_exceptions': True, + }), +) + + +def test_plugin_qq_urls(): + + AppriseURLTester(tests=apprise_url_tests).run_all()