no log: latest apprise upgrade

pull/2420/head v1.4.3-beta.6
morpheus65535 7 months ago
parent 20d235e1b5
commit e5db62eb95

@ -1,12 +1,12 @@
Metadata-Version: 2.1
Name: apprise
Version: 1.7.2
Version: 1.7.3
Summary: Push Notifications that work with just about every platform!
Home-page: https://github.com/caronc/apprise
Author: Chris Caron
Author-email: lead2gold@gmail.com
License: BSD
Keywords: Alerts Apprise API Automated Packet Reporting System AWS Boxcar BulkSMS BulkVS Burst SMS Chat CLI ClickSend D7Networks Dapnet DBus DingTalk Discord Email Emby Enigma2 Faast FCM Flock Form Gnome Google Chat Gotify Growl Guilded Home Assistant httpSMS IFTTT Join JSON Kavenegar KODI Kumulos LaMetric Line MacOSX Mailgun Mastodon Matrix Mattermost MessageBird Microsoft Misskey MQTT MSG91 MSTeams Nextcloud NextcloudTalk Notica Notifiarr Notifico Ntfy Office365 OneSignal Opsgenie PagerDuty PagerTree ParsePlatform PopcornNotify Prowl PushBullet Pushed Pushjet PushMe Push Notifications Pushover PushSafer Pushy PushDeer Reddit Rocket.Chat RSyslog Ryver SendGrid ServerChan SES Signal SimplePush Sinch Slack SMSEagle SMS Manager SMTP2Go SNS SparkPost Streamlabs Stride Synology Chat Syslog Techulus Telegram Threema Gateway Twilio Twist Twitter Voipms Vonage Webex WeCom Bot WhatsApp Windows XBMC XML Zulip
Keywords: Alerts Apprise API Automated Packet Reporting System AWS Boxcar BulkSMS BulkVS Burst SMS Chat CLI ClickSend D7Networks Dapnet DBus DingTalk Discord Email Emby Enigma2 Faast FCM Flock Form Gnome Google Chat Gotify Growl Guilded Home Assistant httpSMS IFTTT Join JSON Kavenegar KODI Kumulos LaMetric Line MacOSX Mailgun Mastodon Matrix Mattermost MessageBird Microsoft Misskey MQTT MSG91 MSTeams Nextcloud NextcloudTalk Notica Notifiarr Notifico Ntfy Office365 OneSignal Opsgenie PagerDuty PagerTree ParsePlatform PopcornNotify Prowl PushBullet Pushed Pushjet PushMe Push Notifications Pushover PushSafer Pushy PushDeer Reddit Revolt Rocket.Chat RSyslog Ryver SendGrid ServerChan SES Signal SimplePush Sinch Slack SMSEagle SMS Manager SMTP2Go SNS SparkPost Streamlabs Stride Synology Chat Syslog Techulus Telegram Threema Gateway Twilio Twist Twitter Voipms Vonage Webex WeCom Bot WhatsApp Windows XBMC XML Zulip
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
@ -146,6 +146,7 @@ The table below identifies the services this tool supports and some example serv
| [Pushy](https://github.com/caronc/apprise/wiki/Notify_pushy) | pushy:// | (TCP) 443 | pushy://apikey/DEVICE<br />pushy://apikey/DEVICE1/DEVICE2/DEVICEN<br />pushy://apikey/TOPIC<br />pushy://apikey/TOPIC1/TOPIC2/TOPICN
| [PushDeer](https://github.com/caronc/apprise/wiki/Notify_pushdeer) | pushdeer:// or pushdeers:// | (TCP) 80 or 443 | pushdeer://pushKey<br />pushdeer://hostname/pushKey<br />pushdeer://hostname:port/pushKey
| [Reddit](https://github.com/caronc/apprise/wiki/Notify_reddit) | reddit:// | (TCP) 443 | reddit://user:password@app_id/app_secret/subreddit<br />reddit://user:password@app_id/app_secret/sub1/sub2/subN
| [Revolt](https://github.com/caronc/apprise/wiki/Notify_Revolt) | revolt:// | (TCP) 443 | revolt://bottoken/ChannelID<br />revolt://bottoken/ChannelID1/ChannelID2/ChannelIDN |
| [Rocket.Chat](https://github.com/caronc/apprise/wiki/Notify_rocketchat) | rocket:// or rockets:// | (TCP) 80 or 443 | rocket://user:password@hostname/RoomID/Channel<br />rockets://user:password@hostname:443/#Channel1/#Channel1/RoomID<br />rocket://user:password@hostname/#Channel<br />rocket://webhook@hostname<br />rockets://webhook@hostname/@User/#Channel
| [RSyslog](https://github.com/caronc/apprise/wiki/Notify_rsyslog) | rsyslog:// | (UDP) 514 | rsyslog://hostname<br />rsyslog://hostname/Facility
| [Ryver](https://github.com/caronc/apprise/wiki/Notify_ryver) | ryver:// | (TCP) 443 | ryver://Organization/Token<br />ryver://botname@Organization/Token
@ -270,30 +271,41 @@ No one wants to put their credentials out for everyone to see on the command lin
# configuration files (if present) from:
# ~/.apprise
# ~/.apprise.yml
# ~/.apprise.yaml
# ~/.config/apprise
# ~/.config/apprise.yml
# ~/.config/apprise.yaml
# /etc/apprise
# /etc/apprise.yml
# /etc/apprise.yaml
# Also a subdirectory handling allows you to leverage plugins
# ~/.apprise/apprise
# ~/.apprise/apprise.yml
# ~/.apprise/apprise.yaml
# ~/.config/apprise/apprise
# ~/.config/apprise/apprise.yml
# ~/.config/apprise/apprise.yaml
# /etc/apprise/apprise
# /etc/apprise/apprise.yml
# /etc/apprise/apprise.yaml
# Windows users can store their default configuration files here:
# %APPDATA%/Apprise/apprise
# %APPDATA%/Apprise/apprise.yml
# %APPDATA%/Apprise/apprise.yaml
# %LOCALAPPDATA%/Apprise/apprise
# %LOCALAPPDATA%/Apprise/apprise.yml
# %LOCALAPPDATA%/Apprise/apprise.yaml
# %ALLUSERSPROFILE%\Apprise\apprise
# %ALLUSERSPROFILE%\Apprise\apprise.yml
# %ALLUSERSPROFILE%\Apprise\apprise.yaml
# %PROGRAMFILES%\Apprise\apprise
# %PROGRAMFILES%\Apprise\apprise.yml
# %PROGRAMFILES%\Apprise\apprise.yaml
# %COMMONPROGRAMFILES%\Apprise\apprise
# %COMMONPROGRAMFILES%\Apprise\apprise.yml
# %COMMONPROGRAMFILES%\Apprise\apprise.yaml
# If you loaded one of those files, your command line gets really easy:
apprise -vv -t 'my title' -b 'my notification body'

@ -1,12 +1,12 @@
../../bin/apprise,sha256=ZJ-e4qqxNLtdW_DAvpuPPX5iROIiQd8I6nvg7vtAv-g,233
apprise-1.7.2.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
apprise-1.7.2.dist-info/LICENSE,sha256=gt7qKBxRhVcdmXCYVtrWP6DtYjD0DzONet600dkU994,1343
apprise-1.7.2.dist-info/METADATA,sha256=lNkOI_XF6axOtqkZLFfmVDiDGew_HtM2pfFDZyG62ME,43818
apprise-1.7.2.dist-info/RECORD,,
apprise-1.7.2.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
apprise-1.7.2.dist-info/WHEEL,sha256=Xo9-1PvkuimrydujYJAjF7pCkriuXBpUPEjma1nZyJ0,92
apprise-1.7.2.dist-info/entry_points.txt,sha256=71YypBuNdjAKiaLsiMG40HEfLHxkU4Mi7o_S0s0d8wI,45
apprise-1.7.2.dist-info/top_level.txt,sha256=JrCRn-_rXw5LMKXkIgMSE4E0t1Ks9TYrBH54Pflwjkk,8
apprise-1.7.3.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
apprise-1.7.3.dist-info/LICENSE,sha256=gt7qKBxRhVcdmXCYVtrWP6DtYjD0DzONet600dkU994,1343
apprise-1.7.3.dist-info/METADATA,sha256=1IS6O2IzRJcduJO9wK9tJhz1jDhZXcTTXfudj3-yy-Q,44360
apprise-1.7.3.dist-info/RECORD,,
apprise-1.7.3.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
apprise-1.7.3.dist-info/WHEEL,sha256=Xo9-1PvkuimrydujYJAjF7pCkriuXBpUPEjma1nZyJ0,92
apprise-1.7.3.dist-info/entry_points.txt,sha256=71YypBuNdjAKiaLsiMG40HEfLHxkU4Mi7o_S0s0d8wI,45
apprise-1.7.3.dist-info/top_level.txt,sha256=JrCRn-_rXw5LMKXkIgMSE4E0t1Ks9TYrBH54Pflwjkk,8
apprise/Apprise.py,sha256=Stm2NhJprWRaMwQfTiIQG_nR1bLpHi_zcdwEcsCpa-A,32865
apprise/Apprise.pyi,sha256=_4TBKvT-QVj3s6PuTh3YX-BbQMeJTdBGdVpubLMY4_k,2203
apprise/AppriseAsset.py,sha256=jRW8Y1EcAvjVA9h_mINmsjO4DM3S0aDl6INIFVMcUCs,11647
@ -19,9 +19,9 @@ apprise/AppriseLocale.py,sha256=ISth7xC7M1WhsSNXdGZFouaA4bi07KP35m9RX-ExG48,8852
apprise/AttachmentManager.py,sha256=EwlnjuKn3fv_pioWcmMCkyDTsO178t6vkEOD8AjAPsw,2053
apprise/ConfigurationManager.py,sha256=MUmGajxjgnr6FGN7xb3q0nD0VVgdTdvapBBR7CsI-rc,2058
apprise/NotificationManager.py,sha256=ZJgkiCgcJ7Bz_6bwQ47flrcxvLMbA4Vbw0HG_yTsGdE,2041
apprise/URLBase.py,sha256=HgRiGXOCb4ZhTXmRved9VxfcX-eec3pII3Eb0zRh8Aw,28389
apprise/URLBase.py,sha256=ZWjHz69790EfVNDIBzWzRZzjw-gwC3db_t3_3an6cWI,28388
apprise/URLBase.pyi,sha256=WLaRREH7FzZ5x3-qkDkupojWGFC4uFwJ1EDt02lVs8c,520
apprise/__init__.py,sha256=cQvk-yABi1MGIYCxa9di1DYMMAl6IuI5BhbzfOt6NSY,3368
apprise/__init__.py,sha256=hqhBy0IX4xGRicwbKBMX_OVy1tgOo7hBrH_hG0n0XP4,3368
apprise/assets/NotifyXML-1.0.xsd,sha256=292qQ_IUl5EWDhPyzm9UTT0C2rVvJkyGar8jiODkJs8,986
apprise/assets/NotifyXML-1.1.xsd,sha256=bjR3CGG4AEXoJjYkGCbDttKHSkPP1FlIWO02E7G59g4,1758
apprise/assets/themes/default/apprise-failure-128x128.ico,sha256=Mt0ptfHJaN3Wsv5UCNDn9_3lyEDHxVDv1JdaDEI_xCA,67646
@ -50,7 +50,7 @@ apprise/attachment/AttachBase.pyi,sha256=w0XG_QKauiMLJ7eQ4S57IiLIURZHm_Snw7l6-ih
apprise/attachment/AttachFile.py,sha256=MbHY_av0GeM_AIBKV02Hq7SHiZ9eCr1yTfvDMUgi2I4,4765
apprise/attachment/AttachHTTP.py,sha256=dyDy3U47cI28ENhaw1r5nQlGh8FWHZlHI8n9__k8wcY,11995
apprise/attachment/__init__.py,sha256=xabgXpvV05X-YRuqIt3uGYMXwYNXjHyF6Dwd8HfZCFE,1658
apprise/cli.py,sha256=fa-3beNKx3ZC3KkNwgJMMfs1LDI2Hjyol_bXp6WhK4s,19739
apprise/cli.py,sha256=Xl69ZR6dd9SkKqYErAiq2sSK89mXPwWr-QzHaJmK0Ic,20228
apprise/common.py,sha256=I6wfrndggCL7l7KAl7Cm4uwAX9n0l3SN4-BVvTE0L0M,5593
apprise/common.pyi,sha256=luF3QRiClDCk8Z23rI6FCGYsVmodOt_JYfYyzGogdNM,447
apprise/config/ConfigBase.py,sha256=A4p_N9vSxOK37x9kuYeZFzHhAeEt-TCe2oweNi2KGg4,53062
@ -67,7 +67,7 @@ apprise/emojis.py,sha256=ONF0t8dY9f2XlEkLUG79-ybKVAj2GqbPj2-Be97vAoI,87738
apprise/i18n/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
apprise/i18n/en/LC_MESSAGES/apprise.mo,sha256=oUTuHREmLEYN07oqYqRMJ_kU71-o5o37NsF4RXlC5AU,3959
apprise/logger.py,sha256=131hqhed8cUj9x_mfXDEvwA2YbcYDFAYiWVK1HgxRVY,6921
apprise/manager.py,sha256=sJUNy6IttMVVS3D8Nqzab96dogmKJpNVOBVx93HrX7c,25526
apprise/manager.py,sha256=1KQVMAzq-wyZlzDBObKawQySah5F_Cq7LFdkmDctqDU,27086
apprise/plugins/NotifyAppriseAPI.py,sha256=ISBE0brD3eQdyw3XrGXd4Uc4kSYvIuI3SSUVCt-bkdo,16654
apprise/plugins/NotifyAprs.py,sha256=IS1uxIl391L3i2LOK6x8xmlOG1W58k4o793Oq2W5Wao,24220
apprise/plugins/NotifyBark.py,sha256=bsDvKooRy4k1Gg7tvBjv3DIx7-WZiV_mbTrkTwMtd9Q,15698
@ -83,7 +83,7 @@ apprise/plugins/NotifyDBus.py,sha256=1eVJHIL3XkFjDePMqfcll35Ie1vxggJ1iBsVFAIaF00
apprise/plugins/NotifyDapnet.py,sha256=KuXjBU0ZrIYtoDei85NeLZ-IP810T4w5oFXH9sWiSh0,13624
apprise/plugins/NotifyDingTalk.py,sha256=NJyETgN6QjtRqtxQjfBLFVuFpURyWykRftm6WpQJVbY,12009
apprise/plugins/NotifyDiscord.py,sha256=M_qmTzB7NNL5_agjYDX38KBN1jRzDBp2EMSNwEF_9Tw,26072
apprise/plugins/NotifyEmail.py,sha256=q75KtPsvLIaa_0gH4-0ASV4KbE9VKDo3ssj_j7Z-fdk,38284
apprise/plugins/NotifyEmail.py,sha256=DhAzLFX4pzzuS07QQFcv0VUOYu2PzQE7TTjlPokJcPY,38883
apprise/plugins/NotifyEmby.py,sha256=OMVO8XsVl_XCBYNNNQi8ni2lS4voLfU8Puk1xJOAvHs,24039
apprise/plugins/NotifyEnigma2.py,sha256=Hj0Q9YOeljSwbfiuMKLqXTVX_1g_mjNUGEts7wfrwno,11498
apprise/plugins/NotifyFCM/__init__.py,sha256=mBFtIgIJuLIFnMB5ndx5Makjs9orVMc2oLoD7LaVT48,21669
@ -111,7 +111,7 @@ apprise/plugins/NotifyLine.py,sha256=OVI0ozMJcq_-dI8dodVX52dzUzgENlAbOik-Kw4l-rI
apprise/plugins/NotifyMQTT.py,sha256=PFLwESgR8dMZvVFHxmOZ8xfy-YqyX5b2kl_e8Z1lo-0,19537
apprise/plugins/NotifyMSG91.py,sha256=P7JPyT1xmucnaEeCZPf_6aJfe1gS_STYYwEM7hJ7QBw,12677
apprise/plugins/NotifyMSTeams.py,sha256=dFH575hoLL3zRddbBKfozlYjxvPJGbj3BKvfJSIkvD0,22976
apprise/plugins/NotifyMacOSX.py,sha256=1LlSjTxkm27btdXCE-rDn2FMGiyZmqlR5-HoXLxK7jM,8227
apprise/plugins/NotifyMacOSX.py,sha256=y2fGpSZXomFiNwKbWImrXQUMVM4JR4uPCnsWpnxQrFA,8271
apprise/plugins/NotifyMailgun.py,sha256=FNS_QLOQWMo62yVO-mMZkpiXudUtSdbHOjfSrLC4oIo,25409
apprise/plugins/NotifyMastodon.py,sha256=2ovjQIOOITHH8lOinC8QCFCJN2QA8foIM2pjdknbblc,35277
apprise/plugins/NotifyMatrix.py,sha256=I8kdaZUZS-drew0JExBbChQVe7Ib4EwAjQd0xE30XT0,50049
@ -123,7 +123,7 @@ apprise/plugins/NotifyNextcloudTalk.py,sha256=dLl_g7Knq5PVcadbzDuQsxbGHTZlC4r-pQ
apprise/plugins/NotifyNotica.py,sha256=yHmk8HiNFjzoI4Gewo_nBRrx9liEmhT95k1d10wqhYg,12990
apprise/plugins/NotifyNotifiarr.py,sha256=ADwLJO9eenfLkNa09tXMGSBTM4c3zTY0SEePvyB8WYA,15857
apprise/plugins/NotifyNotifico.py,sha256=Qe9jMN_M3GL4XlYIWkAf-w_Hf65g9Hde4bVuytGhUW4,12035
apprise/plugins/NotifyNtfy.py,sha256=EiG7-z84XibAcWd0iANsU7nZofWEa9xQ6X1z8oc1ZGE,27789
apprise/plugins/NotifyNtfy.py,sha256=TkDs6jOc30XQn2O2BJ14-nE_cohPdJiSS8DpYXc9hoE,27953
apprise/plugins/NotifyOffice365.py,sha256=8TxsVsdbUghmNj0kceMlmoZzTOKQTgn3priI8JuRuHE,25190
apprise/plugins/NotifyOneSignal.py,sha256=gsw7ckW7xLiJDRUb7eJHNe_4bvdBXmt6_YsB1u_ghjw,18153
apprise/plugins/NotifyOpsgenie.py,sha256=zJWpknjoHq35Iv9w88ucR62odaeIN3nrGFPtYnhDdjA,20515
@ -142,6 +142,7 @@ apprise/plugins/NotifyPushover.py,sha256=MJDquV4zl1cNrGZOC55hLlt6lOb6625WeUcgS5c
apprise/plugins/NotifyPushy.py,sha256=mmWcnu905Fvc8ihYXvZ7lVYErGZH5Q-GbBNS20v5r48,12496
apprise/plugins/NotifyRSyslog.py,sha256=W42LT90X65-pNoU7KdhdX1PBcmsz9RyV376CDa_H3CI,11982
apprise/plugins/NotifyReddit.py,sha256=E78OSyDQfUalBEcg71sdMsNBOwdj7cVBnELrhrZEAXY,25785
apprise/plugins/NotifyRevolt.py,sha256=DRA9Xylwl6leVjVFuJcP4L1cG49CIBtnQdxh4BKnAZ4,14500
apprise/plugins/NotifyRocketChat.py,sha256=GTEfT-upQ56tJgE0kuc59l4uQGySj_d15wjdcARR9Ko,24624
apprise/plugins/NotifyRyver.py,sha256=yhHPMLGeJtcHwBKSPPk0OBfp59DgTvXio1R59JhrJu4,11823
apprise/plugins/NotifySES.py,sha256=wtRmpAZkS5mQma6sdiaPT6U1xcgoj77CB9mNFvSEAw8,33545
@ -160,7 +161,7 @@ apprise/plugins/NotifyStreamlabs.py,sha256=lx3N8T2ufUWFYIZ-kU_rOv50YyGWBqLSCKk7x
apprise/plugins/NotifySynology.py,sha256=_jTqfgWeOuSi_I8geMOraHBVFtDkvm9mempzymrmeAo,11105
apprise/plugins/NotifySyslog.py,sha256=J9Kain2bb-PDNiG5Ydb0q678cYjNE_NjZFqMG9oEXM0,10617
apprise/plugins/NotifyTechulusPush.py,sha256=m43_Qj1scPcgCRX5Dr2Ul7nxMbaiVxNzm_HRuNmfgoA,7253
apprise/plugins/NotifyTelegram.py,sha256=km4Izpx0SIP4f__R9_rVjdgUpJCXmM8KX8Tvl3FMqms,35630
apprise/plugins/NotifyTelegram.py,sha256=Bim4mmPcefHNpvbNSy3pmLuCXRw5IVVWUNUB1SkIhDM,35624
apprise/plugins/NotifyThreema.py,sha256=C_C3j0fJWgeF2uB7ceJFXOdC6Lt0TFBInFMs5Xlg04M,11885
apprise/plugins/NotifyTwilio.py,sha256=WCo8eTI9OF1rtg3ueHHRDXt4Lp45eZ6h3IdTZVf5HM8,15976
apprise/plugins/NotifyTwist.py,sha256=nZA73CYVe-p0tkVMy5q3vFRyflLM4yjUo9LECvkUwgc,28841

@ -28,7 +28,7 @@
import re
from .logger import logger
from time import sleep
import time
from datetime import datetime
from xml.sax.saxutils import escape as sax_escape
@ -298,12 +298,12 @@ class URLBase:
if wait is not None:
self.logger.debug('Throttling forced for {}s...'.format(wait))
sleep(wait)
time.sleep(wait)
elif elapsed < self.request_rate_per_sec:
self.logger.debug('Throttling for {}s...'.format(
self.request_rate_per_sec - elapsed))
sleep(self.request_rate_per_sec - elapsed)
time.sleep(self.request_rate_per_sec - elapsed)
# Update our timestamp before we leave
self._last_io_datetime = datetime.now()

@ -27,7 +27,7 @@
# POSSIBILITY OF SUCH DAMAGE.
__title__ = 'Apprise'
__version__ = '1.7.2'
__version__ = '1.7.3'
__author__ = 'Chris Caron'
__license__ = 'BSD'
__copywrite__ = 'Copyright (C) 2024 Chris Caron <lead2gold@gmail.com>'

@ -68,20 +68,26 @@ DEFAULT_CONFIG_PATHS = (
# Legacy Path Support
'~/.apprise',
'~/.apprise.yml',
'~/.apprise.yaml',
'~/.config/apprise',
'~/.config/apprise.yml',
'~/.config/apprise.yaml',
# Plugin Support Extended Directory Search Paths
'~/.apprise/apprise',
'~/.apprise/apprise.yml',
'~/.apprise/apprise.yaml',
'~/.config/apprise/apprise',
'~/.config/apprise/apprise.yml',
'~/.config/apprise/apprise.yaml',
# Global Configuration Support
'/etc/apprise',
'/etc/apprise.yml',
'/etc/apprise.yaml',
'/etc/apprise/apprise',
'/etc/apprise/apprise.yml',
'/etc/apprise/apprise.yaml',
)
# Define our paths to search for plugins
@ -99,8 +105,10 @@ if platform.system() == 'Windows':
DEFAULT_CONFIG_PATHS = (
expandvars('%APPDATA%\\Apprise\\apprise'),
expandvars('%APPDATA%\\Apprise\\apprise.yml'),
expandvars('%APPDATA%\\Apprise\\apprise.yaml'),
expandvars('%LOCALAPPDATA%\\Apprise\\apprise'),
expandvars('%LOCALAPPDATA%\\Apprise\\apprise.yml'),
expandvars('%LOCALAPPDATA%\\Apprise\\apprise.yaml'),
#
# Global Support
@ -109,14 +117,17 @@ if platform.system() == 'Windows':
# C:\ProgramData\Apprise\
expandvars('%ALLUSERSPROFILE%\\Apprise\\apprise'),
expandvars('%ALLUSERSPROFILE%\\Apprise\\apprise.yml'),
expandvars('%ALLUSERSPROFILE%\\Apprise\\apprise.yaml'),
# C:\Program Files\Apprise
expandvars('%PROGRAMFILES%\\Apprise\\apprise'),
expandvars('%PROGRAMFILES%\\Apprise\\apprise.yml'),
expandvars('%PROGRAMFILES%\\Apprise\\apprise.yaml'),
# C:\Program Files\Common Files
expandvars('%COMMONPROGRAMFILES%\\Apprise\\apprise'),
expandvars('%COMMONPROGRAMFILES%\\Apprise\\apprise.yml'),
expandvars('%COMMONPROGRAMFILES%\\Apprise\\apprise.yaml'),
)
# Default Plugin Search Path for Windows Users

@ -32,6 +32,7 @@ import sys
import time
import hashlib
import inspect
import threading
from .utils import import_module
from .utils import Singleton
from .utils import parse_list
@ -60,6 +61,9 @@ class PluginManager(metaclass=Singleton):
# The module path to scan
module_path = join(abspath(dirname(__file__)), _id)
# thread safe loading
_lock = threading.Lock()
def __init__(self, *args, **kwargs):
"""
Over-ride our class instantiation to provide a singleton
@ -103,40 +107,49 @@ class PluginManager(metaclass=Singleton):
# effort/overhead doing it again
self._paths_previously_scanned = set()
# Track loaded module paths to prevent from loading them again
self._loaded = set()
def unload_modules(self, disable_native=False):
"""
Reset our object and unload all modules
"""
if self._custom_module_map:
# Handle Custom Module Assignments
for meta in self._custom_module_map.values():
if meta['name'] not in self._module_map:
# Nothing to remove
continue
with self._lock:
if self._custom_module_map:
# Handle Custom Module Assignments
for meta in self._custom_module_map.values():
if meta['name'] not in self._module_map:
# Nothing to remove
continue
# For the purpose of tidying up un-used modules in memory
loaded = [m for m in sys.modules.keys()
if m.startswith(
self._module_map[meta['name']]['path'])]
# For the purpose of tidying up un-used modules in memory
loaded = [m for m in sys.modules.keys()
if m.startswith(
self._module_map[meta['name']]['path'])]
for module_path in loaded:
del sys.modules[module_path]
for module_path in loaded:
del sys.modules[module_path]
# Reset disabled plugins (if any)
for schema in self._disabled:
self._schema_map[schema].enabled = True
self._disabled.clear()
# Reset disabled plugins (if any)
for schema in self._disabled:
self._schema_map[schema].enabled = True
self._disabled.clear()
# Reset our variables
self._module_map = None if not disable_native else {}
self._schema_map = {}
self._custom_module_map = {}
# Reset our variables
self._schema_map = {}
self._custom_module_map = {}
if disable_native:
self._module_map = {}
# Reset our path cache
self._paths_previously_scanned = set()
else:
self._module_map = None
self._loaded = set()
# Reset our path cache
self._paths_previously_scanned = set()
def load_modules(self, path=None, name=None):
def load_modules(self, path=None, name=None, force=False):
"""
Load our modules into memory
"""
@ -145,102 +158,120 @@ class PluginManager(metaclass=Singleton):
module_name_prefix = self.module_name_prefix if name is None else name
module_path = self.module_path if path is None else path
if not self:
# Initialize our maps
self._module_map = {}
self._schema_map = {}
self._custom_module_map = {}
with self._lock:
if not force and module_path in self._loaded:
# We're done
return
# Used for the detection of additional Notify Services objects
# The .py extension is optional as we support loading directories too
module_re = re.compile(
r'^(?P<name>' + self.fname_prefix + r'[a-z0-9]+)(\.py)?$', re.I)
t_start = time.time()
for f in os.listdir(module_path):
tl_start = time.time()
match = module_re.match(f)
if not match:
# keep going
continue
# Our base reference
module_count = len(self._module_map) if self._module_map else 0
schema_count = len(self._schema_map) if self._schema_map else 0
elif match.group('name') == f'{self.fname_prefix}Base':
# keep going
continue
if not self:
# Initialize our maps
self._module_map = {}
self._schema_map = {}
self._custom_module_map = {}
# Store our notification/plugin name:
module_name = match.group('name')
module_pyname = '{}.{}'.format(module_name_prefix, module_name)
# Used for the detection of additional Notify Services objects
# The .py extension is optional as we support loading directories
# too
module_re = re.compile(
r'^(?P<name>' + self.fname_prefix + r'[a-z0-9]+)(\.py)?$',
re.I)
if module_name in self._module_map:
logger.warning(
"%s(s) (%s) already loaded; ignoring %s",
self.name, module_name, os.path.join(module_path, f))
continue
t_start = time.time()
for f in os.listdir(module_path):
tl_start = time.time()
match = module_re.match(f)
if not match:
# keep going
continue
try:
module = __import__(
module_pyname,
globals(), locals(),
fromlist=[module_name])
except ImportError:
# No problem, we can try again another way...
module = import_module(
os.path.join(module_path, f), module_pyname)
if not module:
# logging found in import_module and not needed here
elif match.group('name') == f'{self.fname_prefix}Base':
# keep going
continue
if not hasattr(module, module_name):
# Not a library we can load as it doesn't follow the simple
# rule that the class must bear the same name as the
# notification file itself.
logger.trace(
"%s (%s) import failed; no filename/Class "
"match found in %s",
self.name, module_name, os.path.join(module_path, f))
continue
# Store our notification/plugin name:
module_name = match.group('name')
module_pyname = '{}.{}'.format(module_name_prefix, module_name)
# Get our plugin
plugin = getattr(module, module_name)
if not hasattr(plugin, 'app_id'):
# Filter out non-notification modules
logger.trace(
"(%s) import failed; no app_id defined in %s",
self.name, module_name, os.path.join(module_path, f))
continue
if module_name in self._module_map:
logger.warning(
"%s(s) (%s) already loaded; ignoring %s",
self.name, module_name, os.path.join(module_path, f))
continue
# Add our plugin name to our module map
self._module_map[module_name] = {
'plugin': set([plugin]),
'module': module,
'path': '{}.{}'.format(module_name_prefix, module_name),
'native': True,
}
try:
module = __import__(
module_pyname,
globals(), locals(),
fromlist=[module_name])
except ImportError:
# No problem, we can try again another way...
module = import_module(
os.path.join(module_path, f), module_pyname)
if not module:
# logging found in import_module and not needed here
continue
fn = getattr(plugin, 'schemas', None)
schemas = set([]) if not callable(fn) else fn(plugin)
if not hasattr(module, module_name):
# Not a library we can load as it doesn't follow the simple
# rule that the class must bear the same name as the
# notification file itself.
logger.trace(
"%s (%s) import failed; no filename/Class "
"match found in %s",
self.name, module_name, os.path.join(module_path, f))
continue
# map our schema to our plugin
for schema in schemas:
if schema in self._schema_map:
logger.error(
"{} schema ({}) mismatch detected - {} to {}"
.format(self.name, schema, self._schema_map, plugin))
# Get our plugin
plugin = getattr(module, module_name)
if not hasattr(plugin, 'app_id'):
# Filter out non-notification modules
logger.trace(
"(%s) import failed; no app_id defined in %s",
self.name, module_name, os.path.join(module_path, f))
continue
# Assign plugin
self._schema_map[schema] = plugin
# Add our plugin name to our module map
self._module_map[module_name] = {
'plugin': set([plugin]),
'module': module,
'path': '{}.{}'.format(module_name_prefix, module_name),
'native': True,
}
fn = getattr(plugin, 'schemas', None)
schemas = set([]) if not callable(fn) else fn(plugin)
# map our schema to our plugin
for schema in schemas:
if schema in self._schema_map:
logger.error(
"{} schema ({}) mismatch detected - {} to {}"
.format(self.name, schema, self._schema_map,
plugin))
continue
# Assign plugin
self._schema_map[schema] = plugin
logger.trace(
'{} {} loaded in {:.6f}s'.format(
self.name, module_name, (time.time() - tl_start)))
logger.debug(
'{} {}(s) and {} Schema(s) loaded in {:.4f}s'
.format(
self.name, len(self._module_map), len(self._schema_map),
(time.time() - t_start)))
logger.trace(
'{} {} loaded in {:.6f}s'.format(
self.name, module_name, (time.time() - tl_start)))
# Track the directory loaded so we never load it again
self._loaded.add(module_path)
logger.debug(
'{} {}(s) and {} Schema(s) loaded in {:.4f}s'
.format(
self.name,
len(self._module_map) - module_count,
len(self._schema_map) - schema_count,
(time.time() - t_start)))
def module_detection(self, paths, cache=True):
"""
@ -334,67 +365,69 @@ class PluginManager(metaclass=Singleton):
# end of _import_module()
return
for _path in paths:
path = os.path.abspath(os.path.expanduser(_path))
if (cache and path in self._paths_previously_scanned) \
or not os.path.exists(path):
# We're done as we've already scanned this
continue
with self._lock:
for _path in paths:
path = os.path.abspath(os.path.expanduser(_path))
if (cache and path in self._paths_previously_scanned) \
or not os.path.exists(path):
# We're done as we've already scanned this
continue
# Store our path as a way of hashing it has been handled
self._paths_previously_scanned.add(path)
# Store our path as a way of hashing it has been handled
self._paths_previously_scanned.add(path)
if os.path.isdir(path) and not \
os.path.isfile(os.path.join(path, '__init__.py')):
if os.path.isdir(path) and not \
os.path.isfile(os.path.join(path, '__init__.py')):
logger.debug('Scanning for custom plugins in: %s', path)
for entry in os.listdir(path):
re_match = module_re.match(entry)
if not re_match:
# keep going
logger.trace('Plugin Scan: Ignoring %s', entry)
continue
logger.debug('Scanning for custom plugins in: %s', path)
for entry in os.listdir(path):
re_match = module_re.match(entry)
if not re_match:
# keep going
logger.trace('Plugin Scan: Ignoring %s', entry)
continue
new_path = os.path.join(path, entry)
if os.path.isdir(new_path):
# Update our path
new_path = os.path.join(path, entry, '__init__.py')
if not os.path.isfile(new_path):
logger.trace(
'Plugin Scan: Ignoring %s',
os.path.join(path, entry))
new_path = os.path.join(path, entry)
if os.path.isdir(new_path):
# Update our path
new_path = os.path.join(path, entry, '__init__.py')
if not os.path.isfile(new_path):
logger.trace(
'Plugin Scan: Ignoring %s',
os.path.join(path, entry))
continue
if not cache or \
(cache and new_path not in
self._paths_previously_scanned):
# Load our module
_import_module(new_path)
# Add our subdir path
self._paths_previously_scanned.add(new_path)
else:
if os.path.isdir(path):
# This logic is safe to apply because we already
# validated the directories state above; update our
# path
path = os.path.join(path, '__init__.py')
if cache and path in self._paths_previously_scanned:
continue
if not cache or \
(cache and
new_path not in self._paths_previously_scanned):
# Load our module
_import_module(new_path)
self._paths_previously_scanned.add(path)
# Add our subdir path
self._paths_previously_scanned.add(new_path)
else:
if os.path.isdir(path):
# This logic is safe to apply because we already validated
# the directories state above; update our path
path = os.path.join(path, '__init__.py')
if cache and path in self._paths_previously_scanned:
# directly load as is
re_match = module_re.match(os.path.basename(path))
# must be a match and must have a .py extension
if not re_match or not re_match.group(1):
# keep going
logger.trace('Plugin Scan: Ignoring %s', path)
continue
self._paths_previously_scanned.add(path)
# directly load as is
re_match = module_re.match(os.path.basename(path))
# must be a match and must have a .py extension
if not re_match or not re_match.group(1):
# keep going
logger.trace('Plugin Scan: Ignoring %s', path)
continue
# Load our module
_import_module(path)
# Load our module
_import_module(path)
return None
return None
def add(self, plugin, schemas=None, url=None, send_func=None):
"""
@ -714,4 +747,4 @@ class PluginManager(metaclass=Singleton):
"""
Determines if object has loaded or not
"""
return True if self._module_map is not None else False
return True if self._loaded and self._module_map is not None else False

@ -295,6 +295,21 @@ EMAIL_TEMPLATES = (
},
),
# Comcast.net
(
'Comcast.net',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>(comcast)\.net)$', re.I),
{
'port': 465,
'smtp_host': 'smtp.comcast.net',
'secure': True,
'secure_mode': SecureMailMode.SSL,
'login_type': (WebBaseLogin.EMAIL, )
},
),
# Catch All
(
'Custom',
@ -481,34 +496,6 @@ class NotifyEmail(NotifyBase):
# addresses from the URL provided
self.from_addr = [False, '']
if self.user and self.host:
# Prepare the bases of our email
self.from_addr = [self.app_id, '{}@{}'.format(
re.split(r'[\s@]+', self.user)[0],
self.host,
)]
if from_addr:
result = is_email(from_addr)
if result:
self.from_addr = (
result['name'] if result['name'] else False,
result['full_email'])
else:
self.from_addr[0] = from_addr
result = is_email(self.from_addr[1])
if not result:
# Parse Source domain based on from_addr
msg = 'Invalid ~From~ email specified: {}'.format(
'{} <{}>'.format(self.from_addr[0], self.from_addr[1])
if self.from_addr[0] else '{}'.format(self.from_addr[1]))
self.logger.warning(msg)
raise TypeError(msg)
# Store our lookup
self.names[self.from_addr[1]] = self.from_addr[0]
# Now detect the SMTP Server
self.smtp_host = \
smtp_host if isinstance(smtp_host, str) else ''
@ -528,25 +515,6 @@ class NotifyEmail(NotifyBase):
self.logger.warning(msg)
raise TypeError(msg)
if targets:
# Validate recipients (to:) and drop bad ones:
for recipient in parse_emails(targets):
result = is_email(recipient)
if result:
self.targets.append(
(result['name'] if result['name'] else False,
result['full_email']))
continue
self.logger.warning(
'Dropped invalid To email '
'({}) specified.'.format(recipient),
)
else:
# If our target email list is empty we want to add ourselves to it
self.targets.append((False, self.from_addr[1]))
# Validate recipients (cc:) and drop bad ones:
for recipient in parse_emails(cc):
email = is_email(recipient)
@ -598,6 +566,54 @@ class NotifyEmail(NotifyBase):
# Apply any defaults based on certain known configurations
self.NotifyEmailDefaults(secure_mode=secure_mode, **kwargs)
if self.user and self.host:
# Prepare the bases of our email
self.from_addr = [self.app_id, '{}@{}'.format(
re.split(r'[\s@]+', self.user)[0],
self.host,
)]
if from_addr:
result = is_email(from_addr)
if result:
self.from_addr = (
result['name'] if result['name'] else False,
result['full_email'])
else:
# Only update the string but use the already detected info
self.from_addr[0] = from_addr
result = is_email(self.from_addr[1])
if not result:
# Parse Source domain based on from_addr
msg = 'Invalid ~From~ email specified: {}'.format(
'{} <{}>'.format(self.from_addr[0], self.from_addr[1])
if self.from_addr[0] else '{}'.format(self.from_addr[1]))
self.logger.warning(msg)
raise TypeError(msg)
# Store our lookup
self.names[self.from_addr[1]] = self.from_addr[0]
if targets:
# Validate recipients (to:) and drop bad ones:
for recipient in parse_emails(targets):
result = is_email(recipient)
if result:
self.targets.append(
(result['name'] if result['name'] else False,
result['full_email']))
continue
self.logger.warning(
'Dropped invalid To email '
'({}) specified.'.format(recipient),
)
else:
# If our target email list is empty we want to add ourselves to it
self.targets.append((False, self.from_addr[1]))
if not self.secure and self.secure_mode != SecureMailMode.INSECURE:
# Enable Secure mode if not otherwise set
self.secure = True
@ -664,9 +680,7 @@ class NotifyEmail(NotifyBase):
# was specified, then we default to having them all set (which
# basically implies that there are no restrictions and use use
# whatever was specified)
login_type = EMAIL_TEMPLATES[i][2]\
.get('login_type', [])
login_type = EMAIL_TEMPLATES[i][2].get('login_type', [])
if login_type:
# only apply additional logic to our user if a login_type
# was specified.
@ -676,6 +690,10 @@ class NotifyEmail(NotifyBase):
# not supported; switch it to user id
self.user = match.group('id')
else:
# Enforce our host information
self.host = self.user.split('@')[1]
elif WebBaseLogin.USERID not in login_type:
# user specified but login type
# not supported; switch it to email

@ -98,6 +98,7 @@ class NotifyMacOSX(NotifyBase):
'/usr/local/bin/terminal-notifier',
'/usr/bin/terminal-notifier',
'/bin/terminal-notifier',
'/opt/local/bin/terminal-notifier',
)
# Define object templates

@ -1,372 +0,0 @@
# -*- coding: utf-8 -*-
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
#
# 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.
# Create an incoming webhook; the website will provide you with something like:
# http://localhost:8065/hooks/yobjmukpaw3r3urc5h6i369yima
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^
# |-- this is the webhook --|
#
# You can effectively turn the url above to read this:
# mmost://localhost:8065/yobjmukpaw3r3urc5h6i369yima
# - swap http with mmost
# - drop /hooks/ reference
import requests
from json import dumps
from .NotifyBase import NotifyBase
from ..common import NotifyImageSize
from ..common import NotifyType
from ..utils import parse_bool
from ..utils import parse_list
from ..utils import validate_regex
from ..AppriseLocale import gettext_lazy as _
# Some Reference Locations:
# - https://docs.mattermost.com/developer/webhooks-incoming.html
# - https://docs.mattermost.com/administration/config-settings.html
class NotifyMattermost(NotifyBase):
"""
A wrapper for Mattermost Notifications
"""
# The default descriptive name associated with the Notification
service_name = 'Mattermost'
# The services URL
service_url = 'https://mattermost.com/'
# The default protocol
protocol = 'mmost'
# The default secure protocol
secure_protocol = 'mmosts'
# A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_mattermost'
# The default Mattermost port
default_port = 8065
# Allows the user to specify the NotifyImageSize object
image_size = NotifyImageSize.XY_72
# The maximum allowable characters allowed in the body per message
body_maxlen = 4000
# Mattermost does not have a title
title_maxlen = 0
# Define object templates
templates = (
'{schema}://{host}/{token}',
'{schema}://{host}:{port}/{token}',
'{schema}://{host}/{fullpath}/{token}',
'{schema}://{host}:{port}/{fullpath}/{token}',
'{schema}://{botname}@{host}/{token}',
'{schema}://{botname}@{host}:{port}/{token}',
'{schema}://{botname}@{host}/{fullpath}/{token}',
'{schema}://{botname}@{host}:{port}/{fullpath}/{token}',
)
# Define our template tokens
template_tokens = dict(NotifyBase.template_tokens, **{
'host': {
'name': _('Hostname'),
'type': 'string',
'required': True,
},
'token': {
'name': _('Webhook Token'),
'type': 'string',
'private': True,
'required': True,
},
'fullpath': {
'name': _('Path'),
'type': 'string',
},
'botname': {
'name': _('Bot Name'),
'type': 'string',
'map_to': 'user',
},
'port': {
'name': _('Port'),
'type': 'int',
'min': 1,
'max': 65535,
},
})
# Define our template arguments
template_args = dict(NotifyBase.template_args, **{
'channels': {
'name': _('Channels'),
'type': 'list:string',
},
'image': {
'name': _('Include Image'),
'type': 'bool',
'default': True,
'map_to': 'include_image',
},
'to': {
'alias_of': 'channels',
},
})
def __init__(self, token, fullpath=None, channels=None,
include_image=False, **kwargs):
"""
Initialize Mattermost Object
"""
super().__init__(**kwargs)
if self.secure:
self.schema = 'https'
else:
self.schema = 'http'
# our full path
self.fullpath = '' if not isinstance(
fullpath, str) else fullpath.strip()
# Authorization Token (associated with project)
self.token = validate_regex(token)
if not self.token:
msg = 'An invalid Mattermost Authorization Token ' \
'({}) was specified.'.format(token)
self.logger.warning(msg)
raise TypeError(msg)
# Optional Channels (strip off any channel prefix entries if present)
self.channels = [x.lstrip('#') for x in parse_list(channels)]
if not self.port:
self.port = self.default_port
# Place a thumbnail image inline with the message body
self.include_image = include_image
return
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
"""
Perform Mattermost Notification
"""
# Create a copy of our channels, otherwise place a dummy entry
channels = list(self.channels) if self.channels else [None, ]
headers = {
'User-Agent': self.app_id,
'Content-Type': 'application/json'
}
# prepare JSON Object
payload = {
'text': body,
'icon_url': None,
}
# Acquire our image url if configured to do so
image_url = None if not self.include_image \
else self.image_url(notify_type)
if image_url:
# Set our image configuration if told to do so
payload['icon_url'] = image_url
# Set our user
payload['username'] = self.user if self.user else self.app_id
# For error tracking
has_error = False
while len(channels):
# Pop a channel off of the list
channel = channels.pop(0)
if channel:
payload['channel'] = channel
url = '{}://{}:{}{}/hooks/{}'.format(
self.schema, self.host, self.port, self.fullpath,
self.token)
self.logger.debug('Mattermost POST URL: %s (cert_verify=%r)' % (
url, self.verify_certificate,
))
self.logger.debug('Mattermost Payload: %s' % str(payload))
# Always call throttle before any remote server i/o is made
self.throttle()
try:
r = requests.post(
url,
data=dumps(payload),
headers=headers,
verify=self.verify_certificate,
timeout=self.request_timeout,
)
if r.status_code != requests.codes.ok:
# We had a problem
status_str = \
NotifyMattermost.http_response_code_lookup(
r.status_code)
self.logger.warning(
'Failed to send Mattermost notification{}: '
'{}{}error={}.'.format(
'' if not channel
else ' to channel {}'.format(channel),
status_str,
', ' if status_str else '',
r.status_code))
self.logger.debug(
'Response Details:\r\n{}'.format(r.content))
# Flag our error
has_error = True
continue
else:
self.logger.info(
'Sent Mattermost notification{}.'.format(
'' if not channel
else ' to channel {}'.format(channel)))
except requests.RequestException as e:
self.logger.warning(
'A Connection error occurred sending Mattermost '
'notification{}.'.format(
'' if not channel
else ' to channel {}'.format(channel)))
self.logger.debug('Socket Exception: %s' % str(e))
# Flag our error
has_error = True
continue
# Return our overall status
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 = {
'image': 'yes' if self.include_image else 'no',
}
# Extend our parameters
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
if self.channels:
# historically the value only accepted one channel and is
# therefore identified as 'channel'. Channels have always been
# optional, so that is why this setting is nested in an if block
params['channel'] = ','.join(
[NotifyMattermost.quote(x, safe='') for x in self.channels])
default_port = 443 if self.secure else self.default_port
default_schema = self.secure_protocol if self.secure else self.protocol
# Determine if there is a botname present
botname = ''
if self.user:
botname = '{botname}@'.format(
botname=NotifyMattermost.quote(self.user, safe=''),
)
return \
'{schema}://{botname}{hostname}{port}{fullpath}{token}' \
'/?{params}'.format(
schema=default_schema,
botname=botname,
# never encode hostname since we're expecting it to be a valid
# one
hostname=self.host,
port='' if not self.port or self.port == default_port
else ':{}'.format(self.port),
fullpath='/' if not self.fullpath else '{}/'.format(
NotifyMattermost.quote(self.fullpath, safe='/')),
token=self.pprint(self.token, privacy, safe=''),
params=NotifyMattermost.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)
if not results:
# We're done early as we couldn't load the results
return results
# Acquire our tokens; the last one will always be our token
# all entries before it will be our path
tokens = NotifyMattermost.split_path(results['fullpath'])
results['token'] = None if not tokens else tokens.pop()
# Store our path
results['fullpath'] = '' if not tokens \
else '/{}'.format('/'.join(tokens))
# Define our optional list of channels to notify
results['channels'] = list()
# Support both 'to' (for yaml configuration) and channel=
if 'to' in results['qsd'] and len(results['qsd']['to']):
# Allow the user to specify the channel to post to
results['channels'].append(
NotifyMattermost.parse_list(results['qsd']['to']))
if 'channel' in results['qsd'] and len(results['qsd']['channel']):
# Allow the user to specify the channel to post to
results['channels'].append(
NotifyMattermost.parse_list(results['qsd']['channel']))
# Image manipulation
results['include_image'] = \
parse_bool(results['qsd'].get('image', False))
return results

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:

@ -42,6 +42,7 @@ from json import dumps
from os.path import basename
from .NotifyBase import NotifyBase
from ..common import NotifyFormat
from ..common import NotifyType
from ..common import NotifyImageSize
from ..AppriseLocale import gettext_lazy as _
@ -515,6 +516,10 @@ class NotifyNtfy(NotifyBase):
if body:
virt_payload['message'] = body
if self.notify_format == NotifyFormat.MARKDOWN:
# Support Markdown
headers['X-Markdown'] = 'yes'
if self.priority != NtfyPriority.NORMAL:
headers['X-Priority'] = self.priority

@ -0,0 +1,437 @@
# -*- coding: utf-8 -*-
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
#
# 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.
# Youll need your own Revolt Bot and a Channel Id for the notifications to
# be sent in since Revolt does not support webhooks yet.
#
# This plugin will simply work using the url of:
# revolt://BOT_TOKEN/CHANNEL_ID
#
# API Documentation:
# - https://api.revolt.chat/swagger/index.html
#
import requests
from json import dumps, loads
from datetime import timedelta
from datetime import datetime
from datetime import timezone
from .NotifyBase import NotifyBase
from ..common import NotifyImageSize
from ..common import NotifyFormat
from ..common import NotifyType
from ..utils import validate_regex
from ..utils import parse_list
from ..AppriseLocale import gettext_lazy as _
class NotifyRevolt(NotifyBase):
"""
A wrapper for Revolt Notifications
"""
# The default descriptive name associated with the Notification
service_name = 'Revolt'
# The services URL
service_url = 'https://revolt.chat/'
# The default secure protocol
secure_protocol = 'revolt'
# A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_revolt'
# Revolt Channel Message
notify_url = 'https://api.revolt.chat/'
# Revolt supports attachments but doesn't support it here (for now)
attachment_support = False
# Allows the user to specify the NotifyImageSize object
image_size = NotifyImageSize.XY_256
# Revolt is kind enough to return how many more requests we're allowed to
# continue to make within it's header response as:
# X-RateLimit-Reset: The epoc time (in seconds) we can expect our
# rate-limit to be reset.
# X-RateLimit-Remaining: an integer identifying how many requests we're
# still allow to make.
request_rate_per_sec = 3
# Safety net
clock_skew = timedelta(seconds=2)
# The maximum allowable characters allowed in the body per message
body_maxlen = 2000
# Title Maximum Length
title_maxlen = 100
# Define object templates
templates = (
'{schema}://{bot_token}/{targets}',
)
# Defile out template tokens
template_tokens = dict(NotifyBase.template_tokens, **{
'bot_token': {
'name': _('Bot Token'),
'type': 'string',
'private': True,
'required': True,
},
'target_channel': {
'name': _('Channel ID'),
'type': 'string',
'map_to': 'targets',
'regex': (r'^[a-z0-9_-]+$', 'i'),
'private': True,
'required': True,
},
'targets': {
'name': _('Targets'),
'type': 'list:string',
},
})
# Define our template arguments
template_args = dict(NotifyBase.template_args, **{
'channel': {
'alias_of': 'targets',
},
'bot_token': {
'alias_of': 'bot_token',
},
'icon_url': {
'name': _('Icon URL'),
'type': 'string'
},
'url': {
'name': _('Embed URL'),
'type': 'string',
'map_to': 'link',
},
'to': {
'alias_of': 'targets',
},
})
def __init__(self, bot_token, targets, icon_url=None, link=None,
**kwargs):
super().__init__(**kwargs)
# Bot Token
self.bot_token = validate_regex(bot_token)
if not self.bot_token:
msg = 'An invalid Revolt Bot Token ' \
'({}) was specified.'.format(bot_token)
self.logger.warning(msg)
raise TypeError(msg)
# Parse our Channel IDs
self.targets = []
for target in parse_list(targets):
results = validate_regex(
target, *self.template_tokens['target_channel']['regex'])
if not results:
self.logger.warning(
'Dropped invalid Revolt channel ({}) specified.'
.format(target),
)
continue
# Add our target
self.targets.append(target)
# Image for Embed
self.icon_url = icon_url
# Url for embed title
self.link = link
# For Tracking Purposes
self.ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None)
# Default to 1.0
self.ratelimit_remaining = 1.0
return
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
"""
Perform Revolt Notification
"""
if len(self.targets) == 0:
self.logger.warning('There were not Revolt channels to notify.')
return False
payload = {}
# Acquire image_url
image_url = self.icon_url \
if self.icon_url else self.image_url(notify_type)
if self.notify_format == NotifyFormat.MARKDOWN:
payload['embeds'] = [{
'title': None if not title else title[0:self.title_maxlen],
'description': body,
# Our color associated with our notification
'colour': self.color(notify_type),
'replies': None
}]
if image_url:
payload['embeds'][0]['icon_url'] = image_url
if self.link:
payload['embeds'][0]['url'] = self.link
else:
payload['content'] = \
body if not title else "{}\n{}".format(title, body)
has_error = False
channel_ids = list(self.targets)
for channel_id in channel_ids:
postokay, response = self._send(payload, channel_id)
if not postokay:
# Failed to send message
has_error = True
return not has_error
def _send(self, payload, channel_id, retries=1, **kwargs):
"""
Wrapper to the requests (post) object
"""
headers = {
'User-Agent': self.app_id,
'X-Bot-Token': self.bot_token,
'Content-Type': 'application/json; charset=utf-8',
'Accept': 'application/json; charset=utf-8',
}
notify_url = '{0}channels/{1}/messages'.format(
self.notify_url,
channel_id
)
self.logger.debug('Revolt POST URL: %s (cert_verify=%r)' % (
notify_url, self.verify_certificate
))
self.logger.debug('Revolt Payload: %s' % str(payload))
# By default set wait to None
wait = None
now = datetime.now(timezone.utc).replace(tzinfo=None)
if self.ratelimit_remaining <= 0.0:
# Determine how long we should wait for or if we should wait at
# all. This isn't fool-proof because we can't be sure the client
# time (calling this script) is completely synced up with the
# Discord server. One would hope we're on NTP and our clocks are
# the same allowing this to role smoothly:
if now < self.ratelimit_reset:
# We need to throttle for the difference in seconds
wait = abs(
(self.ratelimit_reset - now + self.clock_skew)
.total_seconds())
# Default content response object
content = {}
# Always call throttle before any remote server i/o is made;
self.throttle(wait=wait)
try:
r = requests.post(
notify_url,
data=dumps(payload),
headers=headers,
verify=self.verify_certificate,
timeout=self.request_timeout
)
try:
content = loads(r.content)
except (AttributeError, TypeError, ValueError):
# ValueError = r.content is Unparsable
# TypeError = r.content is None
# AttributeError = r is None
content = {}
# Handle rate limiting (if specified)
try:
# Store our rate limiting (if provided)
self.ratelimit_remaining = \
int(r.headers.get('X-RateLimit-Remaining'))
self.ratelimit_reset = \
now + timedelta(seconds=(int(
r.headers.get('X-RateLimit-Reset-After')) / 1000))
except (TypeError, ValueError):
# This is returned if we could not retrieve this
# information gracefully accept this state and move on
pass
if r.status_code not in (
requests.codes.ok, requests.codes.no_content):
# Some details to debug by
self.logger.debug('Response Details:\r\n{}'.format(
content if content else r.content))
# We had a problem
status_str = \
NotifyBase.http_response_code_lookup(r.status_code)
self.logger.warning(
'Revolt request limit reached; '
'instructed to throttle for %.3fs',
abs((self.ratelimit_reset - now + self.clock_skew)
.total_seconds()))
if r.status_code == requests.codes.too_many_requests \
and retries > 0:
# Try again
return self._send(
payload=payload, channel_id=channel_id,
retries=retries - 1, **kwargs)
self.logger.warning(
'Failed to send to Revolt notification: '
'{}{}error={}.'.format(
status_str,
', ' if status_str else '',
r.status_code))
# Return; we're done
return (False, content)
else:
self.logger.info('Sent Revolt notification.')
except requests.RequestException as e:
self.logger.warning(
'A Connection error occurred posting to Revolt.')
self.logger.debug('Socket Exception: %s' % str(e))
return (False, content)
return (True, content)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
"""
# Define any URL parameters
params = {}
if self.icon_url:
params['icon_url'] = self.icon_url
if self.link:
params['url'] = self.link
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
return '{schema}://{bot_token}/{targets}/?{params}'.format(
schema=self.secure_protocol,
bot_token=self.pprint(self.bot_token, privacy, safe=''),
targets='/'.join(
[self.pprint(x, privacy, safe='') for x in self.targets]),
params=NotifyRevolt.urlencode(params),
)
def __len__(self):
"""
Returns the number of targets associated with this notification
"""
return 1 if not self.targets else len(self.targets)
@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
# Store our bot token
bot_token = NotifyRevolt.unquote(results['host'])
# Now fetch the Channel IDs
targets = NotifyRevolt.split_path(results['fullpath'])
results['bot_token'] = bot_token
results['targets'] = targets
# 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'] += \
NotifyRevolt.parse_list(results['qsd']['to'])
# Support channel id on the URL string (if specified)
if 'channel' in results['qsd']:
results['targets'] += \
NotifyRevolt.parse_list(results['qsd']['channel'])
# Support bot token on the URL string (if specified)
if 'bot_token' in results['qsd']:
results['bot_token'] = \
NotifyRevolt.unquote(results['qsd']['bot_token'])
if 'icon_url' in results['qsd']:
results['icon_url'] = \
NotifyRevolt.unquote(results['qsd']['icon_url'])
if 'url' in results['qsd']:
results['link'] = NotifyRevolt.unquote(results['qsd']['url'])
if 'format' not in results['qsd'] and (
'url' in results or 'icon_url' in results):
# Markdown is implied
results['format'] = NotifyFormat.MARKDOWN
return results

@ -297,7 +297,6 @@ class NotifyTelegram(NotifyBase):
'name': _('Target Chat ID'),
'type': 'string',
'map_to': 'targets',
'map_to': 'targets',
'regex': (r'^((-?[0-9]{1,32})|([a-z_-][a-z0-9_-]+))$', 'i'),
},
'targets': {
@ -916,7 +915,7 @@ class NotifyTelegram(NotifyBase):
"""
Returns the number of targets associated with this notification
"""
return len(self.targets)
return 1 if not self.targets else len(self.targets)
@staticmethod
def parse_url(url):

@ -2,7 +2,7 @@
alembic==1.13.1
aniso8601==9.0.1
argparse==1.4.0
apprise==1.7.2
apprise==1.7.3
apscheduler<=3.10.4
attrs==23.2.0
blinker==1.7.0

Loading…
Cancel
Save