From b42995457d12cda03c1d3407a3b692ee08ab2633 Mon Sep 17 00:00:00 2001 From: FieldofClay <7278759+FieldofClay@users.noreply.github.com> Date: Thu, 2 Nov 2023 17:03:48 +1100 Subject: [PATCH] initial version --- .gitignore | 2 + README.md | 23 + README/alert_actions.conf.spec | 3 + README/saved_searches.conf.spec | 19 + appserver/static/appIcon.png | Bin 0 -> 7118 bytes bin/send_apprise_alert.py | 78 + default/alert_actions.conf | 9 + default/app.conf | 16 + default/data/ui/alerts/apprise_alert.html | 41 + default/setup.xml | 9 + lib/apprise/Apprise.py | 870 +++++++++ lib/apprise/Apprise.pyi | 62 + lib/apprise/AppriseAsset.py | 346 ++++ lib/apprise/AppriseAsset.pyi | 34 + lib/apprise/AppriseAttachment.py | 324 ++++ lib/apprise/AppriseAttachment.pyi | 37 + lib/apprise/AppriseConfig.py | 451 +++++ lib/apprise/AppriseConfig.pyi | 48 + lib/apprise/AppriseLocale.py | 286 +++ lib/apprise/URLBase.py | 796 ++++++++ lib/apprise/URLBase.pyi | 16 + lib/apprise/__init__.py | 91 + lib/apprise/assets/NotifyXML-1.0.xsd | 23 + lib/apprise/assets/NotifyXML-1.1.xsd | 40 + .../default/apprise-failure-128x128.ico | Bin 0 -> 67646 bytes .../default/apprise-failure-128x128.png | Bin 0 -> 16135 bytes .../default/apprise-failure-256x256.png | Bin 0 -> 41931 bytes .../themes/default/apprise-failure-32x32.png | Bin 0 -> 2437 bytes .../themes/default/apprise-failure-72x72.png | Bin 0 -> 7600 bytes .../themes/default/apprise-info-128x128.ico | Bin 0 -> 67646 bytes .../themes/default/apprise-info-128x128.png | Bin 0 -> 16671 bytes .../themes/default/apprise-info-256x256.png | Bin 0 -> 43331 bytes .../themes/default/apprise-info-32x32.png | Bin 0 -> 2485 bytes .../themes/default/apprise-info-72x72.png | Bin 0 -> 7875 bytes .../assets/themes/default/apprise-logo.png | Bin 0 -> 160907 bytes .../default/apprise-success-128x128.ico | Bin 0 -> 67646 bytes .../default/apprise-success-128x128.png | Bin 0 -> 17446 bytes .../default/apprise-success-256x256.png | Bin 0 -> 48729 bytes .../themes/default/apprise-success-32x32.png | Bin 0 -> 2471 bytes .../themes/default/apprise-success-72x72.png | Bin 0 -> 7858 bytes .../default/apprise-warning-128x128.ico | Bin 0 -> 67646 bytes .../default/apprise-warning-128x128.png | Bin 0 -> 16784 bytes .../default/apprise-warning-256x256.png | Bin 0 -> 43708 bytes .../themes/default/apprise-warning-32x32.png | Bin 0 -> 2472 bytes .../themes/default/apprise-warning-72x72.png | Bin 0 -> 7913 bytes lib/apprise/attachment/AttachBase.py | 377 ++++ lib/apprise/attachment/AttachBase.pyi | 36 + lib/apprise/attachment/AttachFile.py | 141 ++ lib/apprise/attachment/AttachHTTP.py | 337 ++++ lib/apprise/attachment/__init__.py | 118 ++ lib/apprise/cli.py | 520 +++++ lib/apprise/common.py | 240 +++ lib/apprise/common.pyi | 22 + lib/apprise/config/ConfigBase.py | 1391 ++++++++++++++ lib/apprise/config/ConfigBase.pyi | 3 + lib/apprise/config/ConfigFile.py | 177 ++ lib/apprise/config/ConfigHTTP.py | 275 +++ lib/apprise/config/ConfigMemory.py | 85 + lib/apprise/config/__init__.py | 109 ++ lib/apprise/conversion.py | 198 ++ lib/apprise/decorators/CustomNotifyPlugin.py | 231 +++ lib/apprise/decorators/__init__.py | 34 + lib/apprise/decorators/notify.py | 127 ++ lib/apprise/i18n/__init__.py | 0 lib/apprise/i18n/en/LC_MESSAGES/apprise.mo | Bin 0 -> 3959 bytes lib/apprise/logger.py | 201 ++ lib/apprise/plugins/NotifyAppriseAPI.py | 483 +++++ lib/apprise/plugins/NotifyBark.py | 517 +++++ lib/apprise/plugins/NotifyBase.py | 569 ++++++ lib/apprise/plugins/NotifyBase.pyi | 1 + lib/apprise/plugins/NotifyBoxcar.py | 399 ++++ lib/apprise/plugins/NotifyBulkSMS.py | 480 +++++ lib/apprise/plugins/NotifyBurstSMS.py | 460 +++++ lib/apprise/plugins/NotifyClickSend.py | 331 ++++ lib/apprise/plugins/NotifyD7Networks.py | 429 +++++ lib/apprise/plugins/NotifyDBus.py | 451 +++++ lib/apprise/plugins/NotifyDapnet.py | 405 ++++ lib/apprise/plugins/NotifyDingTalk.py | 358 ++++ lib/apprise/plugins/NotifyDiscord.py | 709 +++++++ lib/apprise/plugins/NotifyEmail.py | 1090 +++++++++++ lib/apprise/plugins/NotifyEmby.py | 724 +++++++ lib/apprise/plugins/NotifyEnigma2.py | 351 ++++ lib/apprise/plugins/NotifyFCM/__init__.py | 628 ++++++ lib/apprise/plugins/NotifyFCM/color.py | 121 ++ lib/apprise/plugins/NotifyFCM/common.py | 46 + lib/apprise/plugins/NotifyFCM/oauth.py | 319 +++ lib/apprise/plugins/NotifyFCM/priority.py | 251 +++ lib/apprise/plugins/NotifyFaast.py | 215 +++ lib/apprise/plugins/NotifyFlock.py | 388 ++++ lib/apprise/plugins/NotifyForm.py | 524 +++++ lib/apprise/plugins/NotifyGnome.py | 278 +++ lib/apprise/plugins/NotifyGoogleChat.py | 365 ++++ lib/apprise/plugins/NotifyGotify.py | 331 ++++ lib/apprise/plugins/NotifyGrowl.py | 432 +++++ lib/apprise/plugins/NotifyGuilded.py | 94 + lib/apprise/plugins/NotifyHomeAssistant.py | 313 +++ lib/apprise/plugins/NotifyIFTTT.py | 378 ++++ lib/apprise/plugins/NotifyJSON.py | 427 ++++ lib/apprise/plugins/NotifyJoin.py | 420 ++++ lib/apprise/plugins/NotifyKavenegar.py | 362 ++++ lib/apprise/plugins/NotifyKumulos.py | 240 +++ lib/apprise/plugins/NotifyLametric.py | 1004 ++++++++++ lib/apprise/plugins/NotifyLine.py | 320 +++ lib/apprise/plugins/NotifyMQTT.py | 567 ++++++ lib/apprise/plugins/NotifyMSG91.py | 384 ++++ lib/apprise/plugins/NotifyMSTeams.py | 622 ++++++ lib/apprise/plugins/NotifyMacOSX.py | 257 +++ lib/apprise/plugins/NotifyMailgun.py | 723 +++++++ lib/apprise/plugins/NotifyMastodon.py | 991 ++++++++++ lib/apprise/plugins/NotifyMatrix.py | 1455 ++++++++++++++ lib/apprise/plugins/NotifyMattermost.py | 372 ++++ lib/apprise/plugins/NotifyMessageBird.py | 356 ++++ lib/apprise/plugins/NotifyMisskey.py | 307 +++ lib/apprise/plugins/NotifyNextcloud.py | 374 ++++ lib/apprise/plugins/NotifyNextcloudTalk.py | 329 ++++ lib/apprise/plugins/NotifyNotica.py | 396 ++++ lib/apprise/plugins/NotifyNotifiarr.py | 472 +++++ lib/apprise/plugins/NotifyNotifico.py | 382 ++++ lib/apprise/plugins/NotifyNtfy.py | 845 ++++++++ lib/apprise/plugins/NotifyOffice365.py | 718 +++++++ lib/apprise/plugins/NotifyOneSignal.py | 519 +++++ lib/apprise/plugins/NotifyOpsgenie.py | 611 ++++++ lib/apprise/plugins/NotifyPagerDuty.py | 538 ++++++ lib/apprise/plugins/NotifyPagerTree.py | 420 ++++ lib/apprise/plugins/NotifyParsePlatform.py | 320 +++ lib/apprise/plugins/NotifyPopcornNotify.py | 311 +++ lib/apprise/plugins/NotifyProwl.py | 304 +++ lib/apprise/plugins/NotifyPushBullet.py | 440 +++++ lib/apprise/plugins/NotifyPushDeer.py | 218 +++ lib/apprise/plugins/NotifyPushMe.py | 221 +++ lib/apprise/plugins/NotifyPushSafer.py | 839 ++++++++ lib/apprise/plugins/NotifyPushed.py | 372 ++++ lib/apprise/plugins/NotifyPushjet.py | 268 +++ lib/apprise/plugins/NotifyPushover.py | 640 ++++++ lib/apprise/plugins/NotifyPushy.py | 384 ++++ lib/apprise/plugins/NotifyRSyslog.py | 376 ++++ lib/apprise/plugins/NotifyReddit.py | 762 ++++++++ lib/apprise/plugins/NotifyRocketChat.py | 732 +++++++ lib/apprise/plugins/NotifyRyver.py | 358 ++++ lib/apprise/plugins/NotifySES.py | 942 +++++++++ lib/apprise/plugins/NotifySMSEagle.py | 689 +++++++ lib/apprise/plugins/NotifySMTP2Go.py | 581 ++++++ lib/apprise/plugins/NotifySNS.py | 697 +++++++ lib/apprise/plugins/NotifySendGrid.py | 475 +++++ lib/apprise/plugins/NotifyServerChan.py | 176 ++ lib/apprise/plugins/NotifySignalAPI.py | 491 +++++ lib/apprise/plugins/NotifySimplePush.py | 335 ++++ lib/apprise/plugins/NotifySinch.py | 471 +++++ lib/apprise/plugins/NotifySlack.py | 1123 +++++++++++ lib/apprise/plugins/NotifySparkPost.py | 800 ++++++++ lib/apprise/plugins/NotifySpontit.py | 386 ++++ lib/apprise/plugins/NotifyStreamlabs.py | 469 +++++ lib/apprise/plugins/NotifySyslog.py | 323 ++++ lib/apprise/plugins/NotifyTechulusPush.py | 216 +++ lib/apprise/plugins/NotifyTelegram.py | 992 ++++++++++ lib/apprise/plugins/NotifyTwilio.py | 447 +++++ lib/apprise/plugins/NotifyTwist.py | 844 ++++++++ lib/apprise/plugins/NotifyTwitter.py | 880 +++++++++ lib/apprise/plugins/NotifyVoipms.py | 375 ++++ lib/apprise/plugins/NotifyVonage.py | 396 ++++ lib/apprise/plugins/NotifyWebexTeams.py | 261 +++ lib/apprise/plugins/NotifyWhatsApp.py | 559 ++++++ lib/apprise/plugins/NotifyWindows.py | 259 +++ lib/apprise/plugins/NotifyXBMC.py | 384 ++++ lib/apprise/plugins/NotifyXML.py | 499 +++++ lib/apprise/plugins/NotifyZulip.py | 402 ++++ lib/apprise/plugins/__init__.py | 575 ++++++ lib/apprise/py.typed | 0 lib/apprise/utils.py | 1710 +++++++++++++++++ lib/markdown/__init__.py | 48 + lib/markdown/__main__.py | 151 ++ lib/markdown/__meta__.py | 51 + lib/markdown/blockparser.py | 159 ++ lib/markdown/blockprocessors.py | 636 ++++++ lib/markdown/core.py | 506 +++++ lib/markdown/extensions/__init__.py | 145 ++ lib/markdown/extensions/abbr.py | 105 + lib/markdown/extensions/admonition.py | 179 ++ lib/markdown/extensions/attr_list.py | 179 ++ lib/markdown/extensions/codehilite.py | 338 ++++ lib/markdown/extensions/def_list.py | 119 ++ lib/markdown/extensions/extra.py | 66 + lib/markdown/extensions/fenced_code.py | 182 ++ lib/markdown/extensions/footnotes.py | 416 ++++ lib/markdown/extensions/legacy_attrs.py | 67 + lib/markdown/extensions/legacy_em.py | 52 + lib/markdown/extensions/md_in_html.py | 372 ++++ lib/markdown/extensions/meta.py | 85 + lib/markdown/extensions/nl2br.py | 41 + lib/markdown/extensions/sane_lists.py | 65 + lib/markdown/extensions/smarty.py | 265 +++ lib/markdown/extensions/tables.py | 243 +++ lib/markdown/extensions/toc.py | 408 ++++ lib/markdown/extensions/wikilinks.py | 96 + lib/markdown/htmlparser.py | 330 ++++ lib/markdown/inlinepatterns.py | 990 ++++++++++ lib/markdown/postprocessors.py | 143 ++ lib/markdown/preprocessors.py | 91 + lib/markdown/serializers.py | 193 ++ lib/markdown/test_tools.py | 224 +++ lib/markdown/treeprocessors.py | 465 +++++ lib/markdown/util.py | 388 ++++ lib/yaml/__init__.py | 390 ++++ lib/yaml/composer.py | 139 ++ lib/yaml/constructor.py | 748 +++++++ lib/yaml/cyaml.py | 101 + lib/yaml/dumper.py | 62 + lib/yaml/emitter.py | 1137 +++++++++++ lib/yaml/error.py | 75 + lib/yaml/events.py | 86 + lib/yaml/loader.py | 63 + lib/yaml/nodes.py | 49 + lib/yaml/parser.py | 589 ++++++ lib/yaml/reader.py | 185 ++ lib/yaml/representer.py | 389 ++++ lib/yaml/resolver.py | 227 +++ lib/yaml/scanner.py | 1435 ++++++++++++++ lib/yaml/serializer.py | 111 ++ lib/yaml/tokens.py | 104 + metadata/default.meta | 3 + static/appIcon.png | Bin 0 -> 7118 bytes static/appIcon_2x.png | Bin 0 -> 7875 bytes 222 files changed, 72090 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 README/alert_actions.conf.spec create mode 100644 README/saved_searches.conf.spec create mode 100644 appserver/static/appIcon.png create mode 100644 bin/send_apprise_alert.py create mode 100644 default/alert_actions.conf create mode 100644 default/app.conf create mode 100644 default/data/ui/alerts/apprise_alert.html create mode 100644 default/setup.xml create mode 100644 lib/apprise/Apprise.py create mode 100644 lib/apprise/Apprise.pyi create mode 100644 lib/apprise/AppriseAsset.py create mode 100644 lib/apprise/AppriseAsset.pyi create mode 100644 lib/apprise/AppriseAttachment.py create mode 100644 lib/apprise/AppriseAttachment.pyi create mode 100644 lib/apprise/AppriseConfig.py create mode 100644 lib/apprise/AppriseConfig.pyi create mode 100644 lib/apprise/AppriseLocale.py create mode 100644 lib/apprise/URLBase.py create mode 100644 lib/apprise/URLBase.pyi create mode 100644 lib/apprise/__init__.py create mode 100644 lib/apprise/assets/NotifyXML-1.0.xsd create mode 100644 lib/apprise/assets/NotifyXML-1.1.xsd create mode 100644 lib/apprise/assets/themes/default/apprise-failure-128x128.ico create mode 100644 lib/apprise/assets/themes/default/apprise-failure-128x128.png create mode 100644 lib/apprise/assets/themes/default/apprise-failure-256x256.png create mode 100644 lib/apprise/assets/themes/default/apprise-failure-32x32.png create mode 100644 lib/apprise/assets/themes/default/apprise-failure-72x72.png create mode 100644 lib/apprise/assets/themes/default/apprise-info-128x128.ico create mode 100644 lib/apprise/assets/themes/default/apprise-info-128x128.png create mode 100644 lib/apprise/assets/themes/default/apprise-info-256x256.png create mode 100644 lib/apprise/assets/themes/default/apprise-info-32x32.png create mode 100644 lib/apprise/assets/themes/default/apprise-info-72x72.png create mode 100644 lib/apprise/assets/themes/default/apprise-logo.png create mode 100644 lib/apprise/assets/themes/default/apprise-success-128x128.ico create mode 100644 lib/apprise/assets/themes/default/apprise-success-128x128.png create mode 100644 lib/apprise/assets/themes/default/apprise-success-256x256.png create mode 100644 lib/apprise/assets/themes/default/apprise-success-32x32.png create mode 100644 lib/apprise/assets/themes/default/apprise-success-72x72.png create mode 100644 lib/apprise/assets/themes/default/apprise-warning-128x128.ico create mode 100644 lib/apprise/assets/themes/default/apprise-warning-128x128.png create mode 100644 lib/apprise/assets/themes/default/apprise-warning-256x256.png create mode 100644 lib/apprise/assets/themes/default/apprise-warning-32x32.png create mode 100644 lib/apprise/assets/themes/default/apprise-warning-72x72.png create mode 100644 lib/apprise/attachment/AttachBase.py create mode 100644 lib/apprise/attachment/AttachBase.pyi create mode 100644 lib/apprise/attachment/AttachFile.py create mode 100644 lib/apprise/attachment/AttachHTTP.py create mode 100644 lib/apprise/attachment/__init__.py create mode 100644 lib/apprise/cli.py create mode 100644 lib/apprise/common.py create mode 100644 lib/apprise/common.pyi create mode 100644 lib/apprise/config/ConfigBase.py create mode 100644 lib/apprise/config/ConfigBase.pyi create mode 100644 lib/apprise/config/ConfigFile.py create mode 100644 lib/apprise/config/ConfigHTTP.py create mode 100644 lib/apprise/config/ConfigMemory.py create mode 100644 lib/apprise/config/__init__.py create mode 100644 lib/apprise/conversion.py create mode 100644 lib/apprise/decorators/CustomNotifyPlugin.py create mode 100644 lib/apprise/decorators/__init__.py create mode 100644 lib/apprise/decorators/notify.py create mode 100644 lib/apprise/i18n/__init__.py create mode 100644 lib/apprise/i18n/en/LC_MESSAGES/apprise.mo create mode 100644 lib/apprise/logger.py create mode 100644 lib/apprise/plugins/NotifyAppriseAPI.py create mode 100644 lib/apprise/plugins/NotifyBark.py create mode 100644 lib/apprise/plugins/NotifyBase.py create mode 100644 lib/apprise/plugins/NotifyBase.pyi create mode 100644 lib/apprise/plugins/NotifyBoxcar.py create mode 100644 lib/apprise/plugins/NotifyBulkSMS.py create mode 100644 lib/apprise/plugins/NotifyBurstSMS.py create mode 100644 lib/apprise/plugins/NotifyClickSend.py create mode 100644 lib/apprise/plugins/NotifyD7Networks.py create mode 100644 lib/apprise/plugins/NotifyDBus.py create mode 100644 lib/apprise/plugins/NotifyDapnet.py create mode 100644 lib/apprise/plugins/NotifyDingTalk.py create mode 100644 lib/apprise/plugins/NotifyDiscord.py create mode 100644 lib/apprise/plugins/NotifyEmail.py create mode 100644 lib/apprise/plugins/NotifyEmby.py create mode 100644 lib/apprise/plugins/NotifyEnigma2.py create mode 100644 lib/apprise/plugins/NotifyFCM/__init__.py create mode 100644 lib/apprise/plugins/NotifyFCM/color.py create mode 100644 lib/apprise/plugins/NotifyFCM/common.py create mode 100644 lib/apprise/plugins/NotifyFCM/oauth.py create mode 100644 lib/apprise/plugins/NotifyFCM/priority.py create mode 100644 lib/apprise/plugins/NotifyFaast.py create mode 100644 lib/apprise/plugins/NotifyFlock.py create mode 100644 lib/apprise/plugins/NotifyForm.py create mode 100644 lib/apprise/plugins/NotifyGnome.py create mode 100644 lib/apprise/plugins/NotifyGoogleChat.py create mode 100644 lib/apprise/plugins/NotifyGotify.py create mode 100644 lib/apprise/plugins/NotifyGrowl.py create mode 100644 lib/apprise/plugins/NotifyGuilded.py create mode 100644 lib/apprise/plugins/NotifyHomeAssistant.py create mode 100644 lib/apprise/plugins/NotifyIFTTT.py create mode 100644 lib/apprise/plugins/NotifyJSON.py create mode 100644 lib/apprise/plugins/NotifyJoin.py create mode 100644 lib/apprise/plugins/NotifyKavenegar.py create mode 100644 lib/apprise/plugins/NotifyKumulos.py create mode 100644 lib/apprise/plugins/NotifyLametric.py create mode 100644 lib/apprise/plugins/NotifyLine.py create mode 100644 lib/apprise/plugins/NotifyMQTT.py create mode 100644 lib/apprise/plugins/NotifyMSG91.py create mode 100644 lib/apprise/plugins/NotifyMSTeams.py create mode 100644 lib/apprise/plugins/NotifyMacOSX.py create mode 100644 lib/apprise/plugins/NotifyMailgun.py create mode 100644 lib/apprise/plugins/NotifyMastodon.py create mode 100644 lib/apprise/plugins/NotifyMatrix.py create mode 100644 lib/apprise/plugins/NotifyMattermost.py create mode 100644 lib/apprise/plugins/NotifyMessageBird.py create mode 100644 lib/apprise/plugins/NotifyMisskey.py create mode 100644 lib/apprise/plugins/NotifyNextcloud.py create mode 100644 lib/apprise/plugins/NotifyNextcloudTalk.py create mode 100644 lib/apprise/plugins/NotifyNotica.py create mode 100644 lib/apprise/plugins/NotifyNotifiarr.py create mode 100644 lib/apprise/plugins/NotifyNotifico.py create mode 100644 lib/apprise/plugins/NotifyNtfy.py create mode 100644 lib/apprise/plugins/NotifyOffice365.py create mode 100644 lib/apprise/plugins/NotifyOneSignal.py create mode 100644 lib/apprise/plugins/NotifyOpsgenie.py create mode 100644 lib/apprise/plugins/NotifyPagerDuty.py create mode 100644 lib/apprise/plugins/NotifyPagerTree.py create mode 100644 lib/apprise/plugins/NotifyParsePlatform.py create mode 100644 lib/apprise/plugins/NotifyPopcornNotify.py create mode 100644 lib/apprise/plugins/NotifyProwl.py create mode 100644 lib/apprise/plugins/NotifyPushBullet.py create mode 100644 lib/apprise/plugins/NotifyPushDeer.py create mode 100644 lib/apprise/plugins/NotifyPushMe.py create mode 100644 lib/apprise/plugins/NotifyPushSafer.py create mode 100644 lib/apprise/plugins/NotifyPushed.py create mode 100644 lib/apprise/plugins/NotifyPushjet.py create mode 100644 lib/apprise/plugins/NotifyPushover.py create mode 100644 lib/apprise/plugins/NotifyPushy.py create mode 100644 lib/apprise/plugins/NotifyRSyslog.py create mode 100644 lib/apprise/plugins/NotifyReddit.py create mode 100644 lib/apprise/plugins/NotifyRocketChat.py create mode 100644 lib/apprise/plugins/NotifyRyver.py create mode 100644 lib/apprise/plugins/NotifySES.py create mode 100644 lib/apprise/plugins/NotifySMSEagle.py create mode 100644 lib/apprise/plugins/NotifySMTP2Go.py create mode 100644 lib/apprise/plugins/NotifySNS.py create mode 100644 lib/apprise/plugins/NotifySendGrid.py create mode 100644 lib/apprise/plugins/NotifyServerChan.py create mode 100644 lib/apprise/plugins/NotifySignalAPI.py create mode 100644 lib/apprise/plugins/NotifySimplePush.py create mode 100644 lib/apprise/plugins/NotifySinch.py create mode 100644 lib/apprise/plugins/NotifySlack.py create mode 100644 lib/apprise/plugins/NotifySparkPost.py create mode 100644 lib/apprise/plugins/NotifySpontit.py create mode 100644 lib/apprise/plugins/NotifyStreamlabs.py create mode 100644 lib/apprise/plugins/NotifySyslog.py create mode 100644 lib/apprise/plugins/NotifyTechulusPush.py create mode 100644 lib/apprise/plugins/NotifyTelegram.py create mode 100644 lib/apprise/plugins/NotifyTwilio.py create mode 100644 lib/apprise/plugins/NotifyTwist.py create mode 100644 lib/apprise/plugins/NotifyTwitter.py create mode 100644 lib/apprise/plugins/NotifyVoipms.py create mode 100644 lib/apprise/plugins/NotifyVonage.py create mode 100644 lib/apprise/plugins/NotifyWebexTeams.py create mode 100644 lib/apprise/plugins/NotifyWhatsApp.py create mode 100644 lib/apprise/plugins/NotifyWindows.py create mode 100644 lib/apprise/plugins/NotifyXBMC.py create mode 100644 lib/apprise/plugins/NotifyXML.py create mode 100644 lib/apprise/plugins/NotifyZulip.py create mode 100644 lib/apprise/plugins/__init__.py create mode 100644 lib/apprise/py.typed create mode 100644 lib/apprise/utils.py create mode 100644 lib/markdown/__init__.py create mode 100644 lib/markdown/__main__.py create mode 100644 lib/markdown/__meta__.py create mode 100644 lib/markdown/blockparser.py create mode 100644 lib/markdown/blockprocessors.py create mode 100644 lib/markdown/core.py create mode 100644 lib/markdown/extensions/__init__.py create mode 100644 lib/markdown/extensions/abbr.py create mode 100644 lib/markdown/extensions/admonition.py create mode 100644 lib/markdown/extensions/attr_list.py create mode 100644 lib/markdown/extensions/codehilite.py create mode 100644 lib/markdown/extensions/def_list.py create mode 100644 lib/markdown/extensions/extra.py create mode 100644 lib/markdown/extensions/fenced_code.py create mode 100644 lib/markdown/extensions/footnotes.py create mode 100644 lib/markdown/extensions/legacy_attrs.py create mode 100644 lib/markdown/extensions/legacy_em.py create mode 100644 lib/markdown/extensions/md_in_html.py create mode 100644 lib/markdown/extensions/meta.py create mode 100644 lib/markdown/extensions/nl2br.py create mode 100644 lib/markdown/extensions/sane_lists.py create mode 100644 lib/markdown/extensions/smarty.py create mode 100644 lib/markdown/extensions/tables.py create mode 100644 lib/markdown/extensions/toc.py create mode 100644 lib/markdown/extensions/wikilinks.py create mode 100644 lib/markdown/htmlparser.py create mode 100644 lib/markdown/inlinepatterns.py create mode 100644 lib/markdown/postprocessors.py create mode 100644 lib/markdown/preprocessors.py create mode 100644 lib/markdown/serializers.py create mode 100644 lib/markdown/test_tools.py create mode 100644 lib/markdown/treeprocessors.py create mode 100644 lib/markdown/util.py create mode 100644 lib/yaml/__init__.py create mode 100644 lib/yaml/composer.py create mode 100644 lib/yaml/constructor.py create mode 100644 lib/yaml/cyaml.py create mode 100644 lib/yaml/dumper.py create mode 100644 lib/yaml/emitter.py create mode 100644 lib/yaml/error.py create mode 100644 lib/yaml/events.py create mode 100644 lib/yaml/loader.py create mode 100644 lib/yaml/nodes.py create mode 100644 lib/yaml/parser.py create mode 100644 lib/yaml/reader.py create mode 100644 lib/yaml/representer.py create mode 100644 lib/yaml/resolver.py create mode 100644 lib/yaml/scanner.py create mode 100644 lib/yaml/serializer.py create mode 100644 lib/yaml/tokens.py create mode 100644 metadata/default.meta create mode 100644 static/appIcon.png create mode 100644 static/appIcon_2x.png diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5cbe919 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +local +metadata/local.meta diff --git a/README.md b/README.md new file mode 100644 index 0000000..2c8a3a4 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +## Apprise Alert Action +Adds an alert action to Splunk that allows sending a notification using any of the notification services supported by Apprise. + +For a full list of notification services, see https://github.com/caronc/apprise/wiki + +There are two ways of using this addon: + - Providing a URL in each alert action + - Using a configuration file and tags + +### Providing a URL in each alert action +This requires no configuration to use. Just put a valid URL in the alert action and the service will be sent the alert. + +### Using a configuration file and tags +See https://github.com/caronc/apprise/wiki/config for creating an Apprise configuration file. + +To provide the configuration file to the add-on, in the Splunk UI go to Settings>Alert Actions>Setup Apprise Alert Action* + +Alternatively, this can be done by updating and placing the below config in local/alert_actions.conf + + [apprise_alert] + param.config = <> + +Note: The default path the addon looks in for configuration files the apprise_alert/bin/ folder. Either provide a full path or relative path from this directory. \ No newline at end of file diff --git a/README/alert_actions.conf.spec b/README/alert_actions.conf.spec new file mode 100644 index 0000000..3f34065 --- /dev/null +++ b/README/alert_actions.conf.spec @@ -0,0 +1,3 @@ +[apprise_alert] +param.config = +* Location of your Apprise configuration file. Relative paths are from the within the apps/alert_apprise folder. \ No newline at end of file diff --git a/README/saved_searches.conf.spec b/README/saved_searches.conf.spec new file mode 100644 index 0000000..d494c8d --- /dev/null +++ b/README/saved_searches.conf.spec @@ -0,0 +1,19 @@ +#Options for Apprise Alert Action + +action.apprise_alert = [0|1] +* Enable Apprise Alert Action + +action.apprise_alert.param.url = +* The Notification service URL. Please see here for more info: https://github.com/caronc/apprise/wiki +* (optional, if tags is set) + +action.apprise_alert.param.tag = +* Tag to use to send notificaions. Requires a configuration file. +* (optional, if URL is set) + +action.apprise_alert.param.body = +* Body of the alert + +action.apprise_alert.param.title = +* Title of the alert +* (optional) \ No newline at end of file diff --git a/appserver/static/appIcon.png b/appserver/static/appIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..697cc5c14d920f2caa27dc95222dcb90e5bfcf3f GIT binary patch literal 7118 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 pKFV<|t+J+J+Ll literal 0 HcmV?d00001 diff --git a/bin/send_apprise_alert.py b/bin/send_apprise_alert.py new file mode 100644 index 0000000..c887460 --- /dev/null +++ b/bin/send_apprise_alert.py @@ -0,0 +1,78 @@ +import sys, requests, json, re, os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lib")) +import apprise + + +def eprint(*args, **kwargs): + print(*args, file=sys.stderr, **kwargs) + +def check_inputs(config): + required_fields = ['body'] + + if not 'url' in config and not 'tag' in config: + eprint("A URL or tag needs to be specified.") + return False + + if 'tag' in config and not 'config' in config: + eprint("Using a tag requires setting a configuration file defined in setup.") + return False + + if 'tag' in config and 'config' in config: + if not os.path.exists(config['config']): + eprint("Unable to locate config file {}".format(config['config'])) + return False + + for field in required_fields: + if not field in config: + eprint("No "+field+" specified.") + return False + + return True + + +if len(sys.argv) > 1 and sys.argv[1] == "--execute": + alert = json.load(sys.stdin) + if check_inputs(alert['configuration']): + #load config + config = alert['configuration'] + + + if 'config' in config and 'tag' in config: + ac = apprise.AppriseConfig() + ac.add(config['config']) + + ar = apprise.Apprise() + ar.add(ac) + + if "title" in config: + ar.notify( + body=config['body'], + title=config['title'], + tag=config['tag'] + ) + else: + ar.notify( + body=config['body'], + tag=config['tag'] + ) + + + if 'url' in config: + ar = apprise.Apprise() + ar.add(config['url']) + + if "title" in config: + ar.notify( + body=config['body'], + title=config['title'] + ) + else: + ar.notify( + body=config['body'] + ) + + else: + eprint("Invalid configuration detected. Stopped.") +else: + eprint("FATAL No execute flag given") diff --git a/default/alert_actions.conf b/default/alert_actions.conf new file mode 100644 index 0000000..dba21c8 --- /dev/null +++ b/default/alert_actions.conf @@ -0,0 +1,9 @@ +[apprise_alert] +is_custom = 1 +label = Send an Apprise Alert +description = Send an alert using Apprise +icon_path = appIcon.png +alert.execute.cmd = send_apprise_alert.py +alert.execute.cmd.arg.0 = --execute +payload_format = json +python.version = python3 diff --git a/default/app.conf b/default/app.conf new file mode 100644 index 0000000..e8875ae --- /dev/null +++ b/default/app.conf @@ -0,0 +1,16 @@ +[install] +state = enabled + +[package] +check_for_updates = 1 +id = alert_apprise + +[ui] +is_visible = false +is_manageable = false +label = Apprise Alert Action + +[launcher] +author = Michael Clayfield +version = 1.0.0 +description = Alert Action based on Apprise, for sending alerts to many different sources. diff --git a/default/data/ui/alerts/apprise_alert.html b/default/data/ui/alerts/apprise_alert.html new file mode 100644 index 0000000..d0c0c3a --- /dev/null +++ b/default/data/ui/alerts/apprise_alert.html @@ -0,0 +1,41 @@ +
+
+ +
+ +
+
+
+
+ + URL of the service to be called. Only required if not using a tag. See https://github.com/caronc/apprise/wiki for further info. + +
+
+
+ +
+ +
+
+
+
+ + Tag of the service(s) from your configuration file to call. Only required if not using a URL. + +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
diff --git a/default/setup.xml b/default/setup.xml new file mode 100644 index 0000000..1b1293d --- /dev/null +++ b/default/setup.xml @@ -0,0 +1,9 @@ + + + + + text + + + + diff --git a/lib/apprise/Apprise.py b/lib/apprise/Apprise.py new file mode 100644 index 0000000..4c83c48 --- /dev/null +++ b/lib/apprise/Apprise.py @@ -0,0 +1,870 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 asyncio +import concurrent.futures as cf +import os +from itertools import chain +from . import common +from .conversion import convert_between +from .utils import is_exclusive_match +from .utils import parse_list +from .utils import parse_urls +from .utils import cwe312_url +from .logger import logger +from .AppriseAsset import AppriseAsset +from .AppriseConfig import AppriseConfig +from .AppriseAttachment import AppriseAttachment +from .AppriseLocale import AppriseLocale +from .config.ConfigBase import ConfigBase +from .plugins.NotifyBase import NotifyBase + + +from . import plugins +from . import __version__ + + +class Apprise: + """ + Our Notification Manager + + """ + + def __init__(self, servers=None, asset=None, location=None, debug=False): + """ + Loads a set of server urls while applying the Asset() module to each + if specified. + + If no asset is provided, then the default asset is used. + + Optionally specify a global ContentLocation for a more strict means + of handling Attachments. + """ + + # Initialize a server list of URLs + self.servers = list() + + # Assigns an central asset object that will be later passed into each + # notification plugin. Assets contain information such as the local + # directory images can be found in. It can also identify remote + # URL paths that contain the images you want to present to the end + # user. If no asset is specified, then the default one is used. + self.asset = \ + asset if isinstance(asset, AppriseAsset) else AppriseAsset() + + if servers: + self.add(servers) + + # Initialize our locale object + self.locale = AppriseLocale() + + # Set our debug flag + self.debug = debug + + # Store our hosting location for optional strict rule handling + # of Attachments. Setting this to None removes any attachment + # restrictions. + self.location = location + + @staticmethod + def instantiate(url, asset=None, tag=None, suppress_exceptions=True): + """ + Returns the instance of a instantiated plugin based on the provided + Server URL. If the url fails to be parsed, then None is returned. + + The specified url can be either a string (the URL itself) or a + dictionary containing all of the components needed to istantiate + the notification service. If identifying a dictionary, at the bare + minimum, one must specify the schema. + + An example of a url dictionary object might look like: + { + schema: 'mailto', + host: 'google.com', + user: 'myuser', + password: 'mypassword', + } + + Alternatively the string is much easier to specify: + mailto://user:mypassword@google.com + + The dictionary works well for people who are calling details() to + extract the components they need to build the URL manually. + """ + + # Initialize our result set + results = None + + # Prepare our Asset Object + asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset() + + if isinstance(url, str): + # Acquire our url tokens + results = plugins.url_to_dict( + url, secure_logging=asset.secure_logging) + + if results is None: + # Failed to parse the server URL; detailed logging handled + # inside url_to_dict - nothing to report here. + return None + + elif isinstance(url, dict): + # We already have our result set + results = url + + if results.get('schema') not in common.NOTIFY_SCHEMA_MAP: + # schema is a mandatory dictionary item as it is the only way + # we can index into our loaded plugins + logger.error('Dictionary does not include a "schema" entry.') + logger.trace( + 'Invalid dictionary unpacked as:{}{}'.format( + os.linesep, os.linesep.join( + ['{}="{}"'.format(k, v) + for k, v in results.items()]))) + return None + + logger.trace( + 'Dictionary unpacked as:{}{}'.format( + os.linesep, os.linesep.join( + ['{}="{}"'.format(k, v) for k, v in results.items()]))) + + # Otherwise we handle the invalid input specified + else: + logger.error( + 'An invalid URL type (%s) was specified for instantiation', + type(url)) + return None + + if not common.NOTIFY_SCHEMA_MAP[results['schema']].enabled: + # + # First Plugin Enable Check (Pre Initialization) + # + + # Plugin has been disabled at a global level + logger.error( + '%s:// is disabled on this system.', results['schema']) + return None + + # Build a list of tags to associate with the newly added notifications + results['tag'] = set(parse_list(tag)) + + # Set our Asset Object + results['asset'] = asset + + if suppress_exceptions: + try: + # Attempt to create an instance of our plugin using the parsed + # URL information + plugin = common.NOTIFY_SCHEMA_MAP[results['schema']](**results) + + # Create log entry of loaded URL + logger.debug( + 'Loaded {} URL: {}'.format( + common. + NOTIFY_SCHEMA_MAP[results['schema']].service_name, + plugin.url(privacy=asset.secure_logging))) + + except Exception: + # CWE-312 (Secure Logging) Handling + loggable_url = url if not asset.secure_logging \ + else cwe312_url(url) + + # the arguments are invalid or can not be used. + logger.error( + 'Could not load {} URL: {}'.format( + common. + NOTIFY_SCHEMA_MAP[results['schema']].service_name, + loggable_url)) + return None + + else: + # Attempt to create an instance of our plugin using the parsed + # URL information but don't wrap it in a try catch + plugin = common.NOTIFY_SCHEMA_MAP[results['schema']](**results) + + if not plugin.enabled: + # + # Second Plugin Enable Check (Post Initialization) + # + + # Service/Plugin is disabled (on a more local level). This is a + # case where the plugin was initially enabled but then after the + # __init__() was called under the hood something pre-determined + # that it could no longer be used. + + # The only downside to doing it this way is services are + # initialized prior to returning the details() if 3rd party tools + # are polling what is available. These services that become + # disabled thereafter are shown initially that they can be used. + logger.error( + '%s:// has become disabled on this system.', results['schema']) + return None + + return plugin + + def add(self, servers, asset=None, tag=None): + """ + Adds one or more server URLs into our list. + + You can override the global asset if you wish by including it with the + server(s) that you add. + + The tag allows you to associate 1 or more tag values to the server(s) + being added. tagging a service allows you to exclusively access them + when calling the notify() function. + """ + + # Initialize our return status + return_status = True + + if asset is None: + # prepare default asset + asset = self.asset + + if isinstance(servers, str): + # build our server list + servers = parse_urls(servers) + if len(servers) == 0: + return False + + elif isinstance(servers, dict): + # no problem, we support kwargs, convert it to a list + servers = [servers] + + elif isinstance(servers, (ConfigBase, NotifyBase, AppriseConfig)): + # Go ahead and just add our plugin into our list + self.servers.append(servers) + return True + + elif not isinstance(servers, (tuple, set, list)): + logger.error( + "An invalid notification (type={}) was specified.".format( + type(servers))) + return False + + for _server in servers: + + if isinstance(_server, (ConfigBase, NotifyBase, AppriseConfig)): + # Go ahead and just add our plugin into our list + self.servers.append(_server) + continue + + elif not isinstance(_server, (str, dict)): + logger.error( + "An invalid notification (type={}) was specified.".format( + type(_server))) + return_status = False + continue + + # Instantiate ourselves an object, this function throws or + # returns None if it fails + instance = Apprise.instantiate(_server, asset=asset, tag=tag) + if not isinstance(instance, NotifyBase): + # No logging is required as instantiate() handles failure + # and/or success reasons for us + return_status = False + continue + + # Add our initialized plugin to our server listings + self.servers.append(instance) + + # Return our status + return return_status + + def clear(self): + """ + Empties our server list + + """ + self.servers[:] = [] + + def find(self, tag=common.MATCH_ALL_TAG, match_always=True): + """ + Returns a list of all servers matching against the tag specified. + + """ + + # Build our tag setup + # - top level entries are treated as an 'or' + # - second level (or more) entries are treated as 'and' + # + # examples: + # tag="tagA, tagB" = tagA or tagB + # tag=['tagA', 'tagB'] = tagA or tagB + # tag=[('tagA', 'tagC'), 'tagB'] = (tagA and tagC) or tagB + # tag=[('tagB', 'tagC')] = tagB and tagC + + # A match_always flag allows us to pick up on our 'any' keyword + # and notify these services under all circumstances + match_always = common.MATCH_ALWAYS_TAG if match_always else None + + # Iterate over our loaded plugins + for entry in self.servers: + + if isinstance(entry, (ConfigBase, AppriseConfig)): + # load our servers + servers = entry.servers() + + else: + servers = [entry, ] + + for server in servers: + # Apply our tag matching based on our defined logic + if is_exclusive_match( + logic=tag, data=server.tags, + match_all=common.MATCH_ALL_TAG, + match_always=match_always): + yield server + return + + def notify(self, body, title='', notify_type=common.NotifyType.INFO, + body_format=None, tag=common.MATCH_ALL_TAG, match_always=True, + attach=None, interpret_escapes=None): + """ + Send a notification to all the plugins previously loaded. + + If the body_format specified is NotifyFormat.MARKDOWN, it will + be converted to HTML if the Notification type expects this. + + if the tag is specified (either a string or a set/list/tuple + of strings), then only the notifications flagged with that + tagged value are notified. By default, all added services + are notified (tag=MATCH_ALL_TAG) + + This function returns True if all notifications were successfully + sent, False if even just one of them fails, and None if no + notifications were sent at all as a result of tag filtering and/or + simply having empty configuration files that were read. + + Attach can contain a list of attachment URLs. attach can also be + represented by an AttachBase() (or list of) object(s). This + identifies the products you wish to notify + + Set interpret_escapes to True if you want to pre-escape a string + such as turning a \n into an actual new line, etc. + """ + + try: + # Process arguments and build synchronous and asynchronous calls + # (this step can throw internal errors). + sequential_calls, parallel_calls = self._create_notify_calls( + body, title, + notify_type=notify_type, body_format=body_format, + tag=tag, match_always=match_always, attach=attach, + interpret_escapes=interpret_escapes + ) + + except TypeError: + # No notifications sent, and there was an internal error. + return False + + if not sequential_calls and not parallel_calls: + # Nothing to send + return None + + sequential_result = Apprise._notify_sequential(*sequential_calls) + parallel_result = Apprise._notify_parallel_threadpool(*parallel_calls) + return sequential_result and parallel_result + + async def async_notify(self, *args, **kwargs): + """ + Send a notification to all the plugins previously loaded, for + asynchronous callers. + + The arguments are identical to those of Apprise.notify(). + + """ + try: + # Process arguments and build synchronous and asynchronous calls + # (this step can throw internal errors). + sequential_calls, parallel_calls = self._create_notify_calls( + *args, **kwargs) + + except TypeError: + # No notifications sent, and there was an internal error. + return False + + if not sequential_calls and not parallel_calls: + # Nothing to send + return None + + sequential_result = Apprise._notify_sequential(*sequential_calls) + parallel_result = \ + await Apprise._notify_parallel_asyncio(*parallel_calls) + return sequential_result and parallel_result + + def _create_notify_calls(self, *args, **kwargs): + """ + Creates notifications for all the plugins loaded. + + Returns a list of (server, notify() kwargs) tuples for plugins with + parallelism disabled and another list for plugins with parallelism + enabled. + """ + + all_calls = list(self._create_notify_gen(*args, **kwargs)) + + # Split into sequential and parallel notify() calls. + sequential, parallel = [], [] + for (server, notify_kwargs) in all_calls: + if server.asset.async_mode: + parallel.append((server, notify_kwargs)) + else: + sequential.append((server, notify_kwargs)) + + return sequential, parallel + + def _create_notify_gen(self, body, title='', + notify_type=common.NotifyType.INFO, + body_format=None, tag=common.MATCH_ALL_TAG, + match_always=True, attach=None, + interpret_escapes=None): + """ + Internal generator function for _create_notify_calls(). + """ + + if len(self) == 0: + # Nothing to notify + msg = "There are no service(s) to notify" + logger.error(msg) + raise TypeError(msg) + + if not (title or body or attach): + msg = "No message content specified to deliver" + logger.error(msg) + raise TypeError(msg) + + try: + if title and isinstance(title, bytes): + title = title.decode(self.asset.encoding) + + if body and isinstance(body, bytes): + body = body.decode(self.asset.encoding) + + except UnicodeDecodeError: + msg = 'The content passed into Apprise was not of encoding ' \ + 'type: {}'.format(self.asset.encoding) + logger.error(msg) + raise TypeError(msg) + + # Tracks conversions + conversion_body_map = dict() + conversion_title_map = dict() + + # Prepare attachments if required + if attach is not None and not isinstance(attach, AppriseAttachment): + attach = AppriseAttachment( + attach, asset=self.asset, location=self.location) + + # Allow Asset default value + body_format = self.asset.body_format \ + if body_format is None else body_format + + # Allow Asset default value + interpret_escapes = self.asset.interpret_escapes \ + if interpret_escapes is None else interpret_escapes + + # Iterate over our loaded plugins + for server in self.find(tag, match_always=match_always): + # If our code reaches here, we either did not define a tag (it + # was set to None), or we did define a tag and the logic above + # determined we need to notify the service it's associated with + + # First we need to generate a key we will use to determine if we + # need to build our data out. Entries without are merged with + # the body at this stage. + key = server.notify_format if server.title_maxlen > 0\ + else f'_{server.notify_format}' + + if key not in conversion_title_map: + + # Prepare our title + conversion_title_map[key] = '' if not title else title + + # Conversion of title only occurs for services where the title + # is blended with the body (title_maxlen <= 0) + if conversion_title_map[key] and server.title_maxlen <= 0: + conversion_title_map[key] = convert_between( + body_format, server.notify_format, + content=conversion_title_map[key]) + + # Our body is always converted no matter what + conversion_body_map[key] = \ + convert_between( + body_format, server.notify_format, content=body) + + if interpret_escapes: + # + # Escape our content + # + + try: + # Added overhead required due to Python 3 Encoding Bug + # identified here: https://bugs.python.org/issue21331 + conversion_body_map[key] = \ + conversion_body_map[key]\ + .encode('ascii', 'backslashreplace')\ + .decode('unicode-escape') + + conversion_title_map[key] = \ + conversion_title_map[key]\ + .encode('ascii', 'backslashreplace')\ + .decode('unicode-escape') + + except AttributeError: + # Must be of string type + msg = 'Failed to escape message body' + logger.error(msg) + raise TypeError(msg) + + kwargs = dict( + body=conversion_body_map[key], + title=conversion_title_map[key], + notify_type=notify_type, + attach=attach, + body_format=body_format + ) + yield (server, kwargs) + + @staticmethod + def _notify_sequential(*servers_kwargs): + """ + Process a list of notify() calls sequentially and synchronously. + """ + + success = True + + for (server, kwargs) in servers_kwargs: + try: + # Send notification + result = server.notify(**kwargs) + success = success and result + + except TypeError: + # These are our internally thrown notifications. + success = False + + except Exception: + # A catch all so we don't have to abort early + # just because one of our plugins has a bug in it. + logger.exception("Unhandled Notification Exception") + success = False + + return success + + @staticmethod + def _notify_parallel_threadpool(*servers_kwargs): + """ + Process a list of notify() calls in parallel and synchronously. + """ + + n_calls = len(servers_kwargs) + + # 0-length case + if n_calls == 0: + return True + + # There's no need to use a thread pool for just a single notification + if n_calls == 1: + return Apprise._notify_sequential(servers_kwargs[0]) + + # Create log entry + logger.info( + 'Notifying %d service(s) with threads.', len(servers_kwargs)) + + with cf.ThreadPoolExecutor() as executor: + success = True + futures = [executor.submit(server.notify, **kwargs) + for (server, kwargs) in servers_kwargs] + + for future in cf.as_completed(futures): + try: + result = future.result() + success = success and result + + except TypeError: + # These are our internally thrown notifications. + success = False + + except Exception: + # A catch all so we don't have to abort early + # just because one of our plugins has a bug in it. + logger.exception("Unhandled Notification Exception") + success = False + + return success + + @staticmethod + async def _notify_parallel_asyncio(*servers_kwargs): + """ + Process a list of async_notify() calls in parallel and asynchronously. + """ + + n_calls = len(servers_kwargs) + + # 0-length case + if n_calls == 0: + return True + + # (Unlike with the thread pool, we don't optimize for the single- + # notification case because asyncio can do useful work while waiting + # for that thread to complete) + + # Create log entry + logger.info( + 'Notifying %d service(s) asynchronously.', len(servers_kwargs)) + + async def do_call(server, kwargs): + return await server.async_notify(**kwargs) + + cors = (do_call(server, kwargs) for (server, kwargs) in servers_kwargs) + results = await asyncio.gather(*cors, return_exceptions=True) + + if any(isinstance(status, Exception) + and not isinstance(status, TypeError) for status in results): + # A catch all so we don't have to abort early just because + # one of our plugins has a bug in it. + logger.exception("Unhandled Notification Exception") + return False + + if any(isinstance(status, TypeError) for status in results): + # These are our internally thrown notifications. + return False + + return all(results) + + def details(self, lang=None, show_requirements=False, show_disabled=False): + """ + Returns the details associated with the Apprise object + + """ + + # general object returned + response = { + # Defines the current version of Apprise + 'version': __version__, + # Lists all of the currently supported Notifications + 'schemas': [], + # Includes the configured asset details + 'asset': self.asset.details(), + } + + for plugin in set(common.NOTIFY_SCHEMA_MAP.values()): + # Iterate over our hashed plugins and dynamically build details on + # their status: + + content = { + 'service_name': getattr(plugin, 'service_name', None), + 'service_url': getattr(plugin, 'service_url', None), + 'setup_url': getattr(plugin, 'setup_url', None), + # Placeholder - populated below + 'details': None, + + # Let upstream service know of the plugins that support + # attachments + 'attachment_support': getattr( + plugin, 'attachment_support', False), + + # Differentiat between what is a custom loaded plugin and + # which is native. + 'category': getattr(plugin, 'category', None) + } + + # Standard protocol(s) should be None or a tuple + enabled = getattr(plugin, 'enabled', True) + if not show_disabled and not enabled: + # Do not show inactive plugins + continue + + elif show_disabled: + # Add current state to response + content['enabled'] = enabled + + # Standard protocol(s) should be None or a tuple + protocols = getattr(plugin, 'protocol', None) + if isinstance(protocols, str): + protocols = (protocols, ) + + # Secure protocol(s) should be None or a tuple + secure_protocols = getattr(plugin, 'secure_protocol', None) + if isinstance(secure_protocols, str): + secure_protocols = (secure_protocols, ) + + # Add our protocol details to our content + content.update({ + 'protocols': protocols, + 'secure_protocols': secure_protocols, + }) + + if not lang: + # Simply return our results + content['details'] = plugins.details(plugin) + if show_requirements: + content['requirements'] = plugins.requirements(plugin) + + else: + # Emulate the specified language when returning our results + with self.locale.lang_at(lang): + content['details'] = plugins.details(plugin) + if show_requirements: + content['requirements'] = plugins.requirements(plugin) + + # Build our response object + response['schemas'].append(content) + + return response + + def urls(self, privacy=False): + """ + Returns all of the loaded URLs defined in this apprise object. + """ + return [x.url(privacy=privacy) for x in self.servers] + + def pop(self, index): + """ + Removes an indexed Notification Service from the stack and returns it. + + The thing is we can never pop AppriseConfig() entries, only what was + loaded within them. So pop needs to carefully iterate over our list + and only track actual entries. + """ + + # Tracking variables + prev_offset = -1 + offset = prev_offset + + for idx, s in enumerate(self.servers): + if isinstance(s, (ConfigBase, AppriseConfig)): + servers = s.servers() + if len(servers) > 0: + # Acquire a new maximum offset to work with + offset = prev_offset + len(servers) + + if offset >= index: + # we can pop an element from our config stack + fn = s.pop if isinstance(s, ConfigBase) \ + else s.server_pop + + return fn(index if prev_offset == -1 + else (index - prev_offset - 1)) + + else: + offset = prev_offset + 1 + if offset == index: + return self.servers.pop(idx) + + # Update our old offset + prev_offset = offset + + # If we reach here, then we indexed out of range + raise IndexError('list index out of range') + + def __getitem__(self, index): + """ + Returns the indexed server entry of a loaded notification server + """ + # Tracking variables + prev_offset = -1 + offset = prev_offset + + for idx, s in enumerate(self.servers): + if isinstance(s, (ConfigBase, AppriseConfig)): + # Get our list of servers associate with our config object + servers = s.servers() + if len(servers) > 0: + # Acquire a new maximum offset to work with + offset = prev_offset + len(servers) + + if offset >= index: + return servers[index if prev_offset == -1 + else (index - prev_offset - 1)] + + else: + offset = prev_offset + 1 + if offset == index: + return self.servers[idx] + + # Update our old offset + prev_offset = offset + + # If we reach here, then we indexed out of range + raise IndexError('list index out of range') + + def __getstate__(self): + """ + Pickle Support dumps() + """ + attributes = { + 'asset': self.asset, + # Prepare our URL list as we need to extract the associated tags + # and asset details associated with it + 'urls': [{ + 'url': server.url(privacy=False), + 'tag': server.tags if server.tags else None, + 'asset': server.asset} for server in self.servers], + 'locale': self.locale, + 'debug': self.debug, + 'location': self.location, + } + + return attributes + + def __setstate__(self, state): + """ + Pickle Support loads() + """ + self.servers = list() + self.asset = state['asset'] + self.locale = state['locale'] + self.location = state['location'] + for entry in state['urls']: + self.add(entry['url'], asset=entry['asset'], tag=entry['tag']) + + def __bool__(self): + """ + Allows the Apprise object to be wrapped in an 'if statement'. + True is returned if at least one service has been loaded. + """ + return len(self) > 0 + + def __iter__(self): + """ + Returns an iterator to each of our servers loaded. This includes those + found inside configuration. + """ + return chain(*[[s] if not isinstance(s, (ConfigBase, AppriseConfig)) + else iter(s.servers()) for s in self.servers]) + + def __len__(self): + """ + Returns the number of servers loaded; this includes those found within + loaded configuration. This funtion nnever actually counts the + Config entry themselves (if they exist), only what they contain. + """ + return sum([1 if not isinstance(s, (ConfigBase, AppriseConfig)) + else len(s.servers()) for s in self.servers]) diff --git a/lib/apprise/Apprise.pyi b/lib/apprise/Apprise.pyi new file mode 100644 index 0000000..5a34c9c --- /dev/null +++ b/lib/apprise/Apprise.pyi @@ -0,0 +1,62 @@ +from typing import Any, Dict, List, Iterable, Iterator, Optional + +from . import (AppriseAsset, AppriseAttachment, AppriseConfig, ConfigBase, + NotifyBase, NotifyFormat, NotifyType) +from .common import ContentLocation + +_Server = Union[str, ConfigBase, NotifyBase, AppriseConfig] +_Servers = Union[_Server, Dict[Any, _Server], Iterable[_Server]] +# Can't define this recursively as mypy doesn't support recursive types: +# https://github.com/python/mypy/issues/731 +_Tag = Union[str, Iterable[Union[str, Iterable[str]]]] + +class Apprise: + def __init__( + self, + servers: _Servers = ..., + asset: Optional[AppriseAsset] = ..., + location: Optional[ContentLocation] = ..., + debug: bool = ... + ) -> None: ... + @staticmethod + def instantiate( + url: Union[str, Dict[str, NotifyBase]], + asset: Optional[AppriseAsset] = ..., + tag: Optional[_Tag] = ..., + suppress_exceptions: bool = ... + ) -> NotifyBase: ... + def add( + self, + servers: _Servers = ..., + asset: Optional[AppriseAsset] = ..., + tag: Optional[_Tag] = ... + ) -> bool: ... + def clear(self) -> None: ... + def find(self, tag: str = ...) -> Iterator[Apprise]: ... + def notify( + self, + body: str, + title: str = ..., + notify_type: NotifyType = ..., + body_format: NotifyFormat = ..., + tag: _Tag = ..., + attach: Optional[AppriseAttachment] = ..., + interpret_escapes: Optional[bool] = ... + ) -> bool: ... + async def async_notify( + self, + body: str, + title: str = ..., + notify_type: NotifyType = ..., + body_format: NotifyFormat = ..., + tag: _Tag = ..., + attach: Optional[AppriseAttachment] = ..., + interpret_escapes: Optional[bool] = ... + ) -> bool: ... + def details(self, lang: Optional[str] = ...) -> Dict[str, Any]: ... + def urls(self, privacy: bool = ...) -> Iterable[str]: ... + def pop(self, index: int) -> ConfigBase: ... + def __getitem__(self, index: int) -> ConfigBase: ... + def __bool__(self) -> bool: ... + def __iter__(self) -> Iterator[ConfigBase]: ... + def __len__(self) -> int: ... \ No newline at end of file diff --git a/lib/apprise/AppriseAsset.py b/lib/apprise/AppriseAsset.py new file mode 100644 index 0000000..835c3b6 --- /dev/null +++ b/lib/apprise/AppriseAsset.py @@ -0,0 +1,346 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 re +from uuid import uuid4 +from os.path import join +from os.path import dirname +from os.path import isfile +from os.path import abspath +from .common import NotifyType +from .utils import module_detection + + +class AppriseAsset: + """ + Provides a supplimentary class that can be used to provide extra + information and details that can be used by Apprise such as providing + an alternate location to where images/icons can be found and the + URL masks. + + Any variable that starts with an underscore (_) can only be initialized + by this class manually and will/can not be parsed from a configuration + file. + + """ + # Application Identifier + app_id = 'Apprise' + + # Application Description + app_desc = 'Apprise Notifications' + + # Provider URL + app_url = 'https://github.com/caronc/apprise' + + # A Simple Mapping of Colors; For every NOTIFY_TYPE identified, + # there should be a mapping to it's color here: + html_notify_map = { + NotifyType.INFO: '#3AA3E3', + NotifyType.SUCCESS: '#3AA337', + NotifyType.FAILURE: '#A32037', + NotifyType.WARNING: '#CACF29', + } + + # Ascii Notification + ascii_notify_map = { + NotifyType.INFO: '[i]', + NotifyType.SUCCESS: '[+]', + NotifyType.FAILURE: '[!]', + NotifyType.WARNING: '[~]', + } + + # The default color to return if a mapping isn't found in our table above + default_html_color = '#888888' + + # The default image extension to use + default_extension = '.png' + + # The default theme + theme = 'default' + + # Image URL Mask + image_url_mask = \ + 'https://github.com/caronc/apprise/raw/master/apprise/assets/' \ + 'themes/{THEME}/apprise-{TYPE}-{XY}{EXTENSION}' + + # Application Logo + image_url_logo = \ + 'https://github.com/caronc/apprise/raw/master/apprise/assets/' \ + 'themes/{THEME}/apprise-logo.png' + + # Image Path Mask + image_path_mask = abspath(join( + dirname(__file__), + 'assets', + 'themes', + '{THEME}', + 'apprise-{TYPE}-{XY}{EXTENSION}', + )) + + # This value can also be set on calls to Apprise.notify(). This allows + # you to let Apprise upfront the type of data being passed in. This + # must be of type NotifyFormat. Possible values could be: + # - NotifyFormat.TEXT + # - NotifyFormat.MARKDOWN + # - NotifyFormat.HTML + # - None + # + # If no format is specified (hence None), then no special pre-formatting + # actions will take place during a notification. This has been and always + # will be the default. + body_format = None + + # Always attempt to send notifications asynchronous (as the same time + # if possible) + # This is a Python 3 supported option only. If set to False, then + # notifications are sent sequentially (one after another) + async_mode = True + + # Whether or not to interpret escapes found within the input text prior + # to passing it upstream. Such as converting \t to an actual tab and \n + # to a new line. + interpret_escapes = False + + # Defines the encoding of the content passed into Apprise + encoding = 'utf-8' + + # For more detail see CWE-312 @ + # https://cwe.mitre.org/data/definitions/312.html + # + # By enabling this, the logging output has additional overhead applied to + # it preventing secure password and secret information from being + # displayed in the logging. Since there is overhead involved in performing + # this cleanup; system owners who run in a very isolated environment may + # choose to disable this for a slight performance bump. It is recommended + # that you leave this option as is otherwise. + secure_logging = True + + # Optionally specify one or more path to attempt to scan for Python modules + # By default, no paths are scanned. + __plugin_paths = [] + + # All internal/system flags are prefixed with an underscore (_) + # These can only be initialized using Python libraries and are not picked + # up from (yaml) configuration files (if set) + + # An internal counter that is used by AppriseAPI + # (https://github.com/caronc/apprise-api). The idea is to allow one + # instance of AppriseAPI to call another, but to track how many times + # this occurs. It's intent is to prevent a loop where an AppriseAPI + # Server calls itself (or loops indefinitely) + _recursion = 0 + + # A unique identifer we can use to associate our calling source + _uid = str(uuid4()) + + def __init__(self, plugin_paths=None, **kwargs): + """ + Asset Initialization + + """ + # Assign default arguments if specified + for key, value in kwargs.items(): + if not hasattr(AppriseAsset, key): + raise AttributeError( + 'AppriseAsset init(): ' + 'An invalid key {} was specified.'.format(key)) + + setattr(self, key, value) + + if plugin_paths: + # Load any decorated modules if defined + module_detection(plugin_paths) + + def color(self, notify_type, color_type=None): + """ + Returns an HTML mapped color based on passed in notify type + + if color_type is: + None then a standard hex string is returned as + a string format ('#000000'). + + int then the integer representation is returned + tuple then the the red, green, blue is returned in a tuple + + """ + + # Attempt to get the type, otherwise return a default grey + # if we couldn't look up the entry + color = self.html_notify_map.get(notify_type, self.default_html_color) + if color_type is None: + # This is the default return type + return color + + elif color_type is int: + # Convert the color to integer + return AppriseAsset.hex_to_int(color) + + # The only other type is tuple + elif color_type is tuple: + return AppriseAsset.hex_to_rgb(color) + + # Unsupported type + raise ValueError( + 'AppriseAsset html_color(): An invalid color_type was specified.') + + def ascii(self, notify_type): + """ + Returns an ascii representation based on passed in notify type + + """ + + # look our response up + return self.ascii_notify_map.get(notify_type, self.default_html_color) + + def image_url(self, notify_type, image_size, logo=False, extension=None): + """ + Apply our mask to our image URL + + if logo is set to True, then the logo_url is used instead + + """ + + url_mask = self.image_url_logo if logo else self.image_url_mask + if not url_mask: + # No image to return + return None + + if extension is None: + extension = self.default_extension + + re_map = { + '{THEME}': self.theme if self.theme else '', + '{TYPE}': notify_type, + '{XY}': image_size, + '{EXTENSION}': extension, + } + + # Iterate over above list and store content accordingly + re_table = re.compile( + r'(' + '|'.join(re_map.keys()) + r')', + re.IGNORECASE, + ) + + return re_table.sub(lambda x: re_map[x.group()], url_mask) + + def image_path(self, notify_type, image_size, must_exist=True, + extension=None): + """ + Apply our mask to our image file path + + """ + + if not self.image_path_mask: + # No image to return + return None + + if extension is None: + extension = self.default_extension + + re_map = { + '{THEME}': self.theme if self.theme else '', + '{TYPE}': notify_type, + '{XY}': image_size, + '{EXTENSION}': extension, + } + + # Iterate over above list and store content accordingly + re_table = re.compile( + r'(' + '|'.join(re_map.keys()) + r')', + re.IGNORECASE, + ) + + # Acquire our path + path = re_table.sub(lambda x: re_map[x.group()], self.image_path_mask) + if must_exist and not isfile(path): + return None + + # Return what we parsed + return path + + def image_raw(self, notify_type, image_size, extension=None): + """ + Returns the raw image if it can (otherwise the function returns None) + + """ + + path = self.image_path( + notify_type=notify_type, + image_size=image_size, + extension=extension, + ) + if path: + try: + with open(path, 'rb') as fd: + return fd.read() + + except (OSError, IOError): + # We can't access the file + return None + + return None + + def details(self): + """ + Returns the details associated with the AppriseAsset object + + """ + return { + 'app_id': self.app_id, + 'app_desc': self.app_desc, + 'default_extension': self.default_extension, + 'theme': self.theme, + 'image_path_mask': self.image_path_mask, + 'image_url_mask': self.image_url_mask, + 'image_url_logo': self.image_url_logo, + } + + @staticmethod + def hex_to_rgb(value): + """ + Takes a hex string (such as #00ff00) and returns a tuple in the form + of (red, green, blue) + + eg: #00ff00 becomes : (0, 65535, 0) + + """ + value = value.lstrip('#') + lv = len(value) + return tuple(int(value[i:i + lv // 3], 16) + for i in range(0, lv, lv // 3)) + + @staticmethod + def hex_to_int(value): + """ + Takes a hex string (such as #00ff00) and returns its integer + equivalent + + eg: #00000f becomes : 15 + + """ + return int(value.lstrip('#'), 16) diff --git a/lib/apprise/AppriseAsset.pyi b/lib/apprise/AppriseAsset.pyi new file mode 100644 index 0000000..0830334 --- /dev/null +++ b/lib/apprise/AppriseAsset.pyi @@ -0,0 +1,34 @@ +from typing import Dict, Optional + +from . import NotifyFormat, NotifyType + +class AppriseAsset: + app_id: str + app_desc: str + app_url: str + html_notify_map: Dict[NotifyType, str] + default_html_color: str + default_extension: str + theme: Optional[str] + image_url_mask: str + image_url_logo: str + image_path_mask: Optional[str] + body_format: Optional[NotifyFormat] + async_mode: bool + interpret_escapes: bool + def __init__( + self, + app_id: str = ..., + app_desc: str = ..., + app_url: str = ..., + html_notify_map: Dict[NotifyType, str] = ..., + default_html_color: str = ..., + default_extension: str = ..., + theme: Optional[str] = ..., + image_url_mask: str = ..., + image_url_logo: str = ..., + image_path_mask: Optional[str] = ..., + body_format: Optional[NotifyFormat] = ..., + async_mode: bool = ..., + interpret_escapes: bool = ... + ) -> None: ... \ No newline at end of file diff --git a/lib/apprise/AppriseAttachment.py b/lib/apprise/AppriseAttachment.py new file mode 100644 index 0000000..e00645d --- /dev/null +++ b/lib/apprise/AppriseAttachment.py @@ -0,0 +1,324 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +from . import attachment +from . import URLBase +from .AppriseAsset import AppriseAsset +from .logger import logger +from .common import ContentLocation +from .common import CONTENT_LOCATIONS +from .common import ATTACHMENT_SCHEMA_MAP +from .utils import GET_SCHEMA_RE + + +class AppriseAttachment: + """ + Our Apprise Attachment File Manager + + """ + + def __init__(self, paths=None, asset=None, cache=True, location=None, + **kwargs): + """ + Loads all of the paths/urls specified (if any). + + The path can either be a single string identifying one explicit + location, otherwise you can pass in a series of locations to scan + via a list. + + By default we cache our responses so that subsiquent calls does not + cause the content to be retrieved again. For local file references + this makes no difference at all. But for remote content, this does + mean more then one call can be made to retrieve the (same) data. This + method can be somewhat inefficient if disabled. Only disable caching + if you understand the consequences. + + You can alternatively set the cache value to an int identifying the + number of seconds the previously retrieved can exist for before it + should be considered expired. + + It's also worth nothing that the cache value is only set to elements + that are not already of subclass AttachBase() + + Optionally set your current ContentLocation in the location argument. + This is used to further handle attachments. The rules are as follows: + - INACCESSIBLE: You simply have disabled use of the object; no + attachments will be retrieved/handled. + - HOSTED: You are hosting an attachment service for others. + In these circumstances all attachments that are LOCAL + based (such as file://) will not be allowed. + - LOCAL: The least restrictive mode as local files can be + referenced in addition to hosted. + + In all both HOSTED and LOCAL modes, INACCESSIBLE attachment types will + continue to be inaccessible. However if you set this field (location) + to None (it's default value) the attachment location category will not + be tested in any way (all attachment types will be allowed). + + The location field is also a global option that can be set when + initializing the Apprise object. + + """ + + # Initialize our attachment listings + self.attachments = list() + + # Set our cache flag + self.cache = cache + + # Prepare our Asset Object + self.asset = \ + asset if isinstance(asset, AppriseAsset) else AppriseAsset() + + if location is not None and location not in CONTENT_LOCATIONS: + msg = "An invalid Attachment location ({}) was specified." \ + .format(location) + logger.warning(msg) + raise TypeError(msg) + + # Store our location + self.location = location + + # Now parse any paths specified + if paths is not None: + # Store our path(s) + if not self.add(paths): + # Parse Source domain based on from_addr + raise TypeError("One or more attachments could not be added.") + + def add(self, attachments, asset=None, cache=None): + """ + Adds one or more attachments into our list. + + By default we cache our responses so that subsiquent calls does not + cause the content to be retrieved again. For local file references + this makes no difference at all. But for remote content, this does + mean more then one call can be made to retrieve the (same) data. This + method can be somewhat inefficient if disabled. Only disable caching + if you understand the consequences. + + You can alternatively set the cache value to an int identifying the + number of seconds the previously retrieved can exist for before it + should be considered expired. + + It's also worth nothing that the cache value is only set to elements + that are not already of subclass AttachBase() + """ + # Initialize our return status + return_status = True + + # Initialize our default cache value + cache = cache if cache is not None else self.cache + + if asset is None: + # prepare default asset + asset = self.asset + + if isinstance(attachments, attachment.AttachBase): + # Go ahead and just add our attachments into our list + self.attachments.append(attachments) + return True + + elif isinstance(attachments, str): + # Save our path + attachments = (attachments, ) + + elif not isinstance(attachments, (tuple, set, list)): + logger.error( + 'An invalid attachment url (type={}) was ' + 'specified.'.format(type(attachments))) + return False + + # Iterate over our attachments + for _attachment in attachments: + if self.location == ContentLocation.INACCESSIBLE: + logger.warning( + "Attachments are disabled; ignoring {}" + .format(_attachment)) + return_status = False + continue + + if isinstance(_attachment, str): + logger.debug("Loading attachment: {}".format(_attachment)) + # Instantiate ourselves an object, this function throws or + # returns None if it fails + instance = AppriseAttachment.instantiate( + _attachment, asset=asset, cache=cache) + if not isinstance(instance, attachment.AttachBase): + return_status = False + continue + + elif isinstance(_attachment, AppriseAttachment): + # We were provided a list of Apprise Attachments + # append our content together + instance = _attachment.attachments + + elif not isinstance(_attachment, attachment.AttachBase): + logger.warning( + "An invalid attachment (type={}) was specified.".format( + type(_attachment))) + return_status = False + continue + + else: + # our entry is of type AttachBase, so just go ahead and point + # our instance to it for some post processing below + instance = _attachment + + # Apply some simple logic if our location flag is set + if self.location and (( + self.location == ContentLocation.HOSTED + and instance.location != ContentLocation.HOSTED) + or instance.location == ContentLocation.INACCESSIBLE): + logger.warning( + "Attachment was disallowed due to accessibility " + "restrictions ({}->{}): {}".format( + self.location, instance.location, + instance.url(privacy=True))) + return_status = False + continue + + # Add our initialized plugin to our server listings + if isinstance(instance, list): + self.attachments.extend(instance) + + else: + self.attachments.append(instance) + + # Return our status + return return_status + + @staticmethod + def instantiate(url, asset=None, cache=None, suppress_exceptions=True): + """ + Returns the instance of a instantiated attachment plugin based on + the provided Attachment URL. If the url fails to be parsed, then None + is returned. + + A specified cache value will over-ride anything set + + """ + # Attempt to acquire the schema at the very least to allow our + # attachment based urls. + schema = GET_SCHEMA_RE.match(url) + if schema is None: + # Plan B is to assume we're dealing with a file + schema = attachment.AttachFile.protocol + url = '{}://{}'.format(schema, URLBase.quote(url)) + + else: + # Ensure our schema is always in lower case + schema = schema.group('schema').lower() + + # Some basic validation + if schema not in ATTACHMENT_SCHEMA_MAP: + logger.warning('Unsupported schema {}.'.format(schema)) + return None + + # Parse our url details of the server object as dictionary containing + # all of the information parsed from our URL + results = ATTACHMENT_SCHEMA_MAP[schema].parse_url(url) + + if not results: + # Failed to parse the server URL + logger.warning('Unparseable URL {}.'.format(url)) + return None + + # Prepare our Asset Object + results['asset'] = \ + asset if isinstance(asset, AppriseAsset) else AppriseAsset() + + if cache is not None: + # Force an over-ride of the cache value to what we have specified + results['cache'] = cache + + if suppress_exceptions: + try: + # Attempt to create an instance of our plugin using the parsed + # URL information + attach_plugin = \ + ATTACHMENT_SCHEMA_MAP[results['schema']](**results) + + except Exception: + # the arguments are invalid or can not be used. + logger.warning('Could not load URL: %s' % url) + return None + + else: + # Attempt to create an instance of our plugin using the parsed + # URL information but don't wrap it in a try catch + attach_plugin = ATTACHMENT_SCHEMA_MAP[results['schema']](**results) + + return attach_plugin + + def clear(self): + """ + Empties our attachment list + + """ + self.attachments[:] = [] + + def size(self): + """ + Returns the total size of accumulated attachments + """ + return sum([len(a) for a in self.attachments if len(a) > 0]) + + def pop(self, index=-1): + """ + Removes an indexed Apprise Attachment from the stack and returns it. + + by default the last element is poped from the list + """ + # Remove our entry + return self.attachments.pop(index) + + def __getitem__(self, index): + """ + Returns the indexed entry of a loaded apprise attachments + """ + return self.attachments[index] + + def __bool__(self): + """ + Allows the Apprise object to be wrapped in an 'if statement'. + True is returned if at least one service has been loaded. + """ + return True if self.attachments else False + + def __iter__(self): + """ + Returns an iterator to our attachment list + """ + return iter(self.attachments) + + def __len__(self): + """ + Returns the number of attachment entries loaded + """ + return len(self.attachments) diff --git a/lib/apprise/AppriseAttachment.pyi b/lib/apprise/AppriseAttachment.pyi new file mode 100644 index 0000000..a28acb1 --- /dev/null +++ b/lib/apprise/AppriseAttachment.pyi @@ -0,0 +1,37 @@ +from typing import Any, Iterable, Optional, Union + +from . import AppriseAsset, ContentLocation +from .attachment import AttachBase + +_Attachment = Union[str, AttachBase] +_Attachments = Iterable[_Attachment] + +class AppriseAttachment: + def __init__( + self, + paths: Optional[_Attachments] = ..., + asset: Optional[AppriseAttachment] = ..., + cache: bool = ..., + location: Optional[ContentLocation] = ..., + **kwargs: Any + ) -> None: ... + def add( + self, + attachments: _Attachments, + asset: Optional[AppriseAttachment] = ..., + cache: Optional[bool] = ... + ) -> bool: ... + @staticmethod + def instantiate( + url: str, + asset: Optional[AppriseAsset] = ..., + cache: Optional[bool] = ..., + suppress_exceptions: bool = ... + ) -> NotifyBase: ... + def clear(self) -> None: ... + def size(self) -> int: ... + def pop(self, index: int = ...) -> AttachBase: ... + def __getitem__(self, index: int) -> AttachBase: ... + def __bool__(self) -> bool: ... + def __iter__(self) -> Iterator[AttachBase]: ... + def __len__(self) -> int: ... \ No newline at end of file diff --git a/lib/apprise/AppriseConfig.py b/lib/apprise/AppriseConfig.py new file mode 100644 index 0000000..07e7b48 --- /dev/null +++ b/lib/apprise/AppriseConfig.py @@ -0,0 +1,451 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +from . import config +from . import ConfigBase +from . import CONFIG_FORMATS +from . import URLBase +from .AppriseAsset import AppriseAsset +from . import common +from .utils import GET_SCHEMA_RE +from .utils import parse_list +from .utils import is_exclusive_match +from .logger import logger + + +class AppriseConfig: + """ + Our Apprise Configuration File Manager + + - Supports a list of URLs defined one after another (text format) + - Supports a destinct YAML configuration format + + """ + + def __init__(self, paths=None, asset=None, cache=True, recursion=0, + insecure_includes=False, **kwargs): + """ + Loads all of the paths specified (if any). + + The path can either be a single string identifying one explicit + location, otherwise you can pass in a series of locations to scan + via a list. + + If no path is specified then a default list is used. + + By default we cache our responses so that subsiquent calls does not + cause the content to be retrieved again. Setting this to False does + mean more then one call can be made to retrieve the (same) data. This + method can be somewhat inefficient if disabled and you're set up to + make remote calls. Only disable caching if you understand the + consequences. + + You can alternatively set the cache value to an int identifying the + number of seconds the previously retrieved can exist for before it + should be considered expired. + + It's also worth nothing that the cache value is only set to elements + that are not already of subclass ConfigBase() + + recursion defines how deep we recursively handle entries that use the + `import` keyword. This keyword requires us to fetch more configuration + from another source and add it to our existing compilation. If the + file we remotely retrieve also has an `import` reference, we will only + advance through it if recursion is set to 2 deep. If set to zero + it is off. There is no limit to how high you set this value. It would + be recommended to keep it low if you do intend to use it. + + insecure includes by default are disabled. When set to True, all + Apprise Config files marked to be in STRICT mode are treated as being + in ALWAYS mode. + + Take a file:// based configuration for example, only a file:// based + configuration can import another file:// based one. because it is set + to STRICT mode. If an http:// based configuration file attempted to + import a file:// one it woul fail. However this import would be + possible if insecure_includes is set to True. + + There are cases where a self hosting apprise developer may wish to load + configuration from memory (in a string format) that contains import + entries (even file:// based ones). In these circumstances if you want + these includes to be honored, this value must be set to True. + """ + + # Initialize a server list of URLs + self.configs = list() + + # Prepare our Asset Object + self.asset = \ + asset if isinstance(asset, AppriseAsset) else AppriseAsset() + + # Set our cache flag + self.cache = cache + + # Initialize our recursion value + self.recursion = recursion + + # Initialize our insecure_includes flag + self.insecure_includes = insecure_includes + + if paths is not None: + # Store our path(s) + self.add(paths) + + return + + def add(self, configs, asset=None, tag=None, cache=True, recursion=None, + insecure_includes=None): + """ + Adds one or more config URLs into our list. + + You can override the global asset if you wish by including it with the + config(s) that you add. + + By default we cache our responses so that subsiquent calls does not + cause the content to be retrieved again. Setting this to False does + mean more then one call can be made to retrieve the (same) data. This + method can be somewhat inefficient if disabled and you're set up to + make remote calls. Only disable caching if you understand the + consequences. + + You can alternatively set the cache value to an int identifying the + number of seconds the previously retrieved can exist for before it + should be considered expired. + + It's also worth nothing that the cache value is only set to elements + that are not already of subclass ConfigBase() + + Optionally override the default recursion value. + + Optionally override the insecure_includes flag. + if insecure_includes is set to True then all plugins that are + set to a STRICT mode will be a treated as ALWAYS. + """ + + # Initialize our return status + return_status = True + + # Initialize our default cache value + cache = cache if cache is not None else self.cache + + # Initialize our default recursion value + recursion = recursion if recursion is not None else self.recursion + + # Initialize our default insecure_includes value + insecure_includes = \ + insecure_includes if insecure_includes is not None \ + else self.insecure_includes + + if asset is None: + # prepare default asset + asset = self.asset + + if isinstance(configs, ConfigBase): + # Go ahead and just add our configuration into our list + self.configs.append(configs) + return True + + elif isinstance(configs, str): + # Save our path + configs = (configs, ) + + elif not isinstance(configs, (tuple, set, list)): + logger.error( + 'An invalid configuration path (type={}) was ' + 'specified.'.format(type(configs))) + return False + + # Iterate over our configuration + for _config in configs: + + if isinstance(_config, ConfigBase): + # Go ahead and just add our configuration into our list + self.configs.append(_config) + continue + + elif not isinstance(_config, str): + logger.warning( + "An invalid configuration (type={}) was specified.".format( + type(_config))) + return_status = False + continue + + logger.debug("Loading configuration: {}".format(_config)) + + # Instantiate ourselves an object, this function throws or + # returns None if it fails + instance = AppriseConfig.instantiate( + _config, asset=asset, tag=tag, cache=cache, + recursion=recursion, insecure_includes=insecure_includes) + if not isinstance(instance, ConfigBase): + return_status = False + continue + + # Add our initialized plugin to our server listings + self.configs.append(instance) + + # Return our status + return return_status + + def add_config(self, content, asset=None, tag=None, format=None, + recursion=None, insecure_includes=None): + """ + Adds one configuration file in it's raw format. Content gets loaded as + a memory based object and only exists for the life of this + AppriseConfig object it was loaded into. + + If you know the format ('yaml' or 'text') you can specify + it for slightly less overhead during this call. Otherwise the + configuration is auto-detected. + + Optionally override the default recursion value. + + Optionally override the insecure_includes flag. + if insecure_includes is set to True then all plugins that are + set to a STRICT mode will be a treated as ALWAYS. + """ + + # Initialize our default recursion value + recursion = recursion if recursion is not None else self.recursion + + # Initialize our default insecure_includes value + insecure_includes = \ + insecure_includes if insecure_includes is not None \ + else self.insecure_includes + + if asset is None: + # prepare default asset + asset = self.asset + + if not isinstance(content, str): + logger.warning( + "An invalid configuration (type={}) was specified.".format( + type(content))) + return False + + logger.debug("Loading raw configuration: {}".format(content)) + + # Create ourselves a ConfigMemory Object to store our configuration + instance = config.ConfigMemory( + content=content, format=format, asset=asset, tag=tag, + recursion=recursion, insecure_includes=insecure_includes) + + if instance.config_format not in CONFIG_FORMATS: + logger.warning( + "The format of the configuration could not be deteced.") + return False + + # Add our initialized plugin to our server listings + self.configs.append(instance) + + # Return our status + return True + + def servers(self, tag=common.MATCH_ALL_TAG, match_always=True, *args, + **kwargs): + """ + Returns all of our servers dynamically build based on parsed + configuration. + + If a tag is specified, it applies to the configuration sources + themselves and not the notification services inside them. + + This is for filtering the configuration files polled for + results. + + If the anytag is set, then any notification that is found + set with that tag are included in the response. + + """ + + # A match_always flag allows us to pick up on our 'any' keyword + # and notify these services under all circumstances + match_always = common.MATCH_ALWAYS_TAG if match_always else None + + # Build our tag setup + # - top level entries are treated as an 'or' + # - second level (or more) entries are treated as 'and' + # + # examples: + # tag="tagA, tagB" = tagA or tagB + # tag=['tagA', 'tagB'] = tagA or tagB + # tag=[('tagA', 'tagC'), 'tagB'] = (tagA and tagC) or tagB + # tag=[('tagB', 'tagC')] = tagB and tagC + + response = list() + + for entry in self.configs: + + # Apply our tag matching based on our defined logic + if is_exclusive_match( + logic=tag, data=entry.tags, match_all=common.MATCH_ALL_TAG, + match_always=match_always): + # Build ourselves a list of services dynamically and return the + # as a list + response.extend(entry.servers()) + + return response + + @staticmethod + def instantiate(url, asset=None, tag=None, cache=None, + recursion=0, insecure_includes=False, + suppress_exceptions=True): + """ + Returns the instance of a instantiated configuration plugin based on + the provided Config URL. If the url fails to be parsed, then None + is returned. + + """ + # Attempt to acquire the schema at the very least to allow our + # configuration based urls. + schema = GET_SCHEMA_RE.match(url) + if schema is None: + # Plan B is to assume we're dealing with a file + schema = config.ConfigFile.protocol + url = '{}://{}'.format(schema, URLBase.quote(url)) + + else: + # Ensure our schema is always in lower case + schema = schema.group('schema').lower() + + # Some basic validation + if schema not in common.CONFIG_SCHEMA_MAP: + logger.warning('Unsupported schema {}.'.format(schema)) + return None + + # Parse our url details of the server object as dictionary containing + # all of the information parsed from our URL + results = common.CONFIG_SCHEMA_MAP[schema].parse_url(url) + + if not results: + # Failed to parse the server URL + logger.warning('Unparseable URL {}.'.format(url)) + return None + + # Build a list of tags to associate with the newly added notifications + results['tag'] = set(parse_list(tag)) + + # Prepare our Asset Object + results['asset'] = \ + asset if isinstance(asset, AppriseAsset) else AppriseAsset() + + if cache is not None: + # Force an over-ride of the cache value to what we have specified + results['cache'] = cache + + # Recursion can never be parsed from the URL + results['recursion'] = recursion + + # Insecure includes flag can never be parsed from the URL + results['insecure_includes'] = insecure_includes + + if suppress_exceptions: + try: + # Attempt to create an instance of our plugin using the parsed + # URL information + cfg_plugin = \ + common.CONFIG_SCHEMA_MAP[results['schema']](**results) + + except Exception: + # the arguments are invalid or can not be used. + logger.warning('Could not load URL: %s' % url) + return None + + else: + # Attempt to create an instance of our plugin using the parsed + # URL information but don't wrap it in a try catch + cfg_plugin = common.CONFIG_SCHEMA_MAP[results['schema']](**results) + + return cfg_plugin + + def clear(self): + """ + Empties our configuration list + + """ + self.configs[:] = [] + + def server_pop(self, index): + """ + Removes an indexed Apprise Notification from the servers + """ + + # Tracking variables + prev_offset = -1 + offset = prev_offset + + for entry in self.configs: + servers = entry.servers(cache=True) + if len(servers) > 0: + # Acquire a new maximum offset to work with + offset = prev_offset + len(servers) + + if offset >= index: + # we can pop an notification from our config stack + return entry.pop(index if prev_offset == -1 + else (index - prev_offset - 1)) + + # Update our old offset + prev_offset = offset + + # If we reach here, then we indexed out of range + raise IndexError('list index out of range') + + def pop(self, index=-1): + """ + Removes an indexed Apprise Configuration from the stack and returns it. + + By default, the last element is removed from the list + """ + # Remove our entry + return self.configs.pop(index) + + def __getitem__(self, index): + """ + Returns the indexed config entry of a loaded apprise configuration + """ + return self.configs[index] + + def __bool__(self): + """ + Allows the Apprise object to be wrapped in an 'if statement'. + True is returned if at least one service has been loaded. + """ + return True if self.configs else False + + def __iter__(self): + """ + Returns an iterator to our config list + """ + return iter(self.configs) + + def __len__(self): + """ + Returns the number of config entries loaded + """ + return len(self.configs) diff --git a/lib/apprise/AppriseConfig.pyi b/lib/apprise/AppriseConfig.pyi new file mode 100644 index 0000000..9ea819a --- /dev/null +++ b/lib/apprise/AppriseConfig.pyi @@ -0,0 +1,48 @@ +from typing import Any, Iterable, Iterator, List, Optional, Union + +from . import AppriseAsset, NotifyBase +from .config import ConfigBase + +_Configs = Union[ConfigBase, str, Iterable[str]] + +class AppriseConfig: + def __init__( + self, + paths: Optional[_Configs] = ..., + asset: Optional[AppriseAsset] = ..., + cache: bool = ..., + recursion: int = ..., + insecure_includes: bool = ..., + **kwargs: Any + ) -> None: ... + def add( + self, + configs: _Configs, + asset: Optional[AppriseAsset] = ..., + cache: bool = ..., + recursion: Optional[bool] = ..., + insecure_includes: Optional[bool] = ... + ) -> bool: ... + def add_config( + self, + content: str, + asset: Optional[AppriseAsset] = ..., + tag: Optional[str] = ..., + format: Optional[str] = ..., + recursion: Optional[int] = ..., + insecure_includes: Optional[bool] = ... + ) -> bool: ... + def servers(self, tag: str = ..., *args: Any, **kwargs: Any) -> List[ConfigBase]: ... + def instantiate( + url: str, + asset: Optional[AppriseAsset] = ..., + tag: Optional[str] = ..., + cache: Optional[bool] = ... + ) -> NotifyBase: ... + def clear(self) -> None: ... + def server_pop(self, index: int) -> ConfigBase: ... + def pop(self, index: int = ...) -> ConfigBase: ... + def __getitem__(self, index: int) -> ConfigBase: ... + def __bool__(self) -> bool: ... + def __iter__(self) -> Iterator[ConfigBase]: ... + def __len__(self) -> int: ... \ No newline at end of file diff --git a/lib/apprise/AppriseLocale.py b/lib/apprise/AppriseLocale.py new file mode 100644 index 0000000..c80afae --- /dev/null +++ b/lib/apprise/AppriseLocale.py @@ -0,0 +1,286 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 ctypes +import locale +import contextlib +import os +import re +from os.path import join +from os.path import dirname +from os.path import abspath +from .logger import logger + + +# This gets toggled to True if we succeed +GETTEXT_LOADED = False + +try: + # Initialize gettext + import gettext + + # Toggle our flag + GETTEXT_LOADED = True + +except ImportError: + # gettext isn't available; no problem; Use the library features without + # multi-language support. + pass + + +class AppriseLocale: + """ + A wrapper class to gettext so that we can manipulate multiple lanaguages + on the fly if required. + + """ + + # Define our translation domain + _domain = 'apprise' + + # The path to our translations + _locale_dir = abspath(join(dirname(__file__), 'i18n')) + + # Locale regular expression + _local_re = re.compile( + r'^((?PC)|(?P([a-z]{2}))([_:](?P[a-z]{2}))?)' + r'(\.(?P[a-z0-9-]+))?$', re.IGNORECASE) + + # Define our default encoding + _default_encoding = 'utf-8' + + # The function to assign `_` by default + _fn = 'gettext' + + # The language we should fall back to if all else fails + _default_language = 'en' + + def __init__(self, language=None): + """ + Initializes our object, if a language is specified, then we + initialize ourselves to that, otherwise we use whatever we detect + from the local operating system. If all else fails, we resort to the + defined default_language. + + """ + + # Cache previously loaded translations + self._gtobjs = {} + + # Get our language + self.lang = AppriseLocale.detect_language(language) + + # Our mapping to our _fn + self.__fn_map = None + + if GETTEXT_LOADED is False: + # We're done + return + + # Add language + self.add(self.lang) + + def add(self, lang=None, set_default=True): + """ + Add a language to our list + """ + lang = lang if lang else self._default_language + if lang not in self._gtobjs: + # Load our gettext object and install our language + try: + self._gtobjs[lang] = gettext.translation( + self._domain, localedir=self._locale_dir, languages=[lang], + fallback=False) + + # The non-intrusive method of applying the gettext change to + # the global namespace only + self.__fn_map = getattr(self._gtobjs[lang], self._fn) + + except FileNotFoundError: + # The translation directory does not exist + logger.debug( + 'Could not load translation path: %s', + join(self._locale_dir, lang)) + + # Fallback (handle case where self.lang does not exist) + if self.lang not in self._gtobjs: + self._gtobjs[self.lang] = gettext + self.__fn_map = getattr(self._gtobjs[self.lang], self._fn) + + return False + + logger.trace('Loaded language %s', lang) + + if set_default: + logger.debug('Language set to %s', lang) + self.lang = lang + + return True + + @contextlib.contextmanager + def lang_at(self, lang, mapto=_fn): + """ + The syntax works as: + with at.lang_at('fr'): + # apprise works as though the french language has been + # defined. afterwards, the language falls back to whatever + # it was. + """ + + if GETTEXT_LOADED is False: + # Do nothing + yield None + + # we're done + return + + # Tidy the language + lang = AppriseLocale.detect_language(lang, detect_fallback=False) + if lang not in self._gtobjs and not self.add(lang, set_default=False): + # Do Nothing + yield getattr(self._gtobjs[self.lang], mapto) + else: + # Yield + yield getattr(self._gtobjs[lang], mapto) + + return + + @property + def gettext(self): + """ + Return the current language gettext() function + + Useful for assigning to `_` + """ + return self._gtobjs[self.lang].gettext + + @staticmethod + def detect_language(lang=None, detect_fallback=True): + """ + Returns the language (if it's retrievable) + """ + # We want to only use the 2 character version of this language + # hence en_CA becomes en, en_US becomes en. + if not isinstance(lang, str): + if detect_fallback is False: + # no detection enabled; we're done + return None + + # Posix lookup + lookup = os.environ.get + localename = None + for variable in ('LC_ALL', 'LC_CTYPE', 'LANG', 'LANGUAGE'): + localename = lookup(variable, None) + if localename: + result = AppriseLocale._local_re.match(localename) + if result and result.group('lang'): + return result.group('lang').lower() + + # Windows handling + if hasattr(ctypes, 'windll'): + windll = ctypes.windll.kernel32 + try: + lang = locale.windows_locale[ + windll.GetUserDefaultUILanguage()] + + # Our detected windows language + return lang[0:2].lower() + + except (TypeError, KeyError): + # Fallback to posix detection + pass + + # Built in locale library check + try: + # Acquire our locale + lang = locale.getlocale()[0] + + except (ValueError, TypeError) as e: + # This occurs when an invalid locale was parsed from the + # environment variable. While we still return None in this + # case, we want to better notify the end user of this. Users + # receiving this error should check their environment + # variables. + logger.warning( + 'Language detection failure / {}'.format(str(e))) + return None + + return None if not lang else lang[0:2].lower() + + def __getstate__(self): + """ + Pickle Support dumps() + """ + state = self.__dict__.copy() + + # Remove the unpicklable entries. + del state['_gtobjs'] + del state['_AppriseLocale__fn_map'] + return state + + def __setstate__(self, state): + """ + Pickle Support loads() + """ + self.__dict__.update(state) + # Our mapping to our _fn + self.__fn_map = None + self._gtobjs = {} + self.add(state['lang'], set_default=True) + + +# +# Prepare our default LOCALE Singleton +# +LOCALE = AppriseLocale() + + +class LazyTranslation: + """ + Doesn't translate anything until str() or unicode() references + are made. + + """ + def __init__(self, text, *args, **kwargs): + """ + Store our text + """ + self.text = text + + super().__init__(*args, **kwargs) + + def __str__(self): + return LOCALE.gettext(self.text) if GETTEXT_LOADED else self.text + + +# Lazy translation handling +def gettext_lazy(text): + """ + A dummy function that can be referenced + """ + return LazyTranslation(text=text) diff --git a/lib/apprise/URLBase.py b/lib/apprise/URLBase.py new file mode 100644 index 0000000..1cea66d --- /dev/null +++ b/lib/apprise/URLBase.py @@ -0,0 +1,796 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 re +from .logger import logger +from time import sleep +from datetime import datetime +from xml.sax.saxutils import escape as sax_escape + +from urllib.parse import unquote as _unquote +from urllib.parse import quote as _quote + +from .AppriseLocale import gettext_lazy as _ +from .AppriseAsset import AppriseAsset +from .utils import urlencode +from .utils import parse_url +from .utils import parse_bool +from .utils import parse_list +from .utils import parse_phone_no + +# Used to break a path list into parts +PATHSPLIT_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') + + +class PrivacyMode: + # Defines different privacy modes strings can be printed as + # Astrisk sets 4 of them: e.g. **** + # This is used for passwords + Secret = '*' + + # Outer takes the first and last character displaying them with + # 3 dots between. Hence, 'i-am-a-token' would become 'i...n' + Outer = 'o' + + # Displays the last four characters + Tail = 't' + + +# Define the HTML Lookup Table +HTML_LOOKUP = { + 400: 'Bad Request - Unsupported Parameters.', + 401: 'Verification Failed.', + 404: 'Page not found.', + 405: 'Method not allowed.', + 500: 'Internal server error.', + 503: 'Servers are overloaded.', +} + + +class URLBase: + """ + This is the base class for all URL Manipulation + """ + + # The default descriptive name associated with the URL + service_name = None + + # The default simple (insecure) protocol + # all inheriting entries must provide their protocol lookup + # protocol:// (in this example they would specify 'protocol') + protocol = None + + # The default secure protocol + # all inheriting entries must provide their protocol lookup + # protocols:// (in this example they would specify 'protocols') + # This value can be the same as the defined protocol. + secure_protocol = None + + # Throttle + request_rate_per_sec = 0 + + # The connect timeout is the number of seconds Requests will wait for your + # client to establish a connection to a remote machine (corresponding to + # the connect()) call on the socket. + socket_connect_timeout = 4.0 + + # The read timeout is the number of seconds the client will wait for the + # server to send a response. + socket_read_timeout = 4.0 + + # Handle + # Maintain a set of tags to associate with this specific notification + tags = set() + + # Secure sites should be verified against a Certificate Authority + verify_certificate = True + + # Logging to our global logger + logger = logger + + # Define a default set of template arguments used for dynamically building + # details about our individual plugins for developers. + + # Define object templates + templates = () + + # Provides a mapping of tokens, certain entries are fixed and automatically + # configured if found (such as schema, host, user, pass, and port) + template_tokens = {} + + # Here is where we define all of the arguments we accept on the url + # such as: schema://whatever/?cto=5.0&rto=15 + # These act the same way as tokens except they are optional and/or + # have default values set if mandatory. This rule must be followed + template_args = { + 'verify': { + 'name': _('Verify SSL'), + # SSL Certificate Authority Verification + 'type': 'bool', + # Provide a default + 'default': verify_certificate, + # look up default using the following parent class value at + # runtime. + '_lookup_default': 'verify_certificate', + }, + 'rto': { + 'name': _('Socket Read Timeout'), + 'type': 'float', + # Provide a default + 'default': socket_read_timeout, + # look up default using the following parent class value at + # runtime. The variable name identified here (in this case + # socket_read_timeout) is checked and it's result is placed + # over-top of the 'default'. This is done because once a parent + # class inherits this one, the overflow_mode already set as a + # default 'could' be potentially over-ridden and changed to a + # different value. + '_lookup_default': 'socket_read_timeout', + }, + 'cto': { + 'name': _('Socket Connect Timeout'), + 'type': 'float', + # Provide a default + 'default': socket_connect_timeout, + # look up default using the following parent class value at + # runtime. The variable name identified here (in this case + # socket_connect_timeout) is checked and it's result is placed + # over-top of the 'default'. This is done because once a parent + # class inherits this one, the overflow_mode already set as a + # default 'could' be potentially over-ridden and changed to a + # different value. + '_lookup_default': 'socket_connect_timeout', + }, + } + + # kwargs are dynamically built because a prefix causes us to parse the + # content slightly differently. The prefix is required and can be either + # a (+ or -). Below would handle the +key=value: + # { + # 'headers': { + # 'name': _('HTTP Header'), + # 'prefix': '+', + # 'type': 'string', + # }, + # }, + # + # In a kwarg situation, the 'key' is always presumed to be treated as + # a string. When the 'type' is defined, it is being defined to respect + # the 'value'. + + template_kwargs = {} + + def __init__(self, asset=None, **kwargs): + """ + Initialize some general logging and common server arguments that will + keep things consistent when working with the children that + inherit this class. + + """ + # Prepare our Asset Object + self.asset = \ + asset if isinstance(asset, AppriseAsset) else AppriseAsset() + + # Certificate Verification (for SSL calls); default to being enabled + self.verify_certificate = parse_bool(kwargs.get('verify', True)) + + # Secure Mode + self.secure = kwargs.get('secure', None) + try: + if not isinstance(self.secure, bool): + # Attempt to detect + self.secure = kwargs.get('schema', '')[-1].lower() == 's' + + except (TypeError, IndexError): + self.secure = False + + self.host = URLBase.unquote(kwargs.get('host')) + self.port = kwargs.get('port') + if self.port: + try: + self.port = int(self.port) + + except (TypeError, ValueError): + self.logger.warning( + 'Invalid port number specified {}' + .format(self.port)) + self.port = None + + self.user = kwargs.get('user') + if self.user: + # Always unquote user if it exists + self.user = URLBase.unquote(self.user) + + self.password = kwargs.get('password') + if self.password: + # Always unquote the password if it exists + self.password = URLBase.unquote(self.password) + + # Store our full path consistently ensuring it ends with a `/' + self.fullpath = URLBase.unquote(kwargs.get('fullpath')) + if not isinstance(self.fullpath, str) or not self.fullpath: + self.fullpath = '/' + + # Store our Timeout Variables + if 'rto' in kwargs: + try: + self.socket_read_timeout = float(kwargs.get('rto')) + except (TypeError, ValueError): + self.logger.warning( + 'Invalid socket read timeout (rto) was specified {}' + .format(kwargs.get('rto'))) + + if 'cto' in kwargs: + try: + self.socket_connect_timeout = float(kwargs.get('cto')) + + except (TypeError, ValueError): + self.logger.warning( + 'Invalid socket connect timeout (cto) was specified {}' + .format(kwargs.get('cto'))) + + if 'tag' in kwargs: + # We want to associate some tags with our notification service. + # the code below gets the 'tag' argument if defined, otherwise + # it just falls back to whatever was already defined globally + self.tags = set(parse_list(kwargs.get('tag'), self.tags)) + + # Tracks the time any i/o was made to the remote server. This value + # is automatically set and controlled through the throttle() call. + self._last_io_datetime = None + + def throttle(self, last_io=None, wait=None): + """ + A common throttle control + + if a wait is specified, then it will force a sleep of the + specified time if it is larger then the calculated throttle + time. + """ + + if last_io is not None: + # Assume specified last_io + self._last_io_datetime = last_io + + # Get ourselves a reference time of 'now' + reference = datetime.now() + + if self._last_io_datetime is None: + # Set time to 'now' and no need to throttle + self._last_io_datetime = reference + return + + if self.request_rate_per_sec <= 0.0 and not wait: + # We're done if there is no throttle limit set + return + + # If we reach here, we need to do additional logic. + # If the difference between the reference time and 'now' is less than + # the defined request_rate_per_sec then we need to throttle for the + # remaining balance of this time. + + elapsed = (reference - self._last_io_datetime).total_seconds() + + if wait is not None: + self.logger.debug('Throttling forced for {}s...'.format(wait)) + 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) + + # Update our timestamp before we leave + self._last_io_datetime = datetime.now() + return + + def url(self, privacy=False, *args, **kwargs): + """ + Assembles the URL associated with the notification based on the + arguments provied. + + """ + + # Our default parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=URLBase.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=URLBase.quote(self.user, safe=''), + ) + + default_port = 443 if self.secure else 80 + + return '{schema}://{auth}{hostname}{port}{fullpath}?{params}'.format( + schema='https' if self.secure else 'http', + auth=auth, + # never encode hostname since we're expecting it to be a valid one + hostname=self.host, + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + fullpath=URLBase.quote(self.fullpath, safe='/') + if self.fullpath else '/', + params=URLBase.urlencode(params), + ) + + def __contains__(self, tags): + """ + Returns true if the tag specified is associated with this notification. + + tag can also be a tuple, set, and/or list + + """ + if isinstance(tags, (tuple, set, list)): + return bool(set(tags) & self.tags) + + # return any match + return tags in self.tags + + def __str__(self): + """ + Returns the url path + """ + return self.url(privacy=True) + + @staticmethod + def escape_html(html, convert_new_lines=False, whitespace=True): + """ + Takes html text as input and escapes it so that it won't + conflict with any xml/html wrapping characters. + + Args: + html (str): The HTML code to escape + convert_new_lines (:obj:`bool`, optional): escape new lines (\n) + whitespace (:obj:`bool`, optional): escape whitespace + + Returns: + str: The escaped html + """ + if not isinstance(html, str) or not html: + return '' + + # Escape HTML + escaped = sax_escape(html, {"'": "'", "\"": """}) + + if whitespace: + # Tidy up whitespace too + escaped = escaped\ + .replace(u'\t', u' ')\ + .replace(u' ', u' ') + + if convert_new_lines: + return escaped.replace(u'\n', u'
') + + return escaped + + @staticmethod + def unquote(content, encoding='utf-8', errors='replace'): + """ + Replace %xx escapes by their single-character equivalent. The optional + encoding and errors parameters specify how to decode percent-encoded + sequences. + + Wrapper to Python's `unquote` while remaining compatible with both + Python 2 & 3 since the reference to this function changed between + versions. + + Note: errors set to 'replace' means that invalid sequences are + replaced by a placeholder character. + + Args: + content (str): The quoted URI string you wish to unquote + encoding (:obj:`str`, optional): encoding type + errors (:obj:`str`, errors): how to handle invalid character found + in encoded string (defined by encoding) + + Returns: + str: The unquoted URI string + """ + if not content: + return '' + + return _unquote(content, encoding=encoding, errors=errors) + + @staticmethod + def quote(content, safe='/', encoding=None, errors=None): + """ Replaces single character non-ascii characters and URI specific + ones by their %xx code. + + Wrapper to Python's `quote` while remaining compatible with both + Python 2 & 3 since the reference to this function changed between + versions. + + Args: + content (str): The URI string you wish to quote + safe (str): non-ascii characters and URI specific ones that you + do not wish to escape (if detected). Setting this + string to an empty one causes everything to be + escaped. + encoding (:obj:`str`, optional): encoding type + errors (:obj:`str`, errors): how to handle invalid character found + in encoded string (defined by encoding) + + Returns: + str: The quoted URI string + """ + if not content: + return '' + + return _quote(content, safe=safe, encoding=encoding, errors=errors) + + @staticmethod + def pprint(content, privacy=True, mode=PrivacyMode.Outer, + # privacy print; quoting is ignored when privacy is set to True + quote=True, safe='/', encoding=None, errors=None): + """ + Privacy Print is used to mainpulate the string before passing it into + part of the URL. It is used to mask/hide private details such as + tokens, passwords, apikeys, etc from on-lookers. If the privacy=False + is set, then the quote variable is the next flag checked. + + Quoting is never done if the privacy flag is set to true to avoid + skewing the expected output. + """ + + if not privacy: + if quote: + # Return quoted string if specified to do so + return URLBase.quote( + content, safe=safe, encoding=encoding, errors=errors) + + # Return content 'as-is' + return content + + if mode is PrivacyMode.Secret: + # Return 4 Asterisks + return '****' + + if not isinstance(content, str) or not content: + # Nothing more to do + return '' + + if mode is PrivacyMode.Tail: + # Return the trailing 4 characters + return '...{}'.format(content[-4:]) + + # Default mode is Outer Mode + return '{}...{}'.format(content[0:1], content[-1:]) + + @staticmethod + def urlencode(query, doseq=False, safe='', encoding=None, errors=None): + """Convert a mapping object or a sequence of two-element tuples + + Wrapper to Python's `urlencode` while remaining compatible with both + Python 2 & 3 since the reference to this function changed between + versions. + + The resulting string is a series of key=value pairs separated by '&' + characters, where both key and value are quoted using the quote() + function. + + Note: If the dictionary entry contains an entry that is set to None + it is not included in the final result set. If you want to + pass in an empty variable, set it to an empty string. + + Args: + query (str): The dictionary to encode + doseq (:obj:`bool`, optional): Handle sequences + safe (:obj:`str`): non-ascii characters and URI specific ones that + you do not wish to escape (if detected). Setting this string + to an empty one causes everything to be escaped. + encoding (:obj:`str`, optional): encoding type + errors (:obj:`str`, errors): how to handle invalid character found + in encoded string (defined by encoding) + + Returns: + str: The escaped parameters returned as a string + """ + return urlencode( + query, doseq=doseq, safe=safe, encoding=encoding, errors=errors) + + @staticmethod + def split_path(path, unquote=True): + """Splits a URL up into a list object. + + Parses a specified URL and breaks it into a list. + + Args: + path (str): The path to split up into a list. + unquote (:obj:`bool`, optional): call unquote on each element + added to the returned list. + + Returns: + list: A list containing all of the elements in the path + """ + + try: + paths = PATHSPLIT_LIST_DELIM.split(path.lstrip('/')) + if unquote: + paths = \ + [URLBase.unquote(x) for x in filter(bool, paths)] + + except AttributeError: + # path is not useable, we still want to gracefully return an + # empty list + paths = [] + + return paths + + @staticmethod + def parse_list(content, unquote=True): + """A wrapper to utils.parse_list() with unquoting support + + Parses a specified set of data and breaks it into a list. + + Args: + content (str): The path to split up into a list. If a list is + provided, then it's individual entries are processed. + + unquote (:obj:`bool`, optional): call unquote on each element + added to the returned list. + + Returns: + list: A unique list containing all of the elements in the path + """ + + content = parse_list(content) + if unquote: + content = \ + [URLBase.unquote(x) for x in filter(bool, content)] + + return content + + @staticmethod + def parse_phone_no(content, unquote=True): + """A wrapper to utils.parse_phone_no() with unquoting support + + Parses a specified set of data and breaks it into a list. + + Args: + content (str): The path to split up into a list. If a list is + provided, then it's individual entries are processed. + + unquote (:obj:`bool`, optional): call unquote on each element + added to the returned list. + + Returns: + list: A unique list containing all of the elements in the path + """ + + if unquote: + try: + content = URLBase.unquote(content) + except TypeError: + # Nothing further to do + return [] + + content = parse_phone_no(content) + + return content + + @property + def app_id(self): + return self.asset.app_id if self.asset.app_id else '' + + @property + def app_desc(self): + return self.asset.app_desc if self.asset.app_desc else '' + + @property + def app_url(self): + return self.asset.app_url if self.asset.app_url else '' + + @property + def request_timeout(self): + """This is primarily used to fullfill the `timeout` keyword argument + that is used by requests.get() and requests.put() calls. + """ + return (self.socket_connect_timeout, self.socket_read_timeout) + + @property + def request_auth(self): + """This is primarily used to fullfill the `auth` keyword argument + that is used by requests.get() and requests.put() calls. + """ + return (self.user, self.password) if self.user else None + + @property + def request_url(self): + """ + Assemble a simple URL that can be used by the requests library + + """ + + # Acquire our schema + schema = 'https' if self.secure else 'http' + + # Prepare our URL + url = '%s://%s' % (schema, self.host) + + # Apply Port information if present + if isinstance(self.port, int): + url += ':%d' % self.port + + # Append our full path + return url + self.fullpath + + def url_parameters(self, *args, **kwargs): + """ + Provides a default set of args to work with. This can greatly + simplify URL construction in the acommpanied url() function. + + The following property returns a dictionary (of strings) containing + all of the parameters that can be set on a URL and managed through + this class. + """ + + return { + # The socket read timeout + 'rto': str(self.socket_read_timeout), + # The request/socket connect timeout + 'cto': str(self.socket_connect_timeout), + # Certificate verification + 'verify': 'yes' if self.verify_certificate else 'no', + } + + @staticmethod + def parse_url(url, verify_host=True, plus_to_space=False, + strict_port=False): + """Parses the URL and returns it broken apart into a dictionary. + + This is very specific and customized for Apprise. + + + Args: + url (str): The URL you want to fully parse. + verify_host (:obj:`bool`, optional): a flag kept with the parsed + URL which some child classes will later use to verify SSL + keys (if SSL transactions take place). Unless under very + specific circumstances, it is strongly recomended that + you leave this default value set to True. + + Returns: + A dictionary is returned containing the URL fully parsed if + successful, otherwise None is returned. + """ + + results = parse_url( + url, default_schema='unknown', verify_host=verify_host, + plus_to_space=plus_to_space, strict_port=strict_port) + + if not results: + # We're done; we failed to parse our url + return results + + # if our URL ends with an 's', then assume our secure flag is set. + results['secure'] = (results['schema'][-1] == 's') + + # Support SSL Certificate 'verify' keyword. Default to being enabled + results['verify'] = True + + if 'verify' in results['qsd']: + results['verify'] = parse_bool( + results['qsd'].get('verify', True)) + + # Password overrides + if 'password' in results['qsd']: + results['password'] = results['qsd']['password'] + if 'pass' in results['qsd']: + results['password'] = results['qsd']['pass'] + + # User overrides + if 'user' in results['qsd']: + results['user'] = results['qsd']['user'] + + # parse_url() always creates a 'password' and 'user' entry in the + # results returned. Entries are set to None if they weren't specified + if results['password'] is None and 'user' in results['qsd']: + # Handle cases where the user= provided in 2 locations, we want + # the original to fall back as a being a password (if one wasn't + # otherwise defined) + # e.g. + # mailtos://PASSWORD@hostname?user=admin@mail-domain.com + # - the PASSWORD gets lost in the parse url() since a user= + # over-ride is specified. + presults = parse_url(results['url']) + if presults: + # Store our Password + results['password'] = presults['user'] + + # Store our socket read timeout if specified + if 'rto' in results['qsd']: + results['rto'] = results['qsd']['rto'] + + # Store our socket connect timeout if specified + if 'cto' in results['qsd']: + results['cto'] = results['qsd']['cto'] + + if 'port' in results['qsd']: + results['port'] = results['qsd']['port'] + + return results + + @staticmethod + def http_response_code_lookup(code, response_mask=None): + """Parses the interger response code returned by a remote call from + a web request into it's human readable string version. + + You can over-ride codes or add new ones by providing your own + response_mask that contains a dictionary of integer -> string mapped + variables + """ + if isinstance(response_mask, dict): + # Apply any/all header over-rides defined + HTML_LOOKUP.update(response_mask) + + # Look up our response + try: + response = HTML_LOOKUP[code] + + except KeyError: + response = '' + + return response + + def __len__(self): + """ + Should be over-ridden and allows the tracking of how many targets + are associated with each URLBase object. + + Default is always 1 + """ + return 1 + + def schemas(self): + """A simple function that returns a set of all schemas associated + with this object based on the object.protocol and + object.secure_protocol + """ + + schemas = set([]) + + for key in ('protocol', 'secure_protocol'): + schema = getattr(self, key, None) + if isinstance(schema, str): + schemas.add(schema) + + elif isinstance(schema, (set, list, tuple)): + # Support iterables list types + for s in schema: + if isinstance(s, str): + schemas.add(s) + + return schemas diff --git a/lib/apprise/URLBase.pyi b/lib/apprise/URLBase.pyi new file mode 100644 index 0000000..9158857 --- /dev/null +++ b/lib/apprise/URLBase.pyi @@ -0,0 +1,16 @@ +from logging import logger +from typing import Any, Iterable, Set, Optional + +class URLBase: + service_name: Optional[str] + protocol: Optional[str] + secure_protocol: Optional[str] + request_rate_per_sec: int + socket_connect_timeout: float + socket_read_timeout: float + tags: Set[str] + verify_certificate: bool + logger: logger + def url(self, privacy: bool = ..., *args: Any, **kwargs: Any) -> str: ... + def __contains__(self, tags: Iterable[str]) -> bool: ... + def __str__(self) -> str: ... \ No newline at end of file diff --git a/lib/apprise/__init__.py b/lib/apprise/__init__.py new file mode 100644 index 0000000..f8bb5c7 --- /dev/null +++ b/lib/apprise/__init__.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +__title__ = 'Apprise' +__version__ = '1.6.0' +__author__ = 'Chris Caron' +__license__ = 'BSD' +__copywrite__ = 'Copyright (C) 2023 Chris Caron ' +__email__ = 'lead2gold@gmail.com' +__status__ = 'Production' + +from .common import NotifyType +from .common import NOTIFY_TYPES +from .common import NotifyImageSize +from .common import NOTIFY_IMAGE_SIZES +from .common import NotifyFormat +from .common import NOTIFY_FORMATS +from .common import OverflowMode +from .common import OVERFLOW_MODES +from .common import ConfigFormat +from .common import CONFIG_FORMATS +from .common import ContentIncludeMode +from .common import CONTENT_INCLUDE_MODES +from .common import ContentLocation +from .common import CONTENT_LOCATIONS + +from .URLBase import URLBase +from .URLBase import PrivacyMode +from .plugins.NotifyBase import NotifyBase +from .config.ConfigBase import ConfigBase +from .attachment.AttachBase import AttachBase + +from .Apprise import Apprise +from .AppriseAsset import AppriseAsset +from .AppriseConfig import AppriseConfig +from .AppriseAttachment import AppriseAttachment + +from . import decorators + +# Inherit our logging with our additional entries added to it +from .logger import logging +from .logger import logger +from .logger import LogCapture + +# Set default logging handler to avoid "No handler found" warnings. +logging.getLogger(__name__).addHandler(logging.NullHandler()) + +__all__ = [ + # Core + 'Apprise', 'AppriseAsset', 'AppriseConfig', 'AppriseAttachment', 'URLBase', + 'NotifyBase', 'ConfigBase', 'AttachBase', + + # Reference + 'NotifyType', 'NotifyImageSize', 'NotifyFormat', 'OverflowMode', + 'NOTIFY_TYPES', 'NOTIFY_IMAGE_SIZES', 'NOTIFY_FORMATS', 'OVERFLOW_MODES', + 'ConfigFormat', 'CONFIG_FORMATS', + 'ContentIncludeMode', 'CONTENT_INCLUDE_MODES', + 'ContentLocation', 'CONTENT_LOCATIONS', + 'PrivacyMode', + + # Decorator + 'decorators', + + # Logging + 'logging', 'logger', 'LogCapture', +] diff --git a/lib/apprise/assets/NotifyXML-1.0.xsd b/lib/apprise/assets/NotifyXML-1.0.xsd new file mode 100644 index 0000000..0e3f8f1 --- /dev/null +++ b/lib/apprise/assets/NotifyXML-1.0.xsd @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/apprise/assets/NotifyXML-1.1.xsd b/lib/apprise/assets/NotifyXML-1.1.xsd new file mode 100644 index 0000000..cc6dbae --- /dev/null +++ b/lib/apprise/assets/NotifyXML-1.1.xsd @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/apprise/assets/themes/default/apprise-failure-128x128.ico b/lib/apprise/assets/themes/default/apprise-failure-128x128.ico new file mode 100644 index 0000000000000000000000000000000000000000..cddc091515d95bb19f28cbbbcb091fed6fce0459 GIT binary patch literal 67646 zcmeHQ2YePq-UjKQR1phJrAZ4(NJt@#(0db96hu)eir|TY;?Y$Uq)10PNg#v{q4(Z9 zp-S&Xy}R%F-F(k8`_8_z`|iF;1naT}dPbC}Aj#P}-n$Lg|6>p{MYezwvLphS%~Q-uqzt?exGS zsA@fwK`2X6E~DH*`5xuZDF27@HZuPul}U@c1Z{o+v9({*Cf;Vea-G z)`4|lome;4@xgX~d!RBNHxy+n%3TyUEPmg!wQ>7q6zb&vpkxZ) ztXZ>*?AZ&6+__7LeEFUek31463Kk3#g$u`sB1Pg+>c~CM3lvHEVDevR` znSH0{de)V7X5HC_2V2H_ARjKRg>ncbQ~jSa=i|UOP82WxrYKi#u&7*lqNr9aNdyNk z6*X#X5D^hOMP%fD5fybz#KfF1OLX*cQM2Yj`S-A}jiP$>Wg;*zN#4)rlqm70d~U8> zPh_e-TK(A;wux!`JdZDSkR)(4xn2b$t=J&NBx&6+iv z$d>J4$mvjE@D=#@2zWS67KIo0nZ-IY*Yo-6ozc-p#B?JFtL zsOvIH69zq;5Mg1P#Iw(SEagJ>>;+xhqP3H4X4}~Z|7%-zH24*if1tSbRgN5m#gk9A z6BWT@At9;gYNlADe8Le6|JaeyU|8$EOo_U z(CA!Is#G_TE7#+$ZPVJ#K44$4PyQFSifCj4ifi9c#+NM7MFa&Ui_nnezId46#f!fR+a;f4yS4r}gJbs5?`@-O8;imiyH6iv%a#}ZRjjB~>1z>EeIc-441bN- zukhf%Rz!raM?XUE*W3&lyH&(QZ-;%q3;3sHi0|vxHRJ>L&gGQ(yBpT&{qBzzNC%;z zt3|P5Eu;^_{YZ|_QAhyU1E7k)T`$$_3InQ+^1Lc`_wU?SEmk3 z`g+D69ng0O2$(73O>TYS`JH{nzWXh2RZ;nUC_cFJx8hH=M|@^B;xoyx@8*N=!TZF& z#xjY2MEFYR`qkk1wXpXM{9~fG0RL^^{T-73Yt`B>b%4StJ#6pC-}x?s#?q$+Wjoq+ zLsZlu#Ha>1_KVhEdvVM@{4H+vQEA69Ioib)%6|d-E>Tnuo&&xI?}vc*Lly!5B_cd* z8EmIiQ8RKS_#XI2`QjfJyA!tGZrJAg!RKk|^0YtP8#G8?Cb->i(87*M!3Fdq`|`K6ah&`I6rXk#EEp-OR2~N#Zx*mmLacSJiGPjIMX>XhK;AC{-&^sI zj@kg(zX@`GD{$Wqe|{&*Zc#gaFL?2oXw>MGXwu}gg${@-OT&iN!fWlnxt%N8P2F~RLnR&JIa$S1koQ%qej~9D4o;A~ zU%fhHfAti|`vtJ^7J=_I{A;d~{2vY39|PSV8@pN5irb1l+%DpQe|-EN@%rn>#OI%1 z6r)C67hivULwxnsHPN%@Mc{iHG;x25dKPI!(Sh1K(Suh%IMag@9mK>Ohdvl29(pLZ z8yzr5g?;;L-s+-qKG@UmDTCNpwQ5s={S4T8v!L@0*&iB`EW&D}fbVtumxKRT0Q*(o z`L)pZ>w*17@D1_b2K;w`?{|q2BhHJ&#LHsbxc%azkLHLkzF05Mlaud=Y13|rH{Lie zX`pdqD;@A${icrfeMaa;$%~B8ftyUAZJ^5n>VtrQ>4I@D^rKTD4X}@Yjawa5%17qs z$nmHsTc)3=7VwP-tTqk$ZYJ=b4Y{8PeLn}-Cqv&8|ApZH#o+&?zP~QzZu8WAuRk`#%aQk3vRUFPcPVMfbUOFIA&k} znl|P-Tt`uLlzWc5mn-{6(DWo=KLxx$UE*Iocs8(41opr`jQGzt@sEsL275mh*sp}l zC;sbz{|1SFty)_k_qT~|-HwPkNtYo1U$VSUe}C1gF`{?xSz_VByJF6q+mH{JKnG_; zvu39pOY`RZ{j{wZ*OC?#T`0NXBqP$32`UC*mj_Pzfa8o0KU~SJedo~DUrT;TVbtZA z-uFDFOyClbw+F=f&W`OWgX#{5@yR-0&wFKo_KmMT_o=$&+tDC!7;4TAX$*qyu}= zX@>Gb(E|5w^x#hx7_kIjpTRB*n2S)bU=6c9wm#m5bN2l&xjlrJFGsP|F;AY-qC$nC z2KLp+`;h&>zk2X=*m|>o3Gq+T@LvF%e-W@J{>y>?3h4b+;QKX@`Rl;*8^P{1 z_6e}}4g4d*Q=sz~z}H_4?3YUXW1?3;?yrK(Ujx41VB+7T$qq4V<|XmUE1y{2X|IFR zdA@x4r5sqi_^$ZFA1;VitxmfZ;;;U$=tR+jofaq?lpWzF52zRX`3ZJ9pgz#;!qCta zum_5|wR!k21b#cb*OGsmEn6N@y3{+s{%i35ILLhi|KOl+!1vRDJ@60p;vXJ17kru=S_d@HcF~i12y9em<~Y2)|NI%yfpcly`ixM;jV}Dr zfKN`^EMU0)e16iGN_V ziO~6zfxQ*~Fv$Lh@VO{f{9|I4nfTYLwFdUyI`I8^QNP|s*luUR|MOuJ7y427XV0Dk ze#1x${%zWv5$P30ABq;Zx6^}vIxu_(-CuB{0Vlgq_YD{yC<}iI{qIyJ;rE}z7U1}_ z<^2ThVv)5G5WcRMY( z(ScnrD4mcV4Nx99(SU9fGUu>p(FSh)&;)((Q*OENaK@m__j4?#T)DpB{jb3Lko~~F zTEJNNdgH=nojj#yM$&zj5ETtvj7K$pj}^z_) z5>uxhlGrax#h;gEMxgOkmFqOxi7%`z`ru_9|_q%8gk!?e+c-$M(A{4KU0L)m@V;- zjL`94gcv@tUkbkW;$OesTJhzV$Kk83#r&3 zu`?Zz26X&+Y>yo%eW3e@PBvlsw!mTd!d0C5VF~aeX7_9Bi3eHUQ>tWVQMuw!U_T7y zD_}nou^xp#Y(FXgCqwQ}wcsBakqCb;*~9+}VDB#k_KTtO4gBlYSqYnOwP^p!7BP3u zSt+~i@6PD?lTSW_F`@%v`SS0i-PftpInl1&*$fpY8c@GkX~CHe($fcSG@yJ!?%m>q zx*n)fWfEc=xh&YAe<%a)*BDVjJnNd?=LHLfi03K{0q=id;$IE;2L*cZr|lOK0^SGy zad8Qv&GU;y#}29Djn`L;POq;KuXb1?TDE|_7q<{Pe=+R+rBe3St-A{T-Wt*O;{#&; z{EOm`e@qmQKKi7+ed(Sv-t+3K{ek`WvJ4z}MZEm-x6u9R7hb0qon{mbsJ%`LcKg6i z14M9wQsxu?*|TpWPvjh8NZ)3vC|Xgp zpy)thulA$?+6VL@xObuhT_;cHPVph#CkzfwfegswL<7};-Mwt2pBpIV zvkMjo_V7ONuWac40Ez!7@IUgmL&k}AFV2J9-6zJ5*&zD%osK@~iE+OA@Dl^YGf!8- zoVifQ`?lh<&lZZgb5Dx_{r5uFEjRIR&|s}-)Ns9c|NZ^&*)L!m|1!pIu86T?H{&@C z;rpbUM=u5qnlDmQ$@|~QvEL3Iz7?vx`s%lk4ZcPFoqL^DGC~JR4k$ZOrvWFsP^ST7 zeA!9^cAvm*3+OU{V+D^r<`^4UeJ|kS-sU>?)H6>%-Cb0A?o;soP>Fv)mEquh^8YCC z@Dwp%z z`>Epf*ES<>=d_rYd=4~pM)d1<7`F3f(W3b_@zz`W;0v4q{@2A4l%%9H;)4$+V|?72 zCzCH<0X*ZVSh2$3{SQ94DDiylwQn*0lewbkM9~8GZgik@f!ZrNpgeG+0lQzIPTG;ve9_zhQ$ZV&cSIz-tEZa2=;K zpH-@4dGPZ|;C~vq97mATu>$fxRm_@o1~Pvfc)lm-;wZ2@aeiGA$YNe_5sz-A{q;4i?j=#LKDp%czQ7Tki3cN=)$!8KPA z+nESk@3_RCdcRXAg{8vQyQe*9FNz+#dv(8eZ`Tb<9=OqfVHa9$Lc1=o(tvfmh_RuJ z$^g!XeG=nvI&L`r4+{PF%xq8MV$0Y*ZT^bS8TePO^f|_QzCfIR82WH7?D;*I=UUbN zUdQu#_1ZypAD6O!%-4rtGb|Bp+pLlDyla=CKKE3uT2u7tGgT}GzSRFihMWQ49)^8z z1Ulh_7&GQF#)WS|-rq&}PGV2qA2{%`$=`OIdETkhd5J&w>R4UJI00#Z=Wev1=s>p* z+-Sf_7ubD6yFIAufXv8%s#T{t#RJv=&&-Sqe2ME#EI8IsuI$I)ed>Pt{lLG<7l`SN z6cfhpfNbt;UaQ|{Kc*i)bm(G3_9vebty`r+?=J-}AAp=cjJ24Y)p2Zx1%NQfP1(|XOa{#UbQv*MR?~mOpnozVr+;uxb*#=4u zD1AWNQ0W3)57_+zr3(xhVCVwIhICud9vgJ>2USd{X3bq#AElZd56&OS=sYsg$w3{v zV#OLk_7Cvj|0!hu=aBthh|X`#6XVBi5D&xV(yw5*7xv)=u_E;j;sV!1moDGB^1Olfc}W|j z5508md|t5VzrFRAmnQ7=;FSYPHyH9j*9VjZPP)KJ50D0Q8KCgDj}zH-fIk^fz50Bo zwFXkqw>eSL+upzhChg?S`-~`G?juoA+J0D5trBy(hXTKe@U!+Hx69oR*Pqk9`!I=p zGVK0tUDqStyHIrLyaBrVENs5&=KJ(}(mRfbXawD{Sn7U`?T;CA1Gd6t(Y5OZlh27I zu~j%{Y;Rm|(1al~beUk41z!9W4JcjUL<1^5r27N31#}wl`U6%yke&?CeZhSBeDb4j zqCeAHcZ~Fr?Ctlg;?W{8QubGTZjjXdRXq5=_2yhLdh|;1%rmZY6iiG50%}4pABVg? zfqV9Yb`~PeyI3U5J_)}4JVWxsTPH7V^0&Ns^CK1%2mQZTtXlPhjQh|A7&GR&jQi<$ zcjPjDO z++~0jf4dB@$Agp|$TiX&`t3u1;Nx%1AvN#Ip8a9)em@KT0ac0r7l_%fgbq%G&F+7V ztENpmiNu7%ko{?5#8-PU);CYIduat?I459J^)uh4zxQ5`bL7a0@r5?PU>307jTq56 zv1ZK=V(r==aLjo?H-XbN;B^`Fa#79~?$P4{=9XL#@4RD_x8FXGSd&pm59*kEUh{`P zT!IgB74fT^(&u2jF(u_ZbmC$7Atz+~k-6rdd~(^e1$2MF&;wo>p!UI^Y#!JN!}7!%xySmC4QwfcMS=W+i0g)#Ri2J`yf1cqNgPs|5K z`ym^yi*@UMkcIIA&J*Ok;iRNH!0$F{-_XhEM4A1t zb^zso(gC_ZsLKH6j_Njmt^@q}0RaKioa)8cp4-N}5%ZpBpLx%Ue`VVKz(3#%@X~xS zeflos{6v}8>-SPGBtY&H|1o3s!QPt(e}5jvgbzaYE^@p_zdtj_g$oxJ6)RSREP4#K zWVZYI?99}|e(htAm4I)$RmuR`1IiyzG@xt%6%QaCv})z`1(Xb+EvVyf_XX|qij@qA zh}eR@$ZGG87O1DwM!GPsd%1piT=6wY5&Set+;lr=V+#|aTaPkM0FR1WWxnka!ur)u#4gaX9eNJl& zjY1ooHqwRJS9uZhHFUor|EmE1s?zowDq}yvfg=$2-z$a>TP(-&^?O4@>i~C*(*gf4 zzt}12#3#Y$OM%UohB#ec#HW4fMZZ6@$2oH35ccgK|M-Xa^Pm4LPMtb+-`|(f`Y{J- z^5osp7UVhga*>CuM zwEcAa1FL-{K6rmQ^0@XRU%Rb&A9Hm_k6tbDPfR$9ahgTo{Uq?gF4)Q&U>}t;uhrjo zugApMZOflO|4;JH^y=KW@#}ItioQYLzFvP&$6wh2N(bmR0O>%-cQ?9f^r2CI0i~-I4Jl@;~DNI{t6~6?e*h&(@q=L zo;9y6U8<88fB1e?t9X4sj{62d{)bc_E#B*~4A>rkUzH;B`{?f_B_4t8cLaPt8}oG& z;rH(m>I}j&3-ELY)=z^+I~9zfdRuYuX_aYd`H2jn+*GY2l6fsVJvu)jP(%z zH{V=_IeoJb?}PuJl!p0U4a{fj@3~*cHEPsw#edeUS($!!SXh|&*T4Sd`b^S>TixmZ z6L%KI61sF@evyH{n+$m21#2wGZUdK*%@1_Lzgo3f_OYPG)8bEm`jaDG-+ue8{1nXZnkMm2fc@XI=SUeNQ_udX z_IdN>g}s&N7dNLrl9%Z_tX;bnb5y26R`n4F4<2;7RQ`75$`!;Ao#!7g$6>;RtrC0k zfA{VeT=CcA0CpQt_W^VtkZ}N=|1;tPhJ~#|eJws0@$ZD9Z zTeo(sJM~JNHt%9=;CqQbbI{b-pvo6>vjNk?U-tny9;|GDjNl&;u^Ig5Ge-ZO4nB_S z==f7!lrKm8-F^Syz)|4;ufhLg!T$#S;bGrkJrCkP9sEC2yxnaTV*P2z^*n)C;ePSq zhZ8U_%OZ;$`%=+iX7{wo|NZZO-!G_roXGdTbE0XQhD# zPGdcfGsqj|cQ)f;o4T%d?b_E0Z~LCH{?n&VJEBRw-K9$x^z}f@Et&=yy-FNCdQ{xG zbI0*|`;RMEuEhLP=RC(MRU$mt{~+g@GRH{ej2ii(-rOK%|55)lpM+!1vu2${Y;+gK z(~co`FGRA#n><4pwmF*u=eVoVk1O8#aAN)_-KRS9Q#!3@S{Oi=7FPb%7A_flF zCG)-(%;)@`Z$6LX^dr$Fm0G0sAHX3m2RN&m5NH z?CIqap^o-;_?*v~Gsh9fyLazmoWj`#<@f;o0Jn|u!tLFh+ss@u=7db2eoMxARj#N% z{NI0{Ymwc?y4Q^?&(4`M55_MyLk@f==dN;{$`&n*^<#{jaA*Fv`+o}m8Z}nJ|9e`0 z2krm5DEd|gCuaPIW54CfegON=ia&k7YKZ*>>HM$bFXg{s`^QAjcErCy{Uw;&y$p8D za`;9Y#k8p>F|X$W%0%{d%rcBu_bIXW7^TJe~=qk?Yb*tfLwoExBs~wMRM|a z#2+Gj>QtyuG4RGMdGC-RSIqf={``MC|113;f^Vti%4JzUgZL9~edE~w4=83`$~=qQ z4-5XrxUa%r`TilmzlO*63lEzL`)?Z7cJcCmT-;ol^Jnn?B8&qY_%~{l3L036xu2^c zGY>%Lod?Zd0#96qtuVqIPtxm@k>mLIcxmgI0BQ$=1`RUXr=ArM5TIS*dq|r>U90cu z?=feHIfcwEWIiEtMZ0%DFZDNb0mqEl1lxBD{DRAJ40z(in~>>7{D*cP=O{L9>O6Of z-!-5ukd(x=E6z$9@Q1&h{}uj0K}oP%EIteIC*Jn$HhwVM`pnZ^fxnUSTN(2`ne(ss zpKE<6-!CMXu^+?tH}Ib>@i+KC3Ho2*zW}m-5%PeRATMJXWB~DBCGl_8Y#sV#JMzg+ zpsy}rj@K3R-8zgpWPaR^V=}b$oi>gUsu-VYYieq$)1~saojZ48-8tvAhKm<}R@Rf) zQ&%%bv~%Zk@;fw~w;UK4YxZSSRCDn1HAw@c6UP5YE6gRFGv_?kNeDErRrTUpCj$pA zlrn(hN7MoS@OR^Xhc#pA=Q(Ybx&FnUdI94<9{ek7_+#AP7k?x6XW%~r_|L@H59NOn zY%6E{8%zEN{!Q12=FQfleVgDHZb1zB0OTU)cU{9+(lPk2jeYtCmG}D_Z9S)L!-fsW z{qyFWGC$~tAAWGUR4{*}Lx&Eo?_nM(d7n1lpg~t8p53~gm$`nOJAdwTUu0xcnFmC@ z&hHa(40!nPtC9w$PCbkq72mwIkdS(yt!v05y(9AmiNA{daIeRIblYE#|5mIx++G*j zc~0AU{AgZV^wC;g{5|tMY5P?J{)YZHVm~3l6Eyyx2Ki6?XF1|uSMvW-%==m{8aG;j z^*wy?f4=p0*adsQ7ndL(ugek>)55HmUPrfMHye=SKq?=M@nYtRxoz9GZ}*WE>U}k9 zHa6uxWBl~@$j{6T=b9sJ+Vt?LFLOk@cOQlJd@t9Ze&v<3ps%y=MQ=(Pc<;UOKKF3V z`XNJ>OBq0WLC*uV`+mCoSL462v1yo}^S=GLC(sV3Z32EYug(8RMXdE;%=c6CeHH%X z|6m#W8z&_GA(N!Youg;Y#5A3<4 z5`X6PFt3x?)0gKw63#>8JY}ajk>$z-L7#ssX@IoC+#lKtEZtbDgV9rFGP-lfq#Sg!ONiqAu{_GAN`oP!wPTG7^r{0vP`;m>@2m5_jz4XG;$H*&&#}Mou<6LBC;qb` z|E>6Qz9;8<*RQt>KEQIcgYthh>_0F5t-$}pzs>VIF@FMUNWHWVHueG7RFtc?(MMP0 zx?%Qu+Rqu^aod;+)T~)Ew?9jnz_DceGkMP3Mdmfp=3`zzd01huVm`flUzRd}>r{n= z_{JL=Gd|g%njh&l5yj9gI}NVdH2^?SBdzK9(}{aU)TLk_%r{DYXNY* zK3iLev)g95wnXs`9{jn^C)a!NuJy&W-VFYq2>zc8{HGZBhtEU|nB)Hj{&BJMr2IGV zUxIu(;-3op-{60RfAi)Wp#!$U2HXLew@bXxmTQRZL(bqq*vp5oPT(cj&$lHVP>!Mj ze#`#-`yDan7%y|h-8RM#oSsd2{?=QAO}(#dKH^Wl*JC~-MqHQl(Wj4Z+^9r}a5yo&--6}b&$kAA>oe1U*~cE`up+Y$B{4X%==u^Y|WbQ zCGRID-uC!=hTd1auVVhR1-RDqxN%#NGwM4(mftdo)p_y7suf8swEd0@$~0q4X1TZ97suWRss!v?F6%Ru?R4*s8ke+%+| z%k5a>X&3rruXyo={g96e|D%YXoj{EBI%wcKjFs&`4Bxu;8yaS|w`0eSt~fjWfwo|` zZr#lL^=I*T`0!MTJ#+f{_4C?%cI?%74`aaz2`6Pvto>ONC+?N_bG(mZ|0?g7zTqdI z%*B{k9!oun6nRqWhvekD(#F*BC-2+wS95?17W5gj7=ya_+oJJD^WBUCmo3xFSnJOL zf3EYc@UIb~qXT0Sx@(Ech?SdR8mo0|Ng>YY+ok2ODsMwEqnJvCdoT-RP6O z;Eny5D|`?-z~KK59n#SLGmxLRrH%E$2a{aagk{clX0{_oj=1La(D$IuXW@5c>F<%p z-LPlA2W>jWcPm!(!2mYxR*C<_i8mzwEBrZ6eeBp>h}Zk*Y>7Yf3O@MYlEv@W{XY5t zD(+9)U&qpOY>)ffX`u*y_?x;fYu0Rnb3oNP&sD1!u^)Y%XWIUuo^_tXYD~v?#!Sio zQ8kHwGWdTU_rLU`s3GxR55Hz3@W(oj;QyB7e+~baUOI#|W{yDa9fQA| z1{rV?bIS}rn{uF5E$4Zp9NW8j^Ja!ffc{?Fw%$1`s{Pz^eka!`WqhCh9{ZozD}AqI zzQOlg6UG?p{qoCelK-7zdy^;c_xOo7r0ggD%=ut!kZT}vj;dY<9w#MTmE#GVv!0Q> z&q|ei{149kEsElA%Zop*LNVW;KYvvd|0?*#N3{U8&NJ8fr~LQeA37B_AnbpS{I3}~ zNAiDcObYb>0u&iHLcHqINg->=R)cV-^HG3R^y_!~ZaZ^hp`&QITvb3cm~ z^_?GY=zrP=*G<{aoPUl9Bqv|Nc#|bwFY7jQ<~fN!=Mm}n*QsOm{gv!5QY6;irrl^0 z@%6Xy`%!;DG4IWV?+|k=Sg!L={Hq$aUr?a2&NsjDB>6wo_{K-L#Gh;Y&w&m|h7FJ+ z^*`5muTy&|@Lw+RZ&2UBzi}f2|7O6yWs7Z+|68@%1s`x9`o_TjrI(IkJ@Dg@0Vlx! z#GgJBzyEww(!hWLbIkgxx--r{cI?=6;b%;Ub9>Z%`krf(Bqg1cyieQQF7w^6=XKQO zAAUH+=eeazSHk!Z;lN?MRJXQ9sh>lf8yVy z$wt_Kn^A~A))NH&ty}Mb4A>7Da0oWwQP=?n{vA3P_*2eu%!Okwlmnb=r(?%s#`-yj ztA73ZklP?f`n z*{CBdYrhsM7%FW*e&dPw2UhzUYc6@e@x<@@)$riYZ~R0^{O5|O$a&!Z`LF@-jSmn0 zb?O-Fe3SpV&U@oV8=wO=!3NkO*LiNy+=_pjHiw`CfPXvUe-h<1WWZTd&ochXnETwh zXE9b+%%{K67RTRcAGB)KO6CDMT~oGfHOcqPM=>$3geC?QLevKRw-# zOU!K)j?8D!4`3{i-}vD-zW9wN9e;k`Q{sNf8dXAJjF!M2MzEWABzyX z<2ODG{OjWzFAW>4Lp;O4ze$rVhyiSe4j}%!!T)>-G-TU*6aUcj>nDLBJ;SHE&CqdkvDjs`8+Jf+M>q0`F;?^Z`M+(OBai{d(YGfd15QH+oR$5+d@2^og3+U|OFN)br-454 zGrb+@9TzKBO41Q+3epsFx+v?14(0mOcjcV!p+lD-4s{5aTmvu4*A34&n`<2`$4RC#k$&(LbzJ5iYXAB;kD)CQDyaQY5v}LZZ?(=iJCvV;| z_WOzBkJ*?bYI)WZPqad=F2Cdq!3LZQ8DQWajqkg9 zzww6e`_<+*zScmFt%D9Q@Nd%Cz`q&rZ_#`=_+lS)zya_G_#gPUZF>wd;3QRvmlBfalD)4f)_ZUXVY35yYI%O8m!$bb`|fm0HHody&geER7X8Kddgv7cFI zz5eMPSFBhAynjZ@5st~LSPsW#_$@N7JDlD#?0;tt&w>TlCH|Bj95*8NI{y4EDd`Hv z3+wqjKR*5y{Oy{=pKBGm>3xp(<-xoi+xwZ*cQ0Fe+^jwGA&gJ90{&xR1B{0sfbY8p z@%!H1?>pBBwc=khlK6YR?;8vJYw`P@D-j1+4gA-`2G|H2V6()(N#h;R0lQ!a?g0(# zgA6z*`TzOnk77K~i$BLqiNB%&j){;4XoneV^xby!p{#Au+hzEB{LTRRo`v5CR`_$h zNXBxV;xcZ}zX z^`dyc0=~!RP?rw)?Oryn2YVXDe0J{KB`{y%Q{X=VI$#p!0U6)-Rp0jx4W<3Z@B7Y$ z4oE@K@W=O^YsK;V&iuaXYS8jJ*nz;m!DiS1Tj2|ChYZ*S8DQYwvc*C4*J04WQP|2> z{7D1Mp|jHgeQ5fF?c4V?>#Wz`?U?zRjPJ5A&Zqo2>KFQDix=O33~QC{GpQRIH0X$& zg-vn{khrrjhm-ccviG>>+#bdN8S^Pn;8CAvmn#>7H71R?-{+rSbLIW0sJ)m!;xoto z9NL=Rde6AT@0JyR?TTWl+fz@rg$x)k@egvqKdgrFeP@Ng`o4Q?Oe*Yvm5>2k19&as zfa@RwHb4e=@CW}lX}k+`z87Nw`(XzjL?0an4IGvFzipe7;HlHF1Fhfh)#-paGW3-b z5>B~}4cXhn^O-Y`N$i6&?Us&S|^v|nRi-B)80`ENy zTDfKFd&(pF4JyW`@aLRf`he64^zZC-VyvKZ=aDAww`p_M!uQJNr`#`Hn&Xx}YpM}9 z;&-oGDLfzz#jIm?=7v?oToCYoV6|_c1C;!qg*ZS0au|{@4wP*2f6d56@N1SL4q)J4 zD{eK$sn&o7)|>b@Y_JV|YvA9c$sWjn{h)zE68~1Mj-vn4r2Z%V95dBvfcUFIc`#|x zP4w#x#6z6Nbo93HxLv#6QfAP8WbBTZt3utvLfcz?OO)4<9vH{tUKRQaJSM*rcdkL+ zzWrGrnNRHL11wl@8*O%;lTxNkkcmBcpD`a@?kj)aVSNYU#CQ*JyI)%`Jm|kDCJhuR z9IfF$4Y@!wFb~X&e^`xV=m5(9h42Fw!w*~vKhVHGE_OArSu63cTXz%Mwi!CWz`rs0 zU*X@pIq+|B1Tym&`Y;Xh^CaZJDT%+DGq2Nu(g}>!5`#W{rknNE>#iR&ABTP%v8CLg zU#>7$x`lhjcj))iepfi_xbrv0qB%ay^}qQX%6q%MXY9(bCvSnadpO1la_4>+{_Jjv zKk1J3(Xm&&Pk+C7@%HvM5+~wyKez07m_-KQTs#fA6Ig8uVnNfe_OXut9QXlqF=sLb z*n9Dhs<|98U={p;HLwHL!VcH~J76PZz!vDttti{U54(Z?Ug&`RkO2o_2OdE`9Rm%d zK?a?!vH0~74n^PD&lulv1K#{(_zqaLVSnd5=e;0w-x444i1pNO^*|73~3 z&i_$SsgMCHVFM8Vb&vrY(6&vmYqmfJY=;ck0bgJje1W~70R#W$&G`+;W1xW(;H6Wt zZ`r>r1|1msz)l0?v87AzqAt#P+-wWy9UF5^xR&=Vi%zlQuFm-$gZ32tdDm@H_0s*j zwrx+tw!Z}1;kMKX{rk_y+&1^TrFQN9h_Q@Y68{-9Zp(O&&ig9AgK_?xIURG=h|^EC zS;htM60{`@GGMBSe`tso|M0N+umcuC2N?1{8u&*?0sk1tf8ZZqYa?X9Cg_39kOA9a z2O9V{YP1LKJ^&eT2)-cjZ+;9kkOp4DH^bmNtA18AKsul-P&B}Oj~>SSs`l+aag?!K zcc5?I1XFi&jW&g`+n%_yv~Pa~d0Llb{FiaTK7B4>?BTrh<=%MX9P+f!%QYS7H*yXi z?fa1jy73jPmEh979)9}&J7xyZ|ay@7vB^lHceh5u%Z3vY!S z*bZNC2Xw%0=zzVD0sBD%hoA$FNd71OEnA+H_>%@`PwRA`Xo2>j!ihPUTvLJT@alEu zF~1S4^f&!-#ubUXPDA!%KAXOQv1SA9_l?LwID~V<-ryL(!iBft1Dun2Tz~vyGH9hA zI;NSQlXy7nt z;27k9fxq&fxObxiMGK4v(k^4Z9rXa?w2a}jY4aXrtRWxR*POR$$C~GgjwoMPXcKS@ zu3x`-mDSf$To~bHn&MA+N{iPS?diCfr*@FGM?_Tu5 zUd4HWf|BvQ0iQXk#Dlo}T-#W@$kL7+ISN4!&<{w$c+gzP01bc0|Hz1C$O{GkN3Vhn zVBjAM{NrLbK?iJu4%mS^>!G0;F7XyBB^o>H`+=)f)$ zUViy3WbbY13oKlC6ZZL4*!c9hZ%eu$ZG86G6^XOLSnchyh3E7u7A&}lJOk(NRNDI_ zz4NkVgQcBMzk%^R=67(s$J8~drM6GYeudF4Xl9;upTmaBkZII^bU6N573e8RIM}7Dt^-|6Ju?scYT#q$?JV(TyL!7jbsy^&ss1 z;dIWpPN71NW87+psppA3W5BPzdd>%XCHG@vPvAbs`S8Tyr?B_&Sijpd_$$X3EHqHK za1``F0_=c!hzBfyAK;6BRMcw704x5rYHf!M*eUtHUfun$2M>Y<4nqzc1r6ZaK~2*j z15QdAV62B@#7`Ce`kwgnI~s}}h+VH<7v&f(?KQ^p7~j?L)@6snn|nS(6=Mwe4%Q8- zY-zK-51h`acdAv3hmH6dVvXlb?5R(N4!wr9`NsBDj3*}MDD;Wr9BB4GarmXS7`)hG z3*a2zglh`?Q!t*j0D5;3>;T38D_{q%LM&(v)`(kY@PFK9_ySvD2W*EwunTfvH)vp= z#J_(1!=Qnqpn>Di11C^UqMwYlaMYSe+*2knmd>Knf&Ezd;|foG&ujU2b&dUAo^y^4 zq5Tx;#suGz}*dd+WJN=m(Tn?(gFLK{rzj(rlCT1 zIu*qpYYclZ1$tmPY{3<<1y>;^O#IgadjtQNn603J9gqQ#|M7c)Kfft+5alpr<5Be2 zamoJ*f3+taa6L?~Oz_nW3O8ctRGK$;{#{+G?&qGmM-|Td<@Y{0Hp8{PIG&>l*Z!rg zz}#exN78Rpb2g0mde*TV#q-3Se9t_t5+!`|***Qse*QIXv_VIp_|QOM*sUR0hdwN9 zCG@~*(7;;Q0S5jt(OaNPfq(1{6!Jg55gPBszi!=Qpn>D)zmw1d-nH)(4XC|A3s(JL z$Og&?;^yUH9aFbsb&tZE_mkGBgZM60)ce(*GF_jWuW;wyNzdD{=bZhbMO|ao?B8F) z*3T%t<;I00ooIl0Bf&v)CH~>zz&~<5WWYxF0mOfsiGN(&9@tF#AOj%(>l}d&ApU76 zCrut=AG4@Fx9=4;#LAElN=9(6^oI90Vr%^F{f#okus4ivF7TQ5_vq)u+zodn?-kbe zy^8H{-j2gs-HM;suk7Dn`<4wAoPhEl6jgV*f9Rn+SW|8o^uRjEfDI^{5EHcHzZdr4 ze#pUtpn)UM0lxUNKUIJGqXUJHq6h93O( zyx`yz8ArAEnWrDwpX}G)(l!{C^`U{RS#w~#;7!;Av;{W79^3*N*ajKEZ^rBf?|bo& zk3S4ua1=C<207s6f4z?t9dOTYBeVF!>t6q+-ru7CDf=QT$> z{l|X%t!~ew;(tc5$N`=gC{P_agjg>W_}7fw4qdPly1;{fErb8#{l$L!y=(>1kZmYFF#`N1*Qe(?wUJT| z?34aaT#e`a+BqcL3%-dAf=Y*e~p#-}@GV2J#!ks@;}-jvPhgSVwR$<_?2jVq=e? zoRIbs<)>3$`}@s{8R_Gs)ENp--@Wm7UZ)p-&)5H^^t-a{DCf!VT!Yj04L(boJ^jIc z`Cr)>!+0IVQ6J!U{1kPD~GVOx*lw;#D4 z3BPGntXLDQ-P9klHr0_9NDrzg>=ceVuJ&Vft&TOHqv(w9<9qr3eEG^l#^>}~Ur&43 zF1C;De6YFqKu-Lr3JT?8y8X&{BJ)pk<$40+CUudE`3Ytjdp?KH<8zA^ ztuNN zlIQEsCA35C2%UyO2_d!F-m{+-wHdfvzT`J4yagC2O$0}p!OK@U9Wf!|FJcoxF$ zpQWF^B=+~nG}F$h9kUeh{(wJQF4)NPEZ)D%(=0LShrb)=xzzc2<2bk3$g6cz~=8|L&>sMjp$}_F4JkIQLQ=@tw=y@D&GOky*d%DEDT?l!D_j03;V?1x;4sF&?wZEiy`2Q)@ BZX*By literal 0 HcmV?d00001 diff --git a/lib/apprise/assets/themes/default/apprise-failure-128x128.png b/lib/apprise/assets/themes/default/apprise-failure-128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..f60f33305a65d32efbb7a0bb2fac1f1e8420e4da GIT binary patch literal 16135 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 literal 0 HcmV?d00001 diff --git a/lib/apprise/assets/themes/default/apprise-failure-256x256.png b/lib/apprise/assets/themes/default/apprise-failure-256x256.png new file mode 100644 index 0000000000000000000000000000000000000000..2d4ccbbb1d7589862005160590afbe48f88e438f GIT binary patch literal 41931 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 literal 0 HcmV?d00001 diff --git a/lib/apprise/assets/themes/default/apprise-failure-32x32.png b/lib/apprise/assets/themes/default/apprise-failure-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..f25bdfbb92e1d61b7dbe373c56be59f3fa4635b0 GIT binary patch literal 2437 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 Dsl21e literal 0 HcmV?d00001 diff --git a/lib/apprise/assets/themes/default/apprise-failure-72x72.png b/lib/apprise/assets/themes/default/apprise-failure-72x72.png new file mode 100644 index 0000000000000000000000000000000000000000..5dfff757d059be638e128783c8462c5115396e49 GIT binary patch literal 7600 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

-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 Ohi^7djlW#$@c#pV*XMcw literal 0 HcmV?d00001 diff --git a/lib/apprise/assets/themes/default/apprise-info-128x128.png b/lib/apprise/assets/themes/default/apprise-info-128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..cf3ec321e6bfee0872d093c2613014cdd52968d3 GIT binary patch literal 16671 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 literal 0 HcmV?d00001 diff --git a/lib/apprise/assets/themes/default/apprise-info-72x72.png b/lib/apprise/assets/themes/default/apprise-info-72x72.png new file mode 100644 index 0000000000000000000000000000000000000000..cd9a357e16114a208e529b273140d48fcc80b7ac GIT binary patch literal 7875 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 literal 0 HcmV?d00001 diff --git a/lib/apprise/assets/themes/default/apprise-logo.png b/lib/apprise/assets/themes/default/apprise-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..aa6824bedb77227de0d7b3d714fc852fc0cdb25c GIT binary patch literal 160907 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 Id5f_B12U>RZ~y=R literal 0 HcmV?d00001 diff --git a/lib/apprise/assets/themes/default/apprise-success-128x128.ico b/lib/apprise/assets/themes/default/apprise-success-128x128.ico new file mode 100644 index 0000000000000000000000000000000000000000..5a944f91374374364533348fae299b30ca4c4dbd GIT binary patch literal 67646 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 WaJy;a`u1(y;hX)ZzQ5e>@c#n>bPc-z literal 0 HcmV?d00001 diff --git a/lib/apprise/assets/themes/default/apprise-success-128x128.png b/lib/apprise/assets/themes/default/apprise-success-128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..21dfd83bfa43604243522f2c78bcb3e457c3f403 GIT binary patch literal 17446 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 literal 0 HcmV?d00001 diff --git a/lib/apprise/assets/themes/default/apprise-success-256x256.png b/lib/apprise/assets/themes/default/apprise-success-256x256.png new file mode 100644 index 0000000000000000000000000000000000000000..5e2146dd513e93cac307468bfd8c0cef3ab16010 GIT binary patch literal 48729 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 QWdHyG07*qoM6N<$g5(YqVgLXD literal 0 HcmV?d00001 diff --git a/lib/apprise/assets/themes/default/apprise-warning-128x128.ico b/lib/apprise/assets/themes/default/apprise-warning-128x128.ico new file mode 100644 index 0000000000000000000000000000000000000000..c636a514a78c58475ed4ea382901a0b444c1c4f1 GIT binary patch literal 67646 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 literal 0 HcmV?d00001 diff --git a/lib/apprise/assets/themes/default/apprise-warning-128x128.png b/lib/apprise/assets/themes/default/apprise-warning-128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..8510938fc64217da89018a40499e267ae82578ff GIT binary patch literal 16784 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 DUUM92 literal 0 HcmV?d00001 diff --git a/lib/apprise/assets/themes/default/apprise-warning-256x256.png b/lib/apprise/assets/themes/default/apprise-warning-256x256.png new file mode 100644 index 0000000000000000000000000000000000000000..3cc6a2dc124ea3536843469ccbe680a9d9286d1d GIT binary patch literal 43708 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 O0000!JbF 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 T?2w8800000NkvXXu0mjfykT1B literal 0 HcmV?d00001 diff --git a/lib/apprise/attachment/AttachBase.py b/lib/apprise/attachment/AttachBase.py new file mode 100644 index 0000000..c1cadbf --- /dev/null +++ b/lib/apprise/attachment/AttachBase.py @@ -0,0 +1,377 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 os +import time +import mimetypes +from ..URLBase import URLBase +from ..utils import parse_bool +from ..common import ContentLocation +from ..AppriseLocale import gettext_lazy as _ + + +class AttachBase(URLBase): + """ + This is the base class for all supported attachment types + """ + + # For attachment type detection; this amount of data is read into memory + # 128KB (131072B) + max_detect_buffer_size = 131072 + + # Unknown mimetype + unknown_mimetype = 'application/octet-stream' + + # Our filename when we can't otherwise determine one + unknown_filename = 'apprise-attachment' + + # Our filename extension when we can't otherwise determine one + unknown_filename_extension = '.obj' + + # The strict argument is a flag specifying whether the list of known MIME + # types is limited to only the official types registered with IANA. When + # strict is True, only the IANA types are supported; when strict is False + # (the default), some additional non-standard but commonly used MIME types + # are also recognized. + strict = False + + # The maximum file-size we will accept for an attachment size. If this is + # set to zero (0), then no check is performed + # 1 MB = 1048576 bytes + # 5 MB = 5242880 bytes + # 1 GB = 1048576000 bytes + max_file_size = 1048576000 + + # By default all attachments types are inaccessible. + # Developers of items identified in the attachment plugin directory + # are requried to set a location + location = ContentLocation.INACCESSIBLE + + # Here is where we define all of the arguments we accept on the url + # such as: schema://whatever/?overflow=upstream&format=text + # These act the same way as tokens except they are optional and/or + # have default values set if mandatory. This rule must be followed + template_args = { + 'cache': { + 'name': _('Cache Age'), + 'type': 'int', + # We default to (600) which means we cache for 10 minutes + 'default': 600, + }, + 'mime': { + 'name': _('Forced Mime Type'), + 'type': 'string', + }, + 'name': { + 'name': _('Forced File Name'), + 'type': 'string', + }, + 'verify': { + 'name': _('Verify SSL'), + # SSL Certificate Authority Verification + 'type': 'bool', + # Provide a default + 'default': True, + }, + } + + def __init__(self, name=None, mimetype=None, cache=None, **kwargs): + """ + Initialize some general logging and common server arguments that will + keep things consistent when working with the configurations that + inherit this class. + + Optionally provide a filename to over-ride name associated with the + actual file retrieved (from where-ever). + + The mime-type is automatically detected, but you can over-ride this by + explicitly stating what it should be. + + By default we cache our responses so that subsiquent calls does not + cause the content to be retrieved again. For local file references + this makes no difference at all. But for remote content, this does + mean more then one call can be made to retrieve the (same) data. This + method can be somewhat inefficient if disabled. Only disable caching + if you understand the consequences. + + You can alternatively set the cache value to an int identifying the + number of seconds the previously retrieved can exist for before it + should be considered expired. + """ + + super().__init__(**kwargs) + + if not mimetypes.inited: + # Ensure mimetypes has been initialized + mimetypes.init() + + # Attach Filename (does not have to be the same as path) + self._name = name + + # The mime type of the attached content. This is detected if not + # otherwise specified. + self._mimetype = mimetype + + # The detected_mimetype, this is only used as a fallback if the + # mimetype wasn't forced by the user + self.detected_mimetype = None + + # The detected filename by calling child class. A detected filename + # is always used if no force naming was specified. + self.detected_name = None + + # Absolute path to attachment + self.download_path = None + + # Set our cache flag; it can be True, False, None, or a (positive) + # integer... nothing else + if cache is not None: + try: + self.cache = cache if isinstance(cache, bool) else int(cache) + + except (TypeError, ValueError): + err = 'An invalid cache value ({}) was specified.'.format( + cache) + self.logger.warning(err) + raise TypeError(err) + + # Some simple error checking + if self.cache < 0: + err = 'A negative cache value ({}) was specified.'.format( + cache) + self.logger.warning(err) + raise TypeError(err) + + else: + self.cache = None + + # Validate mimetype if specified + if self._mimetype: + if next((t for t in mimetypes.types_map.values() + if self._mimetype == t), None) is None: + err = 'An invalid mime-type ({}) was specified.'.format( + mimetype) + self.logger.warning(err) + raise TypeError(err) + + return + + @property + def path(self): + """ + Returns the absolute path to the filename. If this is not known or + is know but has been considered expired (due to cache setting), then + content is re-retrieved prior to returning. + """ + + if not self.exists(): + # we could not obtain our path + return None + + return self.download_path + + @property + def name(self): + """ + Returns the filename + """ + if self._name: + # return our fixed content + return self._name + + if not self.exists(): + # we could not obtain our name + return None + + if not self.detected_name: + # If we get here, our download was successful but we don't have a + # filename based on our content. + extension = mimetypes.guess_extension(self.mimetype) + self.detected_name = '{}{}'.format( + self.unknown_filename, + extension if extension else self.unknown_filename_extension) + + return self.detected_name + + @property + def mimetype(self): + """ + Returns mime type (if one is present). + + Content is cached once determied to prevent overhead of future + calls. + """ + + if self._mimetype: + # return our pre-calculated cached content + return self._mimetype + + if not self.exists(): + # we could not obtain our attachment + return None + + if not self.detected_mimetype: + # guess_type() returns: (type, encoding) and sets type to None + # if it can't otherwise determine it. + try: + # Directly reference _name and detected_name to prevent + # recursion loop (as self.name calls this function) + self.detected_mimetype, _ = mimetypes.guess_type( + self._name if self._name + else self.detected_name, strict=self.strict) + + except TypeError: + # Thrown if None was specified in filename section + pass + + # Return our mime type + return self.detected_mimetype \ + if self.detected_mimetype else self.unknown_mimetype + + def exists(self): + """ + Simply returns true if the object has downloaded and stored the + attachment AND the attachment has not expired. + """ + + cache = self.template_args['cache']['default'] \ + if self.cache is None else self.cache + + if self.download_path and os.path.isfile(self.download_path) \ + and cache: + + # We have enough reason to look further into our cached content + # and verify it has not expired. + if cache is True: + # return our fixed content as is; we will always cache it + return True + + # Verify our cache time to determine whether we will get our + # content again. + try: + age_in_sec = time.time() - os.stat(self.download_path).st_mtime + if age_in_sec <= cache: + return True + + except (OSError, IOError): + # The file is not present + pass + + return self.download() + + def invalidate(self): + """ + Release any temporary data that may be open by child classes. + Externally fetched content should be automatically cleaned up when + this function is called. + + This function should also reset the following entries to None: + - detected_name : Should identify a human readable filename + - download_path: Must contain a absolute path to content + - detected_mimetype: Should identify mimetype of content + """ + self.detected_name = None + self.download_path = None + self.detected_mimetype = None + return + + def download(self): + """ + This function must be over-ridden by inheriting classes. + + Inherited classes MUST populate: + - detected_name: Should identify a human readable filename + - download_path: Must contain a absolute path to content + - detected_mimetype: Should identify mimetype of content + + If a download fails, you should ensure these values are set to None. + """ + raise NotImplementedError( + "download() is implimented by the child class.") + + @staticmethod + def parse_url(url, verify_host=True, mimetype_db=None): + """Parses the URL and returns it broken apart into a dictionary. + + This is very specific and customized for Apprise. + + Args: + url (str): The URL you want to fully parse. + verify_host (:obj:`bool`, optional): a flag kept with the parsed + URL which some child classes will later use to verify SSL + keys (if SSL transactions take place). Unless under very + specific circumstances, it is strongly recomended that + you leave this default value set to True. + + Returns: + A dictionary is returned containing the URL fully parsed if + successful, otherwise None is returned. + """ + + results = URLBase.parse_url(url, verify_host=verify_host) + + if not results: + # We're done; we failed to parse our url + return results + + # Allow overriding the default config mime type + if 'mime' in results['qsd']: + results['mimetype'] = results['qsd'].get('mime', '') \ + .strip().lower() + + # Allow overriding the default file name + if 'name' in results['qsd']: + results['name'] = results['qsd'].get('name', '') \ + .strip().lower() + + # Our cache value + if 'cache' in results['qsd']: + # First try to get it's integer value + try: + results['cache'] = int(results['qsd']['cache']) + + except (ValueError, TypeError): + # No problem, it just isn't an integer; now treat it as a bool + # instead: + results['cache'] = parse_bool(results['qsd']['cache']) + + return results + + def __len__(self): + """ + Returns the filesize of the attachment. + + """ + return os.path.getsize(self.path) if self.path else 0 + + def __bool__(self): + """ + Allows the Apprise object to be wrapped in an based 'if statement'. + True is returned if our content was downloaded correctly. + """ + return True if self.path else False diff --git a/lib/apprise/attachment/AttachBase.pyi b/lib/apprise/attachment/AttachBase.pyi new file mode 100644 index 0000000..66b7179 --- /dev/null +++ b/lib/apprise/attachment/AttachBase.pyi @@ -0,0 +1,36 @@ +from typing import Any, Dict, Optional + +from .. import ContentLocation + +class AttachBase: + max_detect_buffer_size: int + unknown_mimetype: str + unknown_filename: str + unknown_filename_extension: str + strict: bool + max_file_size: int + location: ContentLocation + template_args: Dict[str, Any] + def __init__( + self, + name: Optional[str] = ..., + mimetype: Optional[str] = ..., + cache: Optional[bool] = ..., + **kwargs: Any + ) -> None: ... + @property + def path(self) -> Optional[str]: ... + @property + def name(self) -> Optional[str]: ... + @property + def mimetype(self) -> Optional[str]: ... + def exists(self) -> bool: ... + def invalidate(self) -> None: ... + def download(self) -> bool: ... + @staticmethod + def parse_url( + url: str, + verify_host: bool = ... + ) -> Dict[str, Any]: ... + def __len__(self) -> int: ... + def __bool__(self) -> bool: ... diff --git a/lib/apprise/attachment/AttachFile.py b/lib/apprise/attachment/AttachFile.py new file mode 100644 index 0000000..d308555 --- /dev/null +++ b/lib/apprise/attachment/AttachFile.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 re +import os +from .AttachBase import AttachBase +from ..common import ContentLocation +from ..AppriseLocale import gettext_lazy as _ + + +class AttachFile(AttachBase): + """ + A wrapper for File based attachment sources + """ + + # The default descriptive name associated with the service + service_name = _('Local File') + + # The default protocol + protocol = 'file' + + # Content is local to the same location as the apprise instance + # being called (server-side) + location = ContentLocation.LOCAL + + def __init__(self, path, **kwargs): + """ + Initialize Local File Attachment Object + + """ + super().__init__(**kwargs) + + # Store path but mark it dirty since we have not performed any + # verification at this point. + self.dirty_path = os.path.expanduser(path) + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = {} + + if self._mimetype: + # A mime-type was enforced + params['mime'] = self._mimetype + + if self._name: + # A name was enforced + params['name'] = self._name + + return 'file://{path}{params}'.format( + path=self.quote(self.dirty_path), + params='?{}'.format(self.urlencode(params)) if params else '', + ) + + def download(self, **kwargs): + """ + Perform retrieval of our data. + + For file base attachments, our data already exists, so we only need to + validate it. + """ + + if self.location == ContentLocation.INACCESSIBLE: + # our content is inaccessible + return False + + # Ensure any existing content set has been invalidated + self.invalidate() + + if not os.path.isfile(self.dirty_path): + return False + + if self.max_file_size > 0 and \ + os.path.getsize(self.dirty_path) > self.max_file_size: + + # The content to attach is to large + self.logger.error( + 'Content exceeds allowable maximum file length ' + '({}KB): {}'.format( + int(self.max_file_size / 1024), self.url(privacy=True))) + + # Return False (signifying a failure) + return False + + # We're good to go if we get here. Set our minimum requirements of + # a call do download() before returning a success + self.download_path = self.dirty_path + self.detected_name = os.path.basename(self.download_path) + + # We don't need to set our self.detected_mimetype as it can be + # pulled at the time it's needed based on the detected_name + return True + + @staticmethod + def parse_url(url): + """ + Parses the URL so that we can handle all different file paths + and return it as our path object + + """ + + results = AttachBase.parse_url(url, verify_host=False) + if not results: + # We're done early; it's not a good URL + return results + + match = re.match(r'file://(?P[^?]+)(\?.*)?', url, re.I) + if not match: + return None + + results['path'] = AttachFile.unquote(match.group('path')) + return results diff --git a/lib/apprise/attachment/AttachHTTP.py b/lib/apprise/attachment/AttachHTTP.py new file mode 100644 index 0000000..0c85947 --- /dev/null +++ b/lib/apprise/attachment/AttachHTTP.py @@ -0,0 +1,337 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 re +import os +import requests +from tempfile import NamedTemporaryFile +from .AttachBase import AttachBase +from ..common import ContentLocation +from ..URLBase import PrivacyMode +from ..AppriseLocale import gettext_lazy as _ + + +class AttachHTTP(AttachBase): + """ + A wrapper for HTTP based attachment sources + """ + + # The default descriptive name associated with the service + service_name = _('Web Based') + + # The default protocol + protocol = 'http' + + # The default secure protocol + secure_protocol = 'https' + + # The number of bytes in memory to read from the remote source at a time + chunk_size = 8192 + + # Web based requests are remote/external to our current location + location = ContentLocation.HOSTED + + def __init__(self, headers=None, **kwargs): + """ + Initialize HTTP Object + + headers can be a dictionary of key/value pairs that you want to + additionally include as part of the server headers to post with + + """ + super().__init__(**kwargs) + + self.schema = 'https' if self.secure else 'http' + + self.fullpath = kwargs.get('fullpath') + if not isinstance(self.fullpath, str): + self.fullpath = '/' + + self.headers = {} + if headers: + # Store our extra headers + self.headers.update(headers) + + # Where our content is written to upon a call to download. + self._temp_file = None + + # Our Query String Dictionary; we use this to track arguments + # specified that aren't otherwise part of this class + self.qsd = {k: v for k, v in kwargs.get('qsd', {}).items() + if k not in self.template_args} + + return + + def download(self, **kwargs): + """ + Perform retrieval of the configuration based on the specified request + """ + + if self.location == ContentLocation.INACCESSIBLE: + # our content is inaccessible + return False + + # Ensure any existing content set has been invalidated + self.invalidate() + + # prepare header + headers = { + 'User-Agent': self.app_id, + } + + # Apply any/all header over-rides defined + headers.update(self.headers) + + auth = None + if self.user: + auth = (self.user, self.password) + + url = '%s://%s' % (self.schema, self.host) + if isinstance(self.port, int): + url += ':%d' % self.port + + url += self.fullpath + + self.logger.debug('HTTP POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + + # Where our request object will temporarily live. + r = None + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + # Make our request + with requests.get( + url, + headers=headers, + auth=auth, + params=self.qsd, + verify=self.verify_certificate, + timeout=self.request_timeout, + stream=True) as r: + + # Handle Errors + r.raise_for_status() + + # Get our file-size (if known) + try: + file_size = int(r.headers.get('Content-Length', '0')) + except (TypeError, ValueError): + # Handle edge case where Content-Length is a bad value + file_size = 0 + + # Perform a little Q/A on file limitations and restrictions + if self.max_file_size > 0 and file_size > self.max_file_size: + + # The content retrieved is to large + self.logger.error( + 'HTTP response exceeds allowable maximum file length ' + '({}KB): {}'.format( + int(self.max_file_size / 1024), + self.url(privacy=True))) + + # Return False (signifying a failure) + return False + + # Detect config format based on mime if the format isn't + # already enforced + self.detected_mimetype = r.headers.get('Content-Type') + + d = r.headers.get('Content-Disposition', '') + result = re.search( + "filename=['\"]?(?P[^'\"]+)['\"]?", d, re.I) + if result: + self.detected_name = result.group('name').strip() + + # Create a temporary file to work with + self._temp_file = NamedTemporaryFile() + + # Get our chunk size + chunk_size = self.chunk_size + + # Track all bytes written to disk + bytes_written = 0 + + # If we get here, we can now safely write our content to disk + for chunk in r.iter_content(chunk_size=chunk_size): + # filter out keep-alive chunks + if chunk: + self._temp_file.write(chunk) + bytes_written = self._temp_file.tell() + + # Prevent a case where Content-Length isn't provided + # we don't want to fetch beyond our limits + if self.max_file_size > 0: + if bytes_written > self.max_file_size: + # The content retrieved is to large + self.logger.error( + 'HTTP response exceeds allowable maximum ' + 'file length ({}KB): {}'.format( + int(self.max_file_size / 1024), + self.url(privacy=True))) + + # Invalidate any variables previously set + self.invalidate() + + # Return False (signifying a failure) + return False + + elif bytes_written + chunk_size \ + > self.max_file_size: + # Adjust out next read to accomodate up to our + # limit +1. This will prevent us from readig + # to much into our memory buffer + self.max_file_size - bytes_written + 1 + + # Ensure our content is flushed to disk for post-processing + self._temp_file.flush() + + # Set our minimum requirements for a successful download() call + self.download_path = self._temp_file.name + if not self.detected_name: + self.detected_name = os.path.basename(self.fullpath) + + except requests.RequestException as e: + self.logger.error( + 'A Connection error occurred retrieving HTTP ' + 'configuration from %s.' % self.host) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Invalidate any variables previously set + self.invalidate() + + # Return False (signifying a failure) + return False + + except (IOError, OSError): + # IOError is present for backwards compatibility with Python + # versions older then 3.3. >= 3.3 throw OSError now. + + # Could not open and/or write the temporary file + self.logger.error( + 'Could not write attachment to disk: {}'.format( + self.url(privacy=True))) + + # Invalidate any variables previously set + self.invalidate() + + # Return False (signifying a failure) + return False + + # Return our success + return True + + def invalidate(self): + """ + Close our temporary file + """ + if self._temp_file: + self._temp_file.close() + self._temp_file = None + + super().invalidate() + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Our URL parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + # Prepare our cache value + if self.cache is not None: + if isinstance(self.cache, bool) or not self.cache: + cache = 'yes' if self.cache else 'no' + else: + cache = int(self.cache) + + # Set our cache value + params['cache'] = cache + + if self._mimetype: + # A format was enforced + params['mime'] = self._mimetype + + if self._name: + # A name was enforced + params['name'] = self._name + + # Append our headers into our parameters + params.update({'+{}'.format(k): v for k, v in self.headers.items()}) + + # Apply any remaining entries to our URL + params.update(self.qsd) + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=self.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=self.quote(self.user, safe=''), + ) + + default_port = 443 if self.secure else 80 + + return '{schema}://{auth}{hostname}{port}{fullpath}?{params}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + auth=auth, + hostname=self.quote(self.host, safe=''), + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + fullpath=self.quote(self.fullpath, safe='/'), + params=self.urlencode(params), + ) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + results = AttachBase.parse_url(url) + + if not results: + # We're done early as we couldn't load the results + return results + + # Add our headers that the user can potentially over-ride if they wish + # to to our returned result set + results['headers'] = results['qsd-'] + results['headers'].update(results['qsd+']) + + return results diff --git a/lib/apprise/attachment/__init__.py b/lib/apprise/attachment/__init__.py new file mode 100644 index 0000000..ba7620a --- /dev/null +++ b/lib/apprise/attachment/__init__.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 re +from os import listdir +from os.path import dirname +from os.path import abspath +from ..common import ATTACHMENT_SCHEMA_MAP + +__all__ = [] + + +# Load our Lookup Matrix +def __load_matrix(path=abspath(dirname(__file__)), name='apprise.attachment'): + """ + Dynamically load our schema map; this allows us to gracefully + skip over modules we simply don't have the dependencies for. + + """ + # Used for the detection of additional Attachment Services objects + # The .py extension is optional as we support loading directories too + module_re = re.compile(r'^(?PAttach[a-z0-9]+)(\.py)?$', re.I) + + for f in listdir(path): + match = module_re.match(f) + if not match: + # keep going + continue + + # Store our notification/plugin name: + plugin_name = match.group('name') + try: + module = __import__( + '{}.{}'.format(name, plugin_name), + globals(), locals(), + fromlist=[plugin_name]) + + except ImportError: + # No problem, we can't use this object + continue + + if not hasattr(module, plugin_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. + continue + + # Get our plugin + plugin = getattr(module, plugin_name) + if not hasattr(plugin, 'app_id'): + # Filter out non-notification modules + continue + + elif plugin_name in __all__: + # we're already handling this object + continue + + # Add our module name to our __all__ + __all__.append(plugin_name) + + # Ensure we provide the class as the reference to this directory and + # not the module: + globals()[plugin_name] = plugin + + # Load protocol(s) if defined + proto = getattr(plugin, 'protocol', None) + if isinstance(proto, str): + if proto not in ATTACHMENT_SCHEMA_MAP: + ATTACHMENT_SCHEMA_MAP[proto] = plugin + + elif isinstance(proto, (set, list, tuple)): + # Support iterables list types + for p in proto: + if p not in ATTACHMENT_SCHEMA_MAP: + ATTACHMENT_SCHEMA_MAP[p] = plugin + + # Load secure protocol(s) if defined + protos = getattr(plugin, 'secure_protocol', None) + if isinstance(protos, str): + if protos not in ATTACHMENT_SCHEMA_MAP: + ATTACHMENT_SCHEMA_MAP[protos] = plugin + + if isinstance(protos, (set, list, tuple)): + # Support iterables list types + for p in protos: + if p not in ATTACHMENT_SCHEMA_MAP: + ATTACHMENT_SCHEMA_MAP[p] = plugin + + return ATTACHMENT_SCHEMA_MAP + + +# Dynamically build our schema base +__load_matrix() diff --git a/lib/apprise/cli.py b/lib/apprise/cli.py new file mode 100644 index 0000000..1303518 --- /dev/null +++ b/lib/apprise/cli.py @@ -0,0 +1,520 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 click +import logging +import platform +import sys +import os +import re + +from os.path import isfile +from os.path import exists +from os.path import expanduser +from os.path import expandvars + +from . import NotifyType +from . import NotifyFormat +from . import Apprise +from . import AppriseAsset +from . import AppriseConfig + +from .utils import parse_list +from .common import NOTIFY_TYPES +from .common import NOTIFY_FORMATS +from .common import ContentLocation +from .logger import logger + +from . import __title__ +from . import __version__ +from . import __license__ +from . import __copywrite__ + +# By default we allow looking 1 level down recursivly in Apprise configuration +# files. +DEFAULT_RECURSION_DEPTH = 1 + +# Defines our click context settings adding -h to the additional options that +# can be specified to get the help menu to come up +CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) + +# Define our default configuration we use if nothing is otherwise specified +DEFAULT_CONFIG_PATHS = ( + # Legacy Path Support + '~/.apprise', + '~/.apprise.yml', + '~/.config/apprise', + '~/.config/apprise.yml', + + # Plugin Support Extended Directory Search Paths + '~/.apprise/apprise', + '~/.apprise/apprise.yml', + '~/.config/apprise/apprise', + '~/.config/apprise/apprise.yml', + + # Global Configuration Support + '/etc/apprise', + '/etc/apprise.yml', + '/etc/apprise/apprise', + '/etc/apprise/apprise.yml', +) + +# Define our paths to search for plugins +DEFAULT_PLUGIN_PATHS = ( + '~/.apprise/plugins', + '~/.config/apprise/plugins', + + # Global Plugin Support + '/var/lib/apprise/plugins', +) + +# Detect Windows +if platform.system() == 'Windows': + # Default Config Search Path for Windows Users + DEFAULT_CONFIG_PATHS = ( + expandvars('%APPDATA%\\Apprise\\apprise'), + expandvars('%APPDATA%\\Apprise\\apprise.yml'), + expandvars('%LOCALAPPDATA%\\Apprise\\apprise'), + expandvars('%LOCALAPPDATA%\\Apprise\\apprise.yml'), + + # + # Global Support + # + + # C:\ProgramData\Apprise\ + expandvars('%ALLUSERSPROFILE%\\Apprise\\apprise'), + expandvars('%ALLUSERSPROFILE%\\Apprise\\apprise.yml'), + + # C:\Program Files\Apprise + expandvars('%PROGRAMFILES%\\Apprise\\apprise'), + expandvars('%PROGRAMFILES%\\Apprise\\apprise.yml'), + + # C:\Program Files\Common Files + expandvars('%COMMONPROGRAMFILES%\\Apprise\\apprise'), + expandvars('%COMMONPROGRAMFILES%\\Apprise\\apprise.yml'), + ) + + # Default Plugin Search Path for Windows Users + DEFAULT_PLUGIN_PATHS = ( + expandvars('%APPDATA%\\Apprise\\plugins'), + expandvars('%LOCALAPPDATA%\\Apprise\\plugins'), + + # + # Global Support + # + + # C:\ProgramData\Apprise\plugins + expandvars('%ALLUSERSPROFILE%\\Apprise\\plugins'), + # C:\Program Files\Apprise\plugins + expandvars('%PROGRAMFILES%\\Apprise\\plugins'), + # C:\Program Files\Common Files + expandvars('%COMMONPROGRAMFILES%\\Apprise\\plugins'), + ) + + +def print_help_msg(command): + """ + Prints help message when -h or --help is specified. + + """ + with click.Context(command) as ctx: + click.echo(command.get_help(ctx)) + + +def print_version_msg(): + """ + Prints version message when -V or --version is specified. + + """ + result = list() + result.append('{} v{}'.format(__title__, __version__)) + result.append(__copywrite__) + result.append( + 'This code is licensed under the {} License.'.format(__license__)) + click.echo('\n'.join(result)) + + +@click.command(context_settings=CONTEXT_SETTINGS) +@click.option('--body', '-b', default=None, type=str, + help='Specify the message body. If no body is specified then ' + 'content is read from .') +@click.option('--title', '-t', default=None, type=str, + help='Specify the message title. This field is complete ' + 'optional.') +@click.option('--plugin-path', '-P', default=None, type=str, multiple=True, + metavar='PLUGIN_PATH', + help='Specify one or more plugin paths to scan.') +@click.option('--config', '-c', default=None, type=str, multiple=True, + metavar='CONFIG_URL', + help='Specify one or more configuration locations.') +@click.option('--attach', '-a', default=None, type=str, multiple=True, + metavar='ATTACHMENT_URL', + help='Specify one or more attachment.') +@click.option('--notification-type', '-n', default=NotifyType.INFO, type=str, + metavar='TYPE', + help='Specify the message type (default={}). ' + 'Possible values are "{}", and "{}".'.format( + NotifyType.INFO, '", "'.join(NOTIFY_TYPES[:-1]), + NOTIFY_TYPES[-1])) +@click.option('--input-format', '-i', default=NotifyFormat.TEXT, type=str, + metavar='FORMAT', + help='Specify the message input format (default={}). ' + 'Possible values are "{}", and "{}".'.format( + NotifyFormat.TEXT, '", "'.join(NOTIFY_FORMATS[:-1]), + NOTIFY_FORMATS[-1])) +@click.option('--theme', '-T', default='default', type=str, metavar='THEME', + help='Specify the default theme.') +@click.option('--tag', '-g', default=None, type=str, multiple=True, + metavar='TAG', help='Specify one or more tags to filter ' + 'which services to notify. Use multiple --tag (-g) entries to ' + '"OR" the tags together and comma separated to "AND" them. ' + 'If no tags are specified then all services are notified.') +@click.option('--disable-async', '-Da', is_flag=True, + help='Send all notifications sequentially') +@click.option('--dry-run', '-d', is_flag=True, + help='Perform a trial run but only prints the notification ' + 'services to-be triggered to stdout. Notifications are never ' + 'sent using this mode.') +@click.option('--details', '-l', is_flag=True, + help='Prints details about the current services supported by ' + 'Apprise.') +@click.option('--recursion-depth', '-R', default=DEFAULT_RECURSION_DEPTH, + type=int, + help='The number of recursive import entries that can be ' + 'loaded from within Apprise configuration. By default ' + 'this is set to {}.'.format(DEFAULT_RECURSION_DEPTH)) +@click.option('--verbose', '-v', count=True, + help='Makes the operation more talkative. Use multiple v to ' + 'increase the verbosity. I.e.: -vvvv') +@click.option('--interpret-escapes', '-e', is_flag=True, + help='Enable interpretation of backslash escapes') +@click.option('--debug', '-D', is_flag=True, help='Debug mode') +@click.option('--version', '-V', is_flag=True, + help='Display the apprise version and exit.') +@click.argument('urls', nargs=-1, + metavar='SERVER_URL [SERVER_URL2 [SERVER_URL3]]',) +def main(body, title, config, attach, urls, notification_type, theme, tag, + input_format, dry_run, recursion_depth, verbose, disable_async, + details, interpret_escapes, plugin_path, debug, version): + """ + Send a notification to all of the specified servers identified by their + URLs the content provided within the title, body and notification-type. + + For a list of all of the supported services and information on how to + use them, check out at https://github.com/caronc/apprise + """ + # Note: Click ignores the return values of functions it wraps, If you + # want to return a specific error code, you must call sys.exit() + # as you will see below. + + debug = True if debug else False + if debug: + # Verbosity must be a minimum of 3 + verbose = 3 if verbose < 3 else verbose + + # Logging + ch = logging.StreamHandler(sys.stdout) + if verbose > 3: + # -vvvv: Most Verbose Debug Logging + logger.setLevel(logging.TRACE) + + elif verbose > 2: + # -vvv: Debug Logging + logger.setLevel(logging.DEBUG) + + elif verbose > 1: + # -vv: INFO Messages + logger.setLevel(logging.INFO) + + elif verbose > 0: + # -v: WARNING Messages + logger.setLevel(logging.WARNING) + + else: + # No verbosity means we display ERRORS only AND any deprecation + # warnings + logger.setLevel(logging.ERROR) + + # Format our logger + formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') + ch.setFormatter(formatter) + logger.addHandler(ch) + + # Update our asyncio logger + asyncio_logger = logging.getLogger('asyncio') + for handler in logger.handlers: + asyncio_logger.addHandler(handler) + asyncio_logger.setLevel(logger.level) + + if version: + print_version_msg() + sys.exit(0) + + # Simple Error Checking + notification_type = notification_type.strip().lower() + if notification_type not in NOTIFY_TYPES: + logger.error( + 'The --notification-type (-n) value of {} is not supported.' + .format(notification_type)) + # 2 is the same exit code returned by Click if there is a parameter + # issue. For consistency, we also return a 2 + sys.exit(2) + + input_format = input_format.strip().lower() + if input_format not in NOTIFY_FORMATS: + logger.error( + 'The --input-format (-i) value of {} is not supported.' + .format(input_format)) + # 2 is the same exit code returned by Click if there is a parameter + # issue. For consistency, we also return a 2 + sys.exit(2) + + if not plugin_path: + # Prepare a default set of plugin path + plugin_path = \ + next((path for path in DEFAULT_PLUGIN_PATHS + if exists(expanduser(path))), None) + + # Prepare our asset + asset = AppriseAsset( + # Our body format + body_format=input_format, + + # Interpret Escapes + interpret_escapes=interpret_escapes, + + # Set the theme + theme=theme, + + # Async mode allows a user to send all of their notifications + # asynchronously. This was made an option incase there are problems + # in the future where it is better that everything runs sequentially/ + # synchronously instead. + async_mode=disable_async is not True, + + # Load our plugins + plugin_paths=plugin_path, + ) + + # Create our Apprise object + a = Apprise(asset=asset, debug=debug, location=ContentLocation.LOCAL) + + if details: + # Print details and exit + results = a.details(show_requirements=True, show_disabled=True) + + # Sort our results: + plugins = sorted( + results['schemas'], key=lambda i: str(i['service_name'])) + for entry in plugins: + protocols = [] if not entry['protocols'] else \ + [p for p in entry['protocols'] + if isinstance(p, str)] + protocols.extend( + [] if not entry['secure_protocols'] else + [p for p in entry['secure_protocols'] + if isinstance(p, str)]) + + if len(protocols) == 1: + # Simplify view by swapping {schema} with the single + # protocol value + + # Convert tuple to list + entry['details']['templates'] = \ + list(entry['details']['templates']) + + for x in range(len(entry['details']['templates'])): + entry['details']['templates'][x] = \ + re.sub( + r'^[^}]+}://', + '{}://'.format(protocols[0]), + entry['details']['templates'][x]) + + fg = "green" if entry['enabled'] else "red" + if entry['category'] == 'custom': + # Identify these differently + fg = "cyan" + # Flip the enable switch so it forces the requirements + # to be displayed + entry['enabled'] = False + + click.echo(click.style( + '{} {:<30} '.format( + '+' if entry['enabled'] else '-', + str(entry['service_name'])), fg=fg, bold=True), + nl=(not entry['enabled'] or len(protocols) == 1)) + + if not entry['enabled']: + if entry['requirements']['details']: + click.echo( + ' ' + str(entry['requirements']['details'])) + + if entry['requirements']['packages_required']: + click.echo(' Python Packages Required:') + for req in entry['requirements']['packages_required']: + click.echo(' - ' + req) + + if entry['requirements']['packages_recommended']: + click.echo(' Python Packages Recommended:') + for req in entry['requirements']['packages_recommended']: + click.echo(' - ' + req) + + # new line padding between entries + if entry['category'] == 'native': + click.echo() + continue + + if len(protocols) > 1: + click.echo('| Schema(s): {}'.format( + ', '.join(protocols), + )) + + prefix = ' - ' + click.echo('{}{}'.format( + prefix, + '\n{}'.format(prefix).join(entry['details']['templates']))) + + # new line padding between entries + click.echo() + + sys.exit(0) + # end if details() + + # The priorities of what is accepted are parsed in order below: + # 1. URLs by command line + # 2. Configuration by command line + # 3. URLs by environment variable: APPRISE_URLS + # 4. Configuration by environment variable: APPRISE_CONFIG + # 5. Default Configuration File(s) (if found) + # + if urls: + if tag: + # Ignore any tags specified + logger.warning( + '--tag (-g) entries are ignored when using specified URLs') + tag = None + + # Load our URLs (if any defined) + for url in urls: + a.add(url) + + if config: + # Provide a warning to the end user if they specified both + logger.warning( + 'You defined both URLs and a --config (-c) entry; ' + 'Only the URLs will be referenced.') + + elif config: + # We load our configuration file(s) now only if no URLs were specified + # Specified config entries trump all + a.add(AppriseConfig( + paths=config, asset=asset, recursion=recursion_depth)) + + elif os.environ.get('APPRISE_URLS', '').strip(): + logger.debug('Loading provided APPRISE_URLS environment variable') + if tag: + # Ignore any tags specified + logger.warning( + '--tag (-g) entries are ignored when using specified URLs') + tag = None + + # Attempt to use our APPRISE_URLS environment variable (if populated) + a.add(os.environ['APPRISE_URLS'].strip()) + + elif os.environ.get('APPRISE_CONFIG', '').strip(): + logger.debug('Loading provided APPRISE_CONFIG environment variable') + # Fall back to config environment variable (if populated) + a.add(AppriseConfig( + paths=os.environ['APPRISE_CONFIG'].strip(), + asset=asset, recursion=recursion_depth)) + + else: + # Load default configuration + a.add(AppriseConfig( + paths=[f for f in DEFAULT_CONFIG_PATHS if isfile(expanduser(f))], + asset=asset, recursion=recursion_depth)) + + if len(a) == 0 and not urls: + logger.error( + 'You must specify at least one server URL or populated ' + 'configuration file.') + print_help_msg(main) + sys.exit(1) + + # each --tag entry comprises of a comma separated 'and' list + # we or each of of the --tag and sets specified. + tags = None if not tag else [parse_list(t) for t in tag] + + if not dry_run: + if body is None: + logger.trace('No --body (-b) specified; reading from stdin') + # if no body was specified, then read from STDIN + body = click.get_text_stream('stdin').read() + + # now print it out + result = a.notify( + body=body, title=title, notify_type=notification_type, tag=tags, + attach=attach) + else: + # Number of rows to assume in the terminal. In future, maybe this can + # be detected and made dynamic. The actual row count is 80, but 5 + # characters are already reserved for the counter on the left + rows = 75 + + # Initialize our URL response; This is populated within the for/loop + # below; but plays a factor at the end when we need to determine if + # we iterated at least once in the loop. + url = None + + for idx, server in enumerate(a.find(tag=tags)): + url = server.url(privacy=True) + click.echo("{: 3d}. {}".format( + idx + 1, + url if len(url) <= rows else '{}...'.format(url[:rows - 3]))) + if server.tags: + click.echo("{} - {}".format(' ' * 5, ', '.join(server.tags))) + + # Initialize a default response of nothing matched, otherwise + # if we matched at least one entry, we can return True + result = None if url is None else True + + if result is None: + # There were no notifications set. This is a result of just having + # empty configuration files and/or being to restrictive when filtering + # by specific tag(s) + + # Exit code 3 is used since Click uses exit code 2 if there is an + # error with the parameters specified + sys.exit(3) + + elif result is False: + # At least 1 notification service failed to send + sys.exit(1) + + # else: We're good! + sys.exit(0) diff --git a/lib/apprise/common.py b/lib/apprise/common.py new file mode 100644 index 0000000..aaf746e --- /dev/null +++ b/lib/apprise/common.py @@ -0,0 +1,240 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# we mirror our base purely for the ability to reset everything; this +# is generally only used in testing and should not be used by developers +# It is also used as a means of preventing a module from being reloaded +# in the event it already exists +NOTIFY_MODULE_MAP = {} + +# Maintains a mapping of all of the Notification services +NOTIFY_SCHEMA_MAP = {} + +# This contains a mapping of all plugins dynamicaly loaded at runtime from +# external modules such as the @notify decorator +# +# The elements here will be additionally added to the NOTIFY_SCHEMA_MAP if +# there is no conflict otherwise. +# The structure looks like the following: +# Module path, e.g. /usr/share/apprise/plugins/my_notify_hook.py +# { +# 'path': path, +# +# 'notify': { +# 'schema': { +# 'name': 'Custom schema name', +# 'fn_name': 'name_of_function_decorator_was_found_on', +# 'url': 'schema://any/additional/info/found/on/url' +# 'plugin': +# }, +# 'schema2': { +# 'name': 'Custom schema name', +# 'fn_name': 'name_of_function_decorator_was_found_on', +# 'url': 'schema://any/additional/info/found/on/url' +# 'plugin': +# } +# } +# +# Note: that the inherits from +# NotifyBase +NOTIFY_CUSTOM_MODULE_MAP = {} + +# Maintains a mapping of all configuration schema's supported +CONFIG_SCHEMA_MAP = {} + +# Maintains a mapping of all attachment schema's supported +ATTACHMENT_SCHEMA_MAP = {} + + +class NotifyType: + """ + A simple mapping of notification types most commonly used with + all types of logging and notification services. + """ + INFO = 'info' + SUCCESS = 'success' + WARNING = 'warning' + FAILURE = 'failure' + + +NOTIFY_TYPES = ( + NotifyType.INFO, + NotifyType.SUCCESS, + NotifyType.WARNING, + NotifyType.FAILURE, +) + + +class NotifyImageSize: + """ + A list of pre-defined image sizes to make it easier to work with defined + plugins. + """ + XY_32 = '32x32' + XY_72 = '72x72' + XY_128 = '128x128' + XY_256 = '256x256' + + +NOTIFY_IMAGE_SIZES = ( + NotifyImageSize.XY_32, + NotifyImageSize.XY_72, + NotifyImageSize.XY_128, + NotifyImageSize.XY_256, +) + + +class NotifyFormat: + """ + A list of pre-defined text message formats that can be passed via the + apprise library. + """ + TEXT = 'text' + HTML = 'html' + MARKDOWN = 'markdown' + + +NOTIFY_FORMATS = ( + NotifyFormat.TEXT, + NotifyFormat.HTML, + NotifyFormat.MARKDOWN, +) + + +class OverflowMode: + """ + A list of pre-defined modes of how to handle the text when it exceeds the + defined maximum message size. + """ + + # Send the data as is; untouched. Let the upstream server decide how the + # content is handled. Some upstream services might gracefully handle this + # with expected intentions; others might not. + UPSTREAM = 'upstream' + + # Always truncate the text when it exceeds the maximum message size and + # send it anyway + TRUNCATE = 'truncate' + + # Split the message into multiple smaller messages that fit within the + # limits of what is expected. The smaller messages are sent + SPLIT = 'split' + + +# Define our modes so we can verify if we need to +OVERFLOW_MODES = ( + OverflowMode.UPSTREAM, + OverflowMode.TRUNCATE, + OverflowMode.SPLIT, +) + + +class ConfigFormat: + """ + A list of pre-defined config formats that can be passed via the + apprise library. + """ + + # A text based configuration. This consists of a list of URLs delimited by + # a new line. pound/hashtag (#) or semi-colon (;) can be used as comment + # characters. + TEXT = 'text' + + # YAML files allow a more rich of an experience when settig up your + # apprise configuration files. + YAML = 'yaml' + + +# Define our configuration formats mostly used for verification +CONFIG_FORMATS = ( + ConfigFormat.TEXT, + ConfigFormat.YAML, +) + + +class ContentIncludeMode: + """ + The different Content inclusion modes. All content based plugins will + have one of these associated with it. + """ + # - Content inclusion of same type only; hence a file:// can include + # a file:// + # - Cross file inclusion is not allowed unless insecure_includes (a flag) + # is set to True. In these cases STRICT acts as type ALWAYS + STRICT = 'strict' + + # This content type can never be included + NEVER = 'never' + + # This content can always be included + ALWAYS = 'always' + + +CONTENT_INCLUDE_MODES = ( + ContentIncludeMode.STRICT, + ContentIncludeMode.NEVER, + ContentIncludeMode.ALWAYS, +) + + +class ContentLocation: + """ + This is primarily used for handling file attachments. The idea is + to track the source of the attachment itself. We don't want + remote calls to a server to access local attachments for example. + + By knowing the attachment type and cross-associating it with how + we plan on accessing the content, we can make a judgement call + (for security reasons) if we will allow it. + + Obviously local uses of apprise can access both local and remote + type files. + """ + # Content is located locally (on the same server as apprise) + LOCAL = 'local' + + # Content is located in a remote location + HOSTED = 'hosted' + + # Content is inaccessible + INACCESSIBLE = 'n/a' + + +CONTENT_LOCATIONS = ( + ContentLocation.LOCAL, + ContentLocation.HOSTED, + ContentLocation.INACCESSIBLE, +) + +# This is a reserved tag that is automatically assigned to every +# Notification Plugin +MATCH_ALL_TAG = 'all' + +# Will cause notification to trigger under any circumstance even if an +# exclusive tagging was provided. +MATCH_ALWAYS_TAG = 'always' diff --git a/lib/apprise/common.pyi b/lib/apprise/common.pyi new file mode 100644 index 0000000..862fc4f --- /dev/null +++ b/lib/apprise/common.pyi @@ -0,0 +1,22 @@ +import types +import typing as t + + +class NotifyType: + INFO: NotifyType + SUCCESS: NotifyType + WARNING: NotifyType + FAILURE: NotifyType + +class NotifyFormat: + TEXT: NotifyFormat + HTML: NotifyFormat + MARKDOWN: NotifyFormat + +class ContentLocation: + LOCAL: ContentLocation + HOSTED: ContentLocation + INACCESSIBLE: ContentLocation + + +NOTIFY_MODULE_MAP: t.Dict[str, t.Dict[str, t.Union[t.Type["NotifyBase"], types.ModuleType]]] diff --git a/lib/apprise/config/ConfigBase.py b/lib/apprise/config/ConfigBase.py new file mode 100644 index 0000000..adddc4f --- /dev/null +++ b/lib/apprise/config/ConfigBase.py @@ -0,0 +1,1391 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 os +import re +import yaml +import time + +from .. import plugins +from .. import common +from ..AppriseAsset import AppriseAsset +from ..URLBase import URLBase +from ..utils import GET_SCHEMA_RE +from ..utils import parse_list +from ..utils import parse_bool +from ..utils import parse_urls +from ..utils import cwe312_url + +# Test whether token is valid or not +VALID_TOKEN = re.compile( + r'(?P[a-z0-9][a-z0-9_]+)', re.I) + + +class ConfigBase(URLBase): + """ + This is the base class for all supported configuration sources + """ + + # The Default Encoding to use if not otherwise detected + encoding = 'utf-8' + + # The default expected configuration format unless otherwise + # detected by the sub-modules + default_config_format = common.ConfigFormat.TEXT + + # This is only set if the user overrides the config format on the URL + # this should always initialize itself as None + config_format = None + + # Don't read any more of this amount of data into memory as there is no + # reason we should be reading in more. This is more of a safe guard then + # anything else. 128KB (131072B) + max_buffer_size = 131072 + + # By default all configuration is not includable using the 'include' + # line found in configuration files. + allow_cross_includes = common.ContentIncludeMode.NEVER + + # the config path manages the handling of relative include + config_path = os.getcwd() + + def __init__(self, cache=True, recursion=0, insecure_includes=False, + **kwargs): + """ + Initialize some general logging and common server arguments that will + keep things consistent when working with the configurations that + inherit this class. + + By default we cache our responses so that subsiquent calls does not + cause the content to be retrieved again. For local file references + this makes no difference at all. But for remote content, this does + mean more then one call can be made to retrieve the (same) data. This + method can be somewhat inefficient if disabled. Only disable caching + if you understand the consequences. + + You can alternatively set the cache value to an int identifying the + number of seconds the previously retrieved can exist for before it + should be considered expired. + + recursion defines how deep we recursively handle entries that use the + `include` keyword. This keyword requires us to fetch more configuration + from another source and add it to our existing compilation. If the + file we remotely retrieve also has an `include` reference, we will only + advance through it if recursion is set to 2 deep. If set to zero + it is off. There is no limit to how high you set this value. It would + be recommended to keep it low if you do intend to use it. + + insecure_include by default are disabled. When set to True, all + Apprise Config files marked to be in STRICT mode are treated as being + in ALWAYS mode. + + Take a file:// based configuration for example, only a file:// based + configuration can include another file:// based one. because it is set + to STRICT mode. If an http:// based configuration file attempted to + include a file:// one it woul fail. However this include would be + possible if insecure_includes is set to True. + + There are cases where a self hosting apprise developer may wish to load + configuration from memory (in a string format) that contains 'include' + entries (even file:// based ones). In these circumstances if you want + these 'include' entries to be honored, this value must be set to True. + """ + + super().__init__(**kwargs) + + # Tracks the time the content was last retrieved on. This place a role + # for cases where we are not caching our response and are required to + # re-retrieve our settings. + self._cached_time = None + + # Tracks previously loaded content for speed + self._cached_servers = None + + # Initialize our recursion value + self.recursion = recursion + + # Initialize our insecure_includes flag + self.insecure_includes = insecure_includes + + if 'encoding' in kwargs: + # Store the encoding + self.encoding = kwargs.get('encoding') + + if 'format' in kwargs \ + and isinstance(kwargs['format'], str): + # Store the enforced config format + self.config_format = kwargs.get('format').lower() + + if self.config_format not in common.CONFIG_FORMATS: + # Simple error checking + err = 'An invalid config format ({}) was specified.'.format( + self.config_format) + self.logger.warning(err) + raise TypeError(err) + + # Set our cache flag; it can be True or a (positive) integer + try: + self.cache = cache if isinstance(cache, bool) else int(cache) + if self.cache < 0: + err = 'A negative cache value ({}) was specified.'.format( + cache) + self.logger.warning(err) + raise TypeError(err) + + except (ValueError, TypeError): + err = 'An invalid cache value ({}) was specified.'.format(cache) + self.logger.warning(err) + raise TypeError(err) + + return + + def servers(self, asset=None, **kwargs): + """ + Performs reads loaded configuration and returns all of the services + that could be parsed and loaded. + + """ + + if not self.expired(): + # We already have cached results to return; use them + return self._cached_servers + + # Our cached response object + self._cached_servers = list() + + # read() causes the child class to do whatever it takes for the + # config plugin to load the data source and return unparsed content + # None is returned if there was an error or simply no data + content = self.read(**kwargs) + if not isinstance(content, str): + # Set the time our content was cached at + self._cached_time = time.time() + + # Nothing more to do; return our empty cache list + return self._cached_servers + + # Our Configuration format uses a default if one wasn't one detected + # or enfored. + config_format = \ + self.default_config_format \ + if self.config_format is None else self.config_format + + # Dynamically load our parse_ function based on our config format + fn = getattr(ConfigBase, 'config_parse_{}'.format(config_format)) + + # Initialize our asset object + asset = asset if isinstance(asset, AppriseAsset) else self.asset + + # Execute our config parse function which always returns a tuple + # of our servers and our configuration + servers, configs = fn(content=content, asset=asset) + self._cached_servers.extend(servers) + + # Configuration files were detected; recursively populate them + # If we have been configured to do so + for url in configs: + + if self.recursion > 0: + # Attempt to acquire the schema at the very least to allow + # our configuration based urls. + schema = GET_SCHEMA_RE.match(url) + if schema is None: + # Plan B is to assume we're dealing with a file + schema = 'file' + if not os.path.isabs(url): + # We're dealing with a relative path; prepend + # our current config path + url = os.path.join(self.config_path, url) + + url = '{}://{}'.format(schema, URLBase.quote(url)) + + else: + # Ensure our schema is always in lower case + schema = schema.group('schema').lower() + + # Some basic validation + if schema not in common.CONFIG_SCHEMA_MAP: + ConfigBase.logger.warning( + 'Unsupported include schema {}.'.format(schema)) + continue + + # CWE-312 (Secure Logging) Handling + loggable_url = url if not asset.secure_logging \ + else cwe312_url(url) + + # Parse our url details of the server object as dictionary + # containing all of the information parsed from our URL + results = common.CONFIG_SCHEMA_MAP[schema].parse_url(url) + if not results: + # Failed to parse the server URL + self.logger.warning( + 'Unparseable include URL {}'.format(loggable_url)) + continue + + # Handle cross inclusion based on allow_cross_includes rules + if (common.CONFIG_SCHEMA_MAP[schema].allow_cross_includes == + common.ContentIncludeMode.STRICT + and schema not in self.schemas() + and not self.insecure_includes) or \ + common.CONFIG_SCHEMA_MAP[schema] \ + .allow_cross_includes == \ + common.ContentIncludeMode.NEVER: + + # Prevent the loading if insecure base protocols + ConfigBase.logger.warning( + 'Including {}:// based configuration is prohibited. ' + 'Ignoring URL {}'.format(schema, loggable_url)) + continue + + # Prepare our Asset Object + results['asset'] = asset + + # No cache is required because we're just lumping this in + # and associating it with the cache value we've already + # declared (prior to our recursion) + results['cache'] = False + + # Recursion can never be parsed from the URL; we decrement + # it one level + results['recursion'] = self.recursion - 1 + + # Insecure Includes flag can never be parsed from the URL + results['insecure_includes'] = self.insecure_includes + + try: + # Attempt to create an instance of our plugin using the + # parsed URL information + cfg_plugin = \ + common.CONFIG_SCHEMA_MAP[results['schema']](**results) + + except Exception as e: + # the arguments are invalid or can not be used. + self.logger.warning( + 'Could not load include URL: {}'.format(loggable_url)) + self.logger.debug('Loading Exception: {}'.format(str(e))) + continue + + # if we reach here, we can now add this servers found + # in this configuration file to our list + self._cached_servers.extend( + cfg_plugin.servers(asset=asset)) + + # We no longer need our configuration object + del cfg_plugin + + else: + # CWE-312 (Secure Logging) Handling + loggable_url = url if not asset.secure_logging \ + else cwe312_url(url) + + self.logger.debug( + 'Recursion limit reached; ignoring Include URL: %s', + loggable_url) + + if self._cached_servers: + self.logger.info( + 'Loaded {} entries from {}'.format( + len(self._cached_servers), + self.url(privacy=asset.secure_logging))) + else: + self.logger.warning( + 'Failed to load Apprise configuration from {}'.format( + self.url(privacy=asset.secure_logging))) + + # Set the time our content was cached at + self._cached_time = time.time() + + return self._cached_servers + + def read(self): + """ + This object should be implimented by the child classes + + """ + return None + + def expired(self): + """ + Simply returns True if the configuration should be considered + as expired or False if content should be retrieved. + """ + if isinstance(self._cached_servers, list) and self.cache: + # We have enough reason to look further into our cached content + # and verify it has not expired. + if self.cache is True: + # we have not expired, return False + return False + + # Verify our cache time to determine whether we will get our + # content again. + age_in_sec = time.time() - self._cached_time + if age_in_sec <= self.cache: + # We have not expired; return False + return False + + # If we reach here our configuration should be considered + # missing and/or expired. + return True + + @staticmethod + def __normalize_tag_groups(group_tags): + """ + Used to normalize a tag assign map which looks like: + { + 'group': set('{tag1}', '{group1}', '{tag2}'), + 'group1': set('{tag2}','{tag3}'), + } + + Then normalized it (merging groups); with respect to the above, the + output would be: + { + 'group': set('{tag1}', '{tag2}', '{tag3}), + 'group1': set('{tag2}','{tag3}'), + } + + """ + # Prepare a key set list we can use + tag_groups = set([str(x) for x in group_tags.keys()]) + + def _expand(tags, ignore=None): + """ + Expands based on tag provided and returns a set + + this also updates the group_tags while it goes + """ + + # Prepare ourselves a return set + results = set() + ignore = set() if ignore is None else ignore + + # track groups + groups = set() + + for tag in tags: + if tag in ignore: + continue + + # Track our groups + groups.add(tag) + + # Store what we know is worth keping + results |= group_tags[tag] - tag_groups + + # Get simple tag assignments + found = group_tags[tag] & tag_groups + if not found: + continue + + for gtag in found: + if gtag in ignore: + continue + + # Go deeper (recursion) + ignore.add(tag) + group_tags[gtag] = _expand(set([gtag]), ignore=ignore) + results |= group_tags[gtag] + + # Pop ignore + ignore.remove(tag) + + return results + + for tag in tag_groups: + # Get our tags + group_tags[tag] |= _expand(set([tag])) + if not group_tags[tag]: + ConfigBase.logger.warning( + 'The group {} has no tags assigned to it'.format(tag)) + del group_tags[tag] + + @staticmethod + def parse_url(url, verify_host=True): + """Parses the URL and returns it broken apart into a dictionary. + + This is very specific and customized for Apprise. + + Args: + url (str): The URL you want to fully parse. + verify_host (:obj:`bool`, optional): a flag kept with the parsed + URL which some child classes will later use to verify SSL + keys (if SSL transactions take place). Unless under very + specific circumstances, it is strongly recomended that + you leave this default value set to True. + + Returns: + A dictionary is returned containing the URL fully parsed if + successful, otherwise None is returned. + """ + + results = URLBase.parse_url(url, verify_host=verify_host) + + if not results: + # We're done; we failed to parse our url + return results + + # Allow overriding the default config format + if 'format' in results['qsd']: + results['format'] = results['qsd'].get('format') + if results['format'] not in common.CONFIG_FORMATS: + URLBase.logger.warning( + 'Unsupported format specified {}'.format( + results['format'])) + del results['format'] + + # Defines the encoding of the payload + if 'encoding' in results['qsd']: + results['encoding'] = results['qsd'].get('encoding') + + # Our cache value + if 'cache' in results['qsd']: + # First try to get it's integer value + try: + results['cache'] = int(results['qsd']['cache']) + + except (ValueError, TypeError): + # No problem, it just isn't an integer; now treat it as a bool + # instead: + results['cache'] = parse_bool(results['qsd']['cache']) + + return results + + @staticmethod + def detect_config_format(content, **kwargs): + """ + Takes the specified content and attempts to detect the format type + + The function returns the actual format type if detected, otherwise + it returns None + """ + + # Detect Format Logic: + # - A pound/hashtag (#) is alawys a comment character so we skip over + # lines matched here. + # - Detection begins on the first non-comment and non blank line + # matched. + # - If we find a string followed by a colon, we know we're dealing + # with a YAML file. + # - If we find a string that starts with a URL, or our tag + # definitions (accepting commas) followed by an equal sign we know + # we're dealing with a TEXT format. + + # Define what a valid line should look like + valid_line_re = re.compile( + r'^\s*(?P([;#]+(?P.*))|' + r'(?P((?P[ \t,a-z0-9_-]+)=)?[a-z0-9]+://.*)|' + r'((?P[a-z0-9]+):.*))?$', re.I) + + try: + # split our content up to read line by line + content = re.split(r'\r*\n', content) + + except TypeError: + # content was not expected string type + ConfigBase.logger.error( + 'Invalid Apprise configuration specified.') + return None + + # By default set our return value to None since we don't know + # what the format is yet + config_format = None + + # iterate over each line of the file to attempt to detect it + # stop the moment a the type has been determined + for line, entry in enumerate(content, start=1): + + result = valid_line_re.match(entry) + if not result: + # Invalid syntax + ConfigBase.logger.error( + 'Undetectable Apprise configuration found ' + 'based on line {}.'.format(line)) + # Take an early exit + return None + + # Attempt to detect configuration + if result.group('yaml'): + config_format = common.ConfigFormat.YAML + ConfigBase.logger.debug( + 'Detected YAML configuration ' + 'based on line {}.'.format(line)) + break + + elif result.group('text'): + config_format = common.ConfigFormat.TEXT + ConfigBase.logger.debug( + 'Detected TEXT configuration ' + 'based on line {}.'.format(line)) + break + + # If we reach here, we have a comment entry + # Adjust default format to TEXT + config_format = common.ConfigFormat.TEXT + + return config_format + + @staticmethod + def config_parse(content, asset=None, config_format=None, **kwargs): + """ + Takes the specified config content and loads it based on the specified + config_format. If a format isn't specified, then it is auto detected. + + """ + + if config_format is None: + # Detect the format + config_format = ConfigBase.detect_config_format(content) + + if not config_format: + # We couldn't detect configuration + ConfigBase.logger.error('Could not detect configuration') + return (list(), list()) + + if config_format not in common.CONFIG_FORMATS: + # Invalid configuration type specified + ConfigBase.logger.error( + 'An invalid configuration format ({}) was specified'.format( + config_format)) + return (list(), list()) + + # Dynamically load our parse_ function based on our config format + fn = getattr(ConfigBase, 'config_parse_{}'.format(config_format)) + + # Execute our config parse function which always returns a list + return fn(content=content, asset=asset) + + @staticmethod + def config_parse_text(content, asset=None): + """ + Parse the specified content as though it were a simple text file only + containing a list of URLs. + + Return a tuple that looks like (servers, configs) where: + - servers contains a list of loaded notification plugins + - configs contains a list of additional configuration files + referenced. + + You may also optionally associate an asset with the notification. + + The file syntax is: + + # + # pound/hashtag allow for line comments + # + # One or more tags can be idenified using comma's (,) to separate + # them. + = + + # Or you can use this format (no tags associated) + + + # you can also use the keyword 'include' and identify a + # configuration location (like this file) which will be included + # as additional configuration entries when loaded. + include + + # Assign tag contents to a group identifier + = + + """ + # A list of loaded Notification Services + servers = list() + + # A list of additional configuration files referenced using + # the include keyword + configs = list() + + # Track all of the tags we want to assign later on + group_tags = {} + + # Track our entries to preload + preloaded = [] + + # Prepare our Asset Object + asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset() + + # Define what a valid line should look like + valid_line_re = re.compile( + r'^\s*(?P([;#]+(?P.*))|' + r'(\s*(?P[a-z0-9, \t_-]+)\s*=|=)?\s*' + r'((?P[a-z0-9]{1,12}://.*)|(?P[a-z0-9, \t_-]+))|' + r'include\s+(?P.+))?\s*$', re.I) + + try: + # split our content up to read line by line + content = re.split(r'\r*\n', content) + + except TypeError: + # content was not expected string type + ConfigBase.logger.error( + 'Invalid Apprise TEXT based configuration specified.') + return (list(), list()) + + for line, entry in enumerate(content, start=1): + result = valid_line_re.match(entry) + if not result: + # Invalid syntax + ConfigBase.logger.error( + 'Invalid Apprise TEXT configuration format found ' + '{} on line {}.'.format(entry, line)) + + # Assume this is a file we shouldn't be parsing. It's owner + # can read the error printed to screen and take action + # otherwise. + return (list(), list()) + + # Retrieve our line + url, assign, config = \ + result.group('url'), \ + result.group('assign'), \ + result.group('config') + + if not (url or config or assign): + # Comment/empty line; do nothing + continue + + if config: + # CWE-312 (Secure Logging) Handling + loggable_url = config if not asset.secure_logging \ + else cwe312_url(config) + + ConfigBase.logger.debug( + 'Include URL: {}'.format(loggable_url)) + + # Store our include line + configs.append(config.strip()) + continue + + # CWE-312 (Secure Logging) Handling + loggable_url = url if not asset.secure_logging \ + else cwe312_url(url) + + if assign: + groups = set(parse_list(result.group('tags'), cast=str)) + if not groups: + # no tags were assigned + ConfigBase.logger.warning( + 'Unparseable tag assignment - no group(s) ' + 'on line {}'.format(line)) + continue + + # Get our tags + tags = set(parse_list(assign, cast=str)) + if not tags: + # no tags were assigned + ConfigBase.logger.warning( + 'Unparseable tag assignment - no tag(s) to assign ' + 'on line {}'.format(line)) + continue + + # Update our tag group map + for tag_group in groups: + if tag_group not in group_tags: + group_tags[tag_group] = set() + + # ensure our tag group is never included in the assignment + group_tags[tag_group] |= tags - set([tag_group]) + continue + + # Acquire our url tokens + results = plugins.url_to_dict( + url, secure_logging=asset.secure_logging) + if results is None: + # Failed to parse the server URL + ConfigBase.logger.warning( + 'Unparseable URL {} on line {}.'.format( + loggable_url, line)) + continue + + # Build a list of tags to associate with the newly added + # notifications if any were set + results['tag'] = set(parse_list(result.group('tags'), cast=str)) + + # Set our Asset Object + results['asset'] = asset + + # Store our preloaded entries + preloaded.append({ + 'results': results, + 'line': line, + 'loggable_url': loggable_url, + }) + + # + # Normalize Tag Groups + # - Expand Groups of Groups so that they don't exist + # + ConfigBase.__normalize_tag_groups(group_tags) + + # + # URL Processing + # + for entry in preloaded: + # Point to our results entry for easier reference below + results = entry['results'] + + # + # Apply our tag groups if they're defined + # + for group, tags in group_tags.items(): + # Detect if anything assigned to this tag also maps back to a + # group. If so we want to add the group to our list + if next((True for tag in results['tag'] + if tag in tags), False): + results['tag'].add(group) + + try: + # Attempt to create an instance of our plugin using the + # parsed URL information + plugin = common.NOTIFY_SCHEMA_MAP[ + results['schema']](**results) + + # Create log entry of loaded URL + ConfigBase.logger.debug( + 'Loaded URL: %s', plugin.url( + privacy=results['asset'].secure_logging)) + + except Exception as e: + # the arguments are invalid or can not be used. + ConfigBase.logger.warning( + 'Could not load URL {} on line {}.'.format( + entry['loggable_url'], entry['line'])) + ConfigBase.logger.debug('Loading Exception: %s' % str(e)) + continue + + # if we reach here, we successfully loaded our data + servers.append(plugin) + + # Return what was loaded + return (servers, configs) + + @staticmethod + def config_parse_yaml(content, asset=None): + """ + Parse the specified content as though it were a yaml file + specifically formatted for Apprise. + + Return a tuple that looks like (servers, configs) where: + - servers contains a list of loaded notification plugins + - configs contains a list of additional configuration files + referenced. + + You may optionally associate an asset with the notification. + + """ + + # A list of loaded Notification Services + servers = list() + + # A list of additional configuration files referenced using + # the include keyword + configs = list() + + # Group Assignments + group_tags = {} + + # Track our entries to preload + preloaded = [] + + try: + # Load our data (safely) + result = yaml.load(content, Loader=yaml.SafeLoader) + + except (AttributeError, + yaml.parser.ParserError, + yaml.error.MarkedYAMLError) as e: + # Invalid content + ConfigBase.logger.error( + 'Invalid Apprise YAML data specified.') + ConfigBase.logger.debug( + 'YAML Exception:{}{}'.format(os.linesep, e)) + return (list(), list()) + + if not isinstance(result, dict): + # Invalid content + ConfigBase.logger.error( + 'Invalid Apprise YAML based configuration specified.') + return (list(), list()) + + # YAML Version + version = result.get('version', 1) + if version != 1: + # Invalid syntax + ConfigBase.logger.error( + 'Invalid Apprise YAML version specified {}.'.format(version)) + return (list(), list()) + + # + # global asset object + # + asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset() + tokens = result.get('asset', None) + if tokens and isinstance(tokens, dict): + for k, v in tokens.items(): + + if k.startswith('_') or k.endswith('_'): + # Entries are considered reserved if they start or end + # with an underscore + ConfigBase.logger.warning( + 'Ignored asset key "{}".'.format(k)) + continue + + if not (hasattr(asset, k) and + isinstance(getattr(asset, k), + (bool, str))): + + # We can't set a function or non-string set value + ConfigBase.logger.warning( + 'Invalid asset key "{}".'.format(k)) + continue + + if v is None: + # Convert to an empty string + v = '' + + if (isinstance(v, (bool, str)) + and isinstance(getattr(asset, k), bool)): + + # If the object in the Asset is a boolean, then + # we want to convert the specified string to + # match that. + setattr(asset, k, parse_bool(v)) + + elif isinstance(v, str): + # Set our asset object with the new value + setattr(asset, k, v.strip()) + + else: + # we must set strings with a string + ConfigBase.logger.warning( + 'Invalid asset value to "{}".'.format(k)) + continue + # + # global tag root directive + # + global_tags = set() + + tags = result.get('tag', None) + if tags and isinstance(tags, (list, tuple, str)): + # Store any preset tags + global_tags = set(parse_list(tags, cast=str)) + + # + # groups root directive + # + groups = result.get('groups', None) + if not isinstance(groups, (list, tuple)): + # Not a problem; we simply have no group entry + groups = list() + + # Iterate over each group defined and store it + for no, entry in enumerate(groups): + if not isinstance(entry, dict): + ConfigBase.logger.warning( + 'No assignment for group {}, entry #{}'.format( + entry, no + 1)) + continue + + for _groups, tags in entry.items(): + for group in parse_list(_groups, cast=str): + if isinstance(tags, (list, tuple)): + _tags = set() + for e in tags: + if isinstance(e, dict): + _tags |= set(e.keys()) + else: + _tags |= set(parse_list(e, cast=str)) + + # Final assignment + tags = _tags + + else: + tags = set(parse_list(tags, cast=str)) + + if group not in group_tags: + group_tags[group] = tags + + else: + group_tags[group] |= tags + + # + # include root directive + # + includes = result.get('include', None) + if isinstance(includes, str): + # Support a single inline string or multiple ones separated by a + # comma and/or space + includes = parse_urls(includes) + + elif not isinstance(includes, (list, tuple)): + # Not a problem; we simply have no includes + includes = list() + + # Iterate over each config URL + for no, url in enumerate(includes): + + if isinstance(url, str): + # Support a single inline string or multiple ones separated by + # a comma and/or space + configs.extend(parse_urls(url)) + + elif isinstance(url, dict): + # Store the url and ignore arguments associated + configs.extend(u for u in url.keys()) + + # + # urls root directive + # + urls = result.get('urls', None) + if not isinstance(urls, (list, tuple)): + # Not a problem; we simply have no urls + urls = list() + + # Iterate over each URL + for no, url in enumerate(urls): + + # Our results object is what we use to instantiate our object if + # we can. Reset it to None on each iteration + results = list() + + # CWE-312 (Secure Logging) Handling + loggable_url = url if not asset.secure_logging \ + else cwe312_url(url) + + if isinstance(url, str): + # We're just a simple URL string... + schema = GET_SCHEMA_RE.match(url) + if schema is None: + # Log invalid entries so that maintainer of config + # config file at least has something to take action + # with. + ConfigBase.logger.warning( + 'Invalid URL {}, entry #{}'.format( + loggable_url, no + 1)) + continue + + # We found a valid schema worthy of tracking; store it's + # details: + _results = plugins.url_to_dict( + url, secure_logging=asset.secure_logging) + if _results is None: + ConfigBase.logger.warning( + 'Unparseable URL {}, entry #{}'.format( + loggable_url, no + 1)) + continue + + # add our results to our global set + results.append(_results) + + elif isinstance(url, dict): + # We are a url string with additional unescaped options. In + # this case we want to iterate over all of our options so we + # can at least tell the end user what entries were ignored + # due to errors + + it = iter(url.items()) + + # Track the URL to-load + _url = None + + # Track last acquired schema + schema = None + for key, tokens in it: + # Test our schema + _schema = GET_SCHEMA_RE.match(key) + if _schema is None: + # Log invalid entries so that maintainer of config + # config file at least has something to take action + # with. + ConfigBase.logger.warning( + 'Ignored entry {} found under urls, entry #{}' + .format(key, no + 1)) + continue + + # Store our schema + schema = _schema.group('schema').lower() + + # Store our URL and Schema Regex + _url = key + + if _url is None: + # the loop above failed to match anything + ConfigBase.logger.warning( + 'Unsupported URL, entry #{}'.format(no + 1)) + continue + + _results = plugins.url_to_dict( + _url, secure_logging=asset.secure_logging) + if _results is None: + # Setup dictionary + _results = { + # Minimum requirements + 'schema': schema, + } + + if isinstance(tokens, (list, tuple, set)): + # populate and/or override any results populated by + # parse_url() + for entries in tokens: + # Copy ourselves a template of our parsed URL as a base + # to work with + r = _results.copy() + + # We are a url string with additional unescaped options + if isinstance(entries, dict): + _url, tokens = next(iter(url.items())) + + # Tags you just can't over-ride + if 'schema' in entries: + del entries['schema'] + + # support our special tokens (if they're present) + if schema in common.NOTIFY_SCHEMA_MAP: + entries = ConfigBase._special_token_handler( + schema, entries) + + # Extend our dictionary with our new entries + r.update(entries) + + # add our results to our global set + results.append(r) + + elif isinstance(tokens, dict): + # support our special tokens (if they're present) + if schema in common.NOTIFY_SCHEMA_MAP: + tokens = ConfigBase._special_token_handler( + schema, tokens) + + # Copy ourselves a template of our parsed URL as a base to + # work with + r = _results.copy() + + # add our result set + r.update(tokens) + + # add our results to our global set + results.append(r) + + else: + # add our results to our global set + results.append(_results) + + else: + # Unsupported + ConfigBase.logger.warning( + 'Unsupported Apprise YAML entry #{}'.format(no + 1)) + continue + + # Track our entries + entry = 0 + + while len(results): + # Increment our entry count + entry += 1 + + # Grab our first item + _results = results.pop(0) + + if _results['schema'] not in common.NOTIFY_SCHEMA_MAP: + # the arguments are invalid or can not be used. + ConfigBase.logger.warning( + 'An invalid Apprise schema ({}) in YAML configuration ' + 'entry #{}, item #{}' + .format(_results['schema'], no + 1, entry)) + continue + + # tag is a special keyword that is managed by Apprise object. + # The below ensures our tags are set correctly + if 'tag' in _results: + # Tidy our list up + _results['tag'] = set( + parse_list(_results['tag'], cast=str)) | global_tags + + else: + # Just use the global settings + _results['tag'] = global_tags + + for key in list(_results.keys()): + # Strip out any tokens we know that we can't accept and + # warn the user + match = VALID_TOKEN.match(key) + if not match: + ConfigBase.logger.warning( + 'Ignoring invalid token ({}) found in YAML ' + 'configuration entry #{}, item #{}' + .format(key, no + 1, entry)) + del _results[key] + + ConfigBase.logger.trace( + 'URL #{}: {} unpacked as:{}{}' + .format(no + 1, url, os.linesep, os.linesep.join( + ['{}="{}"'.format(k, a) + for k, a in _results.items()]))) + + # Prepare our Asset Object + _results['asset'] = asset + + # Store our preloaded entries + preloaded.append({ + 'results': _results, + 'entry': no + 1, + 'item': entry, + }) + + # + # Normalize Tag Groups + # - Expand Groups of Groups so that they don't exist + # + ConfigBase.__normalize_tag_groups(group_tags) + + # + # URL Processing + # + for entry in preloaded: + # Point to our results entry for easier reference below + results = entry['results'] + + # + # Apply our tag groups if they're defined + # + for group, tags in group_tags.items(): + # Detect if anything assigned to this tag also maps back to a + # group. If so we want to add the group to our list + if next((True for tag in results['tag'] + if tag in tags), False): + results['tag'].add(group) + + # Now we generate our plugin + try: + # Attempt to create an instance of our plugin using the + # parsed URL information + plugin = common.\ + NOTIFY_SCHEMA_MAP[results['schema']](**results) + + # Create log entry of loaded URL + ConfigBase.logger.debug( + 'Loaded URL: %s', plugin.url( + privacy=results['asset'].secure_logging)) + + except Exception as e: + # the arguments are invalid or can not be used. + ConfigBase.logger.warning( + 'Could not load Apprise YAML configuration ' + 'entry #{}, item #{}' + .format(entry['entry'], entry['item'])) + ConfigBase.logger.debug('Loading Exception: %s' % str(e)) + continue + + # if we reach here, we successfully loaded our data + servers.append(plugin) + + return (servers, configs) + + def pop(self, index=-1): + """ + Removes an indexed Notification Service from the stack and returns it. + + By default, the last element of the list is removed. + """ + + if not isinstance(self._cached_servers, list): + # Generate ourselves a list of content we can pull from + self.servers() + + # Pop the element off of the stack + return self._cached_servers.pop(index) + + @staticmethod + def _special_token_handler(schema, tokens): + """ + This function takes a list of tokens and updates them to no longer + include any special tokens such as +,-, and : + + - schema must be a valid schema of a supported plugin type + - tokens must be a dictionary containing the yaml entries parsed. + + The idea here is we can post process a set of tokens provided in + a YAML file where the user provided some of the special keywords. + + We effectivley look up what these keywords map to their appropriate + value they're expected + """ + # Create a copy of our dictionary + tokens = tokens.copy() + + for kw, meta in common.NOTIFY_SCHEMA_MAP[schema]\ + .template_kwargs.items(): + + # Determine our prefix: + prefix = meta.get('prefix', '+') + + # Detect any matches + matches = \ + {k[1:]: str(v) for k, v in tokens.items() + if k.startswith(prefix)} + + if not matches: + # we're done with this entry + continue + + if not isinstance(tokens.get(kw), dict): + # Invalid; correct it + tokens[kw] = dict() + + # strip out processed tokens + tokens = {k: v for k, v in tokens.items() + if not k.startswith(prefix)} + + # Update our entries + tokens[kw].update(matches) + + # Now map our tokens accordingly to the class templates defined by + # each service. + # + # This is specifically used for YAML file parsing. It allows a user to + # define an entry such as: + # + # urls: + # - mailto://user:pass@domain: + # - to: user1@hotmail.com + # - to: user2@hotmail.com + # + # Under the hood, the NotifyEmail() class does not parse the `to` + # argument. It's contents needs to be mapped to `targets`. This is + # defined in the class via the `template_args` and template_tokens` + # section. + # + # This function here allows these mappings to take place within the + # YAML file as independant arguments. + class_templates = \ + plugins.details(common.NOTIFY_SCHEMA_MAP[schema]) + + for key in list(tokens.keys()): + + if key not in class_templates['args']: + # No need to handle non-arg entries + continue + + # get our `map_to` and/or 'alias_of' value (if it exists) + map_to = class_templates['args'][key].get( + 'alias_of', class_templates['args'][key].get('map_to', '')) + + if map_to == key: + # We're already good as we are now + continue + + if map_to in class_templates['tokens']: + meta = class_templates['tokens'][map_to] + + else: + meta = class_templates['args'].get( + map_to, class_templates['args'][key]) + + # Perform a translation/mapping if our code reaches here + value = tokens[key] + del tokens[key] + + # Detect if we're dealign with a list or not + is_list = re.search( + r'^list:.*', + meta.get('type'), + re.IGNORECASE) + + if map_to not in tokens: + tokens[map_to] = [] if is_list \ + else meta.get('default') + + elif is_list and not isinstance(tokens.get(map_to), list): + # Convert ourselves to a list if we aren't already + tokens[map_to] = [tokens[map_to]] + + # Type Conversion + if re.search( + r'^(choice:)?string', + meta.get('type'), + re.IGNORECASE) \ + and not isinstance(value, str): + + # Ensure our format is as expected + value = str(value) + + # Apply any further translations if required (absolute map) + # This is the case when an arg maps to a token which further + # maps to a different function arg on the class constructor + abs_map = meta.get('map_to', map_to) + + # Set our token as how it was provided by the configuration + if isinstance(tokens.get(map_to), list): + tokens[abs_map].append(value) + + else: + tokens[abs_map] = value + + # Return our tokens + return tokens + + def __getitem__(self, index): + """ + Returns the indexed server entry associated with the loaded + notification servers + """ + if not isinstance(self._cached_servers, list): + # Generate ourselves a list of content we can pull from + self.servers() + + return self._cached_servers[index] + + def __iter__(self): + """ + Returns an iterator to our server list + """ + if not isinstance(self._cached_servers, list): + # Generate ourselves a list of content we can pull from + self.servers() + + return iter(self._cached_servers) + + def __len__(self): + """ + Returns the total number of servers loaded + """ + if not isinstance(self._cached_servers, list): + # Generate ourselves a list of content we can pull from + self.servers() + + return len(self._cached_servers) + + def __bool__(self): + """ + Allows the Apprise object to be wrapped in an 'if statement'. + True is returned if our content was downloaded correctly. + """ + if not isinstance(self._cached_servers, list): + # Generate ourselves a list of content we can pull from + self.servers() + + return True if self._cached_servers else False diff --git a/lib/apprise/config/ConfigBase.pyi b/lib/apprise/config/ConfigBase.pyi new file mode 100644 index 0000000..abff120 --- /dev/null +++ b/lib/apprise/config/ConfigBase.pyi @@ -0,0 +1,3 @@ +from .. import URLBase + +class ConfigBase(URLBase): ... \ No newline at end of file diff --git a/lib/apprise/config/ConfigFile.py b/lib/apprise/config/ConfigFile.py new file mode 100644 index 0000000..7193551 --- /dev/null +++ b/lib/apprise/config/ConfigFile.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 re +import os +from .ConfigBase import ConfigBase +from ..common import ConfigFormat +from ..common import ContentIncludeMode +from ..AppriseLocale import gettext_lazy as _ + + +class ConfigFile(ConfigBase): + """ + A wrapper for File based configuration sources + """ + + # The default descriptive name associated with the service + service_name = _('Local File') + + # The default protocol + protocol = 'file' + + # Configuration file inclusion can only be of the same type + allow_cross_includes = ContentIncludeMode.STRICT + + def __init__(self, path, **kwargs): + """ + Initialize File Object + + headers can be a dictionary of key/value pairs that you want to + additionally include as part of the server headers to post with + + """ + super().__init__(**kwargs) + + # Store our file path as it was set + self.path = os.path.abspath(os.path.expanduser(path)) + + # Update the config path to be relative to our file we just loaded + self.config_path = os.path.dirname(self.path) + + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Prepare our cache value + if isinstance(self.cache, bool) or not self.cache: + cache = 'yes' if self.cache else 'no' + + else: + cache = int(self.cache) + + # Define any URL parameters + params = { + 'encoding': self.encoding, + 'cache': cache, + } + + if self.config_format: + # A format was enforced; make sure it's passed back with the url + params['format'] = self.config_format + + return 'file://{path}{params}'.format( + path=self.quote(self.path), + params='?{}'.format(self.urlencode(params)) if params else '', + ) + + def read(self, **kwargs): + """ + Perform retrieval of the configuration based on the specified request + """ + + response = None + + try: + if self.max_buffer_size > 0 and \ + os.path.getsize(self.path) > self.max_buffer_size: + + # Content exceeds maximum buffer size + self.logger.error( + 'File size exceeds maximum allowable buffer length' + ' ({}KB).'.format(int(self.max_buffer_size / 1024))) + return None + + except OSError: + # getsize() can throw this acception if the file is missing + # and or simply isn't accessible + self.logger.error( + 'File is not accessible: {}'.format(self.path)) + return None + + # Always call throttle before any server i/o is made + self.throttle() + + try: + with open(self.path, "rt", encoding=self.encoding) as f: + # Store our content for parsing + response = f.read() + + except (ValueError, UnicodeDecodeError): + # A result of our strict encoding check; if we receive this + # then the file we're opening is not something we can + # understand the encoding of.. + + self.logger.error( + 'File not using expected encoding ({}) : {}'.format( + self.encoding, self.path)) + return None + + except (IOError, OSError): + # IOError is present for backwards compatibility with Python + # versions older then 3.3. >= 3.3 throw OSError now. + + # Could not open and/or read the file; this is not a problem since + # we scan a lot of default paths. + self.logger.error( + 'File can not be opened for read: {}'.format(self.path)) + return None + + # Detect config format based on file extension if it isn't already + # enforced + if self.config_format is None and \ + re.match(r'^.*\.ya?ml\s*$', self.path, re.I) is not None: + + # YAML Filename Detected + self.default_config_format = ConfigFormat.YAML + + # Return our response object + return response + + @staticmethod + def parse_url(url): + """ + Parses the URL so that we can handle all different file paths + and return it as our path object + + """ + + results = ConfigBase.parse_url(url, verify_host=False) + if not results: + # We're done early; it's not a good URL + return results + + match = re.match(r'[a-z0-9]+://(?P[^?]+)(\?.*)?', url, re.I) + if not match: + return None + + results['path'] = ConfigFile.unquote(match.group('path')) + return results diff --git a/lib/apprise/config/ConfigHTTP.py b/lib/apprise/config/ConfigHTTP.py new file mode 100644 index 0000000..8e8677c --- /dev/null +++ b/lib/apprise/config/ConfigHTTP.py @@ -0,0 +1,275 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 re +import requests +from .ConfigBase import ConfigBase +from ..common import ConfigFormat +from ..common import ContentIncludeMode +from ..URLBase import PrivacyMode +from ..AppriseLocale import gettext_lazy as _ + +# Support YAML formats +# text/yaml +# text/x-yaml +# application/yaml +# application/x-yaml +MIME_IS_YAML = re.compile('(text|application)/(x-)?yaml', re.I) + +# Support TEXT formats +# text/plain +# text/html +MIME_IS_TEXT = re.compile('text/(plain|html)', re.I) + + +class ConfigHTTP(ConfigBase): + """ + A wrapper for HTTP based configuration sources + """ + + # The default descriptive name associated with the service + service_name = _('Web Based') + + # The default protocol + protocol = 'http' + + # The default secure protocol + secure_protocol = 'https' + + # If an HTTP error occurs, define the number of characters you still want + # to read back. This is useful for debugging purposes, but nothing else. + # The idea behind enforcing this kind of restriction is to prevent abuse + # from queries to services that may be untrusted. + max_error_buffer_size = 2048 + + # Configuration file inclusion can always include this type + allow_cross_includes = ContentIncludeMode.ALWAYS + + def __init__(self, headers=None, **kwargs): + """ + Initialize HTTP Object + + headers can be a dictionary of key/value pairs that you want to + additionally include as part of the server headers to post with + + """ + super().__init__(**kwargs) + + self.schema = 'https' if self.secure else 'http' + + self.fullpath = kwargs.get('fullpath') + if not isinstance(self.fullpath, str): + self.fullpath = '/' + + self.headers = {} + if headers: + # Store our extra headers + self.headers.update(headers) + + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Prepare our cache value + if isinstance(self.cache, bool) or not self.cache: + cache = 'yes' if self.cache else 'no' + + else: + cache = int(self.cache) + + # Define any arguments set + params = { + 'encoding': self.encoding, + 'cache': cache, + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + if self.config_format: + # A format was enforced; make sure it's passed back with the url + params['format'] = self.config_format + + # Append our headers into our args + params.update({'+{}'.format(k): v for k, v in self.headers.items()}) + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=self.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=self.quote(self.user, safe=''), + ) + + default_port = 443 if self.secure else 80 + + return '{schema}://{auth}{hostname}{port}{fullpath}/?{params}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + auth=auth, + hostname=self.quote(self.host, safe=''), + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + fullpath=self.quote(self.fullpath, safe='/'), + params=self.urlencode(params), + ) + + def read(self, **kwargs): + """ + Perform retrieval of the configuration based on the specified request + """ + + # prepare XML Object + headers = { + 'User-Agent': self.app_id, + } + + # Apply any/all header over-rides defined + headers.update(self.headers) + + auth = None + if self.user: + auth = (self.user, self.password) + + url = '%s://%s' % (self.schema, self.host) + if isinstance(self.port, int): + url += ':%d' % self.port + + url += self.fullpath + + self.logger.debug('HTTP POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + + # Prepare our response object + response = None + + # Where our request object will temporarily live. + r = None + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + # Make our request + with requests.post( + url, + headers=headers, + auth=auth, + verify=self.verify_certificate, + timeout=self.request_timeout, + stream=True) as r: + + # Handle Errors + r.raise_for_status() + + # Get our file-size (if known) + try: + file_size = int(r.headers.get('Content-Length', '0')) + except (TypeError, ValueError): + # Handle edge case where Content-Length is a bad value + file_size = 0 + + # Store our response + if self.max_buffer_size > 0 \ + and file_size > self.max_buffer_size: + + # Provide warning of data truncation + self.logger.error( + 'HTTP config response exceeds maximum buffer length ' + '({}KB);'.format(int(self.max_buffer_size / 1024))) + + # Return None - buffer execeeded + return None + + # Store our result (but no more than our buffer length) + response = r.text[:self.max_buffer_size + 1] + + # Verify that our content did not exceed the buffer size: + if len(response) > self.max_buffer_size: + # Provide warning of data truncation + self.logger.error( + 'HTTP config response exceeds maximum buffer length ' + '({}KB);'.format(int(self.max_buffer_size / 1024))) + + # Return None - buffer execeeded + return None + + # Detect config format based on mime if the format isn't + # already enforced + content_type = r.headers.get( + 'Content-Type', 'application/octet-stream') + if self.config_format is None and content_type: + if MIME_IS_YAML.match(content_type) is not None: + + # YAML data detected based on header content + self.default_config_format = ConfigFormat.YAML + + elif MIME_IS_TEXT.match(content_type) is not None: + + # TEXT data detected based on header content + self.default_config_format = ConfigFormat.TEXT + + except requests.RequestException as e: + self.logger.error( + 'A Connection error occurred retrieving HTTP ' + 'configuration from %s.' % self.host) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return None (signifying a failure) + return None + + # Return our response object + return response + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + results = ConfigBase.parse_url(url) + + if not results: + # We're done early as we couldn't load the results + return results + + # Add our headers that the user can potentially over-ride if they wish + # to to our returned result set + results['headers'] = results['qsd-'] + results['headers'].update(results['qsd+']) + + return results diff --git a/lib/apprise/config/ConfigMemory.py b/lib/apprise/config/ConfigMemory.py new file mode 100644 index 0000000..110e04a --- /dev/null +++ b/lib/apprise/config/ConfigMemory.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +from .ConfigBase import ConfigBase +from ..AppriseLocale import gettext_lazy as _ + + +class ConfigMemory(ConfigBase): + """ + For information that was loaded from memory and does not + persist anywhere. + """ + + # The default descriptive name associated with the service + service_name = _('Memory') + + # The default protocol + protocol = 'memory' + + def __init__(self, content, **kwargs): + """ + Initialize Memory Object + + Memory objects just store the raw configuration in memory. There is + no external reference point. It's always considered cached. + """ + super().__init__(**kwargs) + + # Store our raw config into memory + self.content = content + + if self.config_format is None: + # Detect our format if possible + self.config_format = \ + ConfigMemory.detect_config_format(self.content) + + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + return 'memory://' + + def read(self, **kwargs): + """ + Simply return content stored into memory + """ + + return self.content + + @staticmethod + def parse_url(url): + """ + Memory objects have no parseable URL + + """ + # These URLs can not be parsed + return None diff --git a/lib/apprise/config/__init__.py b/lib/apprise/config/__init__.py new file mode 100644 index 0000000..4b7e3fd --- /dev/null +++ b/lib/apprise/config/__init__.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 re +from os import listdir +from os.path import dirname +from os.path import abspath +from ..logger import logger +from ..common import CONFIG_SCHEMA_MAP + +__all__ = [] + + +# Load our Lookup Matrix +def __load_matrix(path=abspath(dirname(__file__)), name='apprise.config'): + """ + Dynamically load our schema map; this allows us to gracefully + skip over modules we simply don't have the dependencies for. + + """ + # Used for the detection of additional Configuration Services objects + # The .py extension is optional as we support loading directories too + module_re = re.compile(r'^(?PConfig[a-z0-9]+)(\.py)?$', re.I) + + for f in listdir(path): + match = module_re.match(f) + if not match: + # keep going + continue + + # Store our notification/plugin name: + plugin_name = match.group('name') + try: + module = __import__( + '{}.{}'.format(name, plugin_name), + globals(), locals(), + fromlist=[plugin_name]) + + except ImportError: + # No problem, we can't use this object + continue + + if not hasattr(module, plugin_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. + continue + + # Get our plugin + plugin = getattr(module, plugin_name) + if not hasattr(plugin, 'app_id'): + # Filter out non-notification modules + continue + + elif plugin_name in __all__: + # we're already handling this object + continue + + # Add our module name to our __all__ + __all__.append(plugin_name) + + # Ensure we provide the class as the reference to this directory and + # not the module: + globals()[plugin_name] = plugin + + 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 CONFIG_SCHEMA_MAP: + logger.error( + "Config schema ({}) mismatch detected - {} to {}" + .format(schema, CONFIG_SCHEMA_MAP[schema], plugin)) + continue + + # Assign plugin + CONFIG_SCHEMA_MAP[schema] = plugin + + return CONFIG_SCHEMA_MAP + + +# Dynamically build our schema base +__load_matrix() diff --git a/lib/apprise/conversion.py b/lib/apprise/conversion.py new file mode 100644 index 0000000..d3781f6 --- /dev/null +++ b/lib/apprise/conversion.py @@ -0,0 +1,198 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 re +from markdown import markdown +from .common import NotifyFormat +from .URLBase import URLBase + +from html.parser import HTMLParser + + +def convert_between(from_format, to_format, content): + """ + Converts between different suported formats. If no conversion exists, + or the selected one fails, the original text will be returned. + + This function returns the content translated (if required) + """ + + converters = { + (NotifyFormat.MARKDOWN, NotifyFormat.HTML): markdown_to_html, + (NotifyFormat.TEXT, NotifyFormat.HTML): text_to_html, + (NotifyFormat.HTML, NotifyFormat.TEXT): html_to_text, + # For now; use same converter for Markdown support + (NotifyFormat.HTML, NotifyFormat.MARKDOWN): html_to_text, + } + + convert = converters.get((from_format, to_format)) + return convert(content) if convert else content + + +def markdown_to_html(content): + """ + Converts specified content from markdown to HTML. + """ + + return markdown(content) + + +def text_to_html(content): + """ + Converts specified content from plain text to HTML. + """ + + # First eliminate any carriage returns + return URLBase.escape_html(content, convert_new_lines=True) + + +def html_to_text(content): + """ + Converts a content from HTML to plain text. + """ + + parser = HTMLConverter() + parser.feed(content) + parser.close() + return parser.converted + + +class HTMLConverter(HTMLParser, object): + """An HTML to plain text converter tuned for email messages.""" + + # The following tags must start on a new line + BLOCK_TAGS = ('p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'div', 'td', 'th', 'code', 'pre', 'label', 'li',) + + # the folowing tags ignore any internal text + IGNORE_TAGS = ( + 'form', 'input', 'textarea', 'select', 'ul', 'ol', 'style', 'link', + 'meta', 'title', 'html', 'head', 'script') + + # Condense Whitespace + WS_TRIM = re.compile(r'[\s]+', re.DOTALL | re.MULTILINE) + + # Sentinel value for block tag boundaries, which may be consolidated into a + # single line break. + BLOCK_END = {} + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + # Shoudl we store the text content or not? + self._do_store = True + + # Initialize internal result list + self._result = [] + + # Initialize public result field (not populated until close() is + # called) + self.converted = "" + + def close(self): + string = ''.join(self._finalize(self._result)) + self.converted = string.strip() + + def _finalize(self, result): + """ + Combines and strips consecutive strings, then converts consecutive + block ends into singleton newlines. + + [ {be} " Hello " {be} {be} " World!" ] -> "\nHello\nWorld!" + """ + + # None means the last visited item was a block end. + accum = None + + for item in result: + if item == self.BLOCK_END: + # Multiple consecutive block ends; do nothing. + if accum is None: + continue + + # First block end; yield the current string, plus a newline. + yield accum.strip() + '\n' + accum = None + + # Multiple consecutive strings; combine them. + elif accum is not None: + accum += item + + # First consecutive string; store it. + else: + accum = item + + # Yield the last string if we have not already done so. + if accum is not None: + yield accum.strip() + + def handle_data(self, data, *args, **kwargs): + """ + Store our data if it is not on the ignore list + """ + + # initialize our previous flag + if self._do_store: + + # Tidy our whitespace + content = self.WS_TRIM.sub(' ', data) + self._result.append(content) + + def handle_starttag(self, tag, attrs): + """ + Process our starting HTML Tag + """ + # Toggle initial states + self._do_store = tag not in self.IGNORE_TAGS + + if tag in self.BLOCK_TAGS: + self._result.append(self.BLOCK_END) + + if tag == 'li': + self._result.append('- ') + + elif tag == 'br': + self._result.append('\n') + + elif tag == 'hr': + if self._result: + self._result[-1] = self._result[-1].rstrip(' ') + + self._result.append('\n---\n') + + elif tag == 'blockquote': + self._result.append(' >') + + def handle_endtag(self, tag): + """ + Edge case handling of open/close tags + """ + self._do_store = True + + if tag in self.BLOCK_TAGS: + self._result.append(self.BLOCK_END) diff --git a/lib/apprise/decorators/CustomNotifyPlugin.py b/lib/apprise/decorators/CustomNotifyPlugin.py new file mode 100644 index 0000000..5ccfded --- /dev/null +++ b/lib/apprise/decorators/CustomNotifyPlugin.py @@ -0,0 +1,231 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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.USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +from ..plugins.NotifyBase import NotifyBase +from ..utils import URL_DETAILS_RE +from ..utils import parse_url +from ..utils import url_assembly +from ..utils import dict_full_update +from .. import common +from ..logger import logger +import inspect + + +class CustomNotifyPlugin(NotifyBase): + """ + Apprise Custom Plugin Hook + + This gets initialized based on @notify decorator definitions + + """ + # Our Custom notification + service_url = 'https://github.com/caronc/apprise/wiki/Custom_Notification' + + # Over-ride our category since this inheritance of the NotifyBase class + # should be treated differently. + category = 'custom' + + # Define object templates + templates = ( + '{schema}://', + ) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns arguments retrieved + + """ + return parse_url(url, verify_host=False, simple=True) + + def url(self, privacy=False, *args, **kwargs): + """ + General URL assembly + """ + return '{schema}://'.format(schema=self.secure_protocol) + + @staticmethod + def instantiate_plugin(url, send_func, name=None): + """ + The function used to add a new notification plugin based on the schema + parsed from the provided URL into our supported matrix structure. + """ + + if not isinstance(url, str): + msg = 'An invalid custom notify url/schema ({}) provided in ' \ + 'function {}.'.format(url, send_func.__name__) + logger.warning(msg) + return None + + # Validate that our schema is okay + re_match = URL_DETAILS_RE.match(url) + if not re_match: + msg = 'An invalid custom notify url/schema ({}) provided in ' \ + 'function {}.'.format(url, send_func.__name__) + logger.warning(msg) + return None + + # Acquire our plugin name + plugin_name = re_match.group('schema').lower() + + if not re_match.group('base'): + url = '{}://'.format(plugin_name) + + # Keep a default set of arguments to apply to all called references + base_args = parse_url( + url, default_schema=plugin_name, verify_host=False, simple=True) + + if plugin_name in common.NOTIFY_SCHEMA_MAP: + # we're already handling this object + msg = 'The schema ({}) is already defined and could not be ' \ + 'loaded from custom notify function {}.' \ + .format(url, send_func.__name__) + logger.warning(msg) + return None + + # We define our own custom wrapper class so that we can initialize + # some key default configuration values allowing calls to our + # `Apprise.details()` to correctly differentiate one custom plugin + # that was loaded from another + class CustomNotifyPluginWrapper(CustomNotifyPlugin): + + # Our Service Name + service_name = name if isinstance(name, str) \ + and name else 'Custom - {}'.format(plugin_name) + + # Store our matched schema + secure_protocol = plugin_name + + requirements = { + # Define our required packaging in order to work + 'details': "Source: {}".format(inspect.getfile(send_func)) + } + + # Assign our send() function + __send = staticmethod(send_func) + + # Update our default arguments + _base_args = base_args + + def __init__(self, **kwargs): + """ + Our initialization + + """ + # init parent + super().__init__(**kwargs) + + self._default_args = {} + + # Apply our updates based on what was parsed + dict_full_update(self._default_args, self._base_args) + dict_full_update(self._default_args, kwargs) + + # Update our arguments (applying them to what we originally) + # initialized as + self._default_args['url'] = url_assembly(**self._default_args) + + def send(self, body, title='', notify_type=common.NotifyType.INFO, + *args, **kwargs): + """ + Our send() call which triggers our hook + """ + + response = False + try: + # Enforce a boolean response + result = self.__send( + body, title, notify_type, *args, + meta=self._default_args, **kwargs) + + if result is None: + # The wrapper did not define a return (or returned + # None) + # this is treated as a successful return as it is + # assumed the developer did not care about the result + # of the call. + response = True + + else: + # Perform boolean check (allowing obects to also be + # returned and check against the __bool__ call + response = True if result else False + + except Exception as e: + # Unhandled Exception + self.logger.warning( + 'An exception occured sending a %s notification.', + common. + NOTIFY_SCHEMA_MAP[self.secure_protocol].service_name) + self.logger.debug( + '%s Exception: %s', + common.NOTIFY_SCHEMA_MAP[self.secure_protocol], str(e)) + return False + + if response: + self.logger.info( + 'Sent %s notification.', + common. + NOTIFY_SCHEMA_MAP[self.secure_protocol].service_name) + else: + self.logger.warning( + 'Failed to send %s notification.', + common. + NOTIFY_SCHEMA_MAP[self.secure_protocol].service_name) + return response + + # Store our plugin into our core map file + common.NOTIFY_SCHEMA_MAP[plugin_name] = CustomNotifyPluginWrapper + + # Update our custom plugin map + module_pyname = str(send_func.__module__) + if module_pyname not in common.NOTIFY_CUSTOM_MODULE_MAP: + # Support non-dynamic includes as well... + common.NOTIFY_CUSTOM_MODULE_MAP[module_pyname] = { + 'path': inspect.getfile(send_func), + + # Initialize our template + 'notify': {}, + } + + common.\ + NOTIFY_CUSTOM_MODULE_MAP[module_pyname]['notify'][plugin_name] = { + # Our Serivice Description (for API and CLI --details view) + 'name': CustomNotifyPluginWrapper.service_name, + # The name of the send function the @notify decorator wrapped + 'fn_name': send_func.__name__, + # The URL that was provided in the @notify decorator call + # associated with the 'on=' + 'url': url, + # The Initialized Plugin that was generated based on the above + # parameters + 'plugin': CustomNotifyPluginWrapper} + + # return our plugin + return common.NOTIFY_SCHEMA_MAP[plugin_name] diff --git a/lib/apprise/decorators/__init__.py b/lib/apprise/decorators/__init__.py new file mode 100644 index 0000000..5b089bb --- /dev/null +++ b/lib/apprise/decorators/__init__.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +from .notify import notify + + +__all__ = [ + 'notify' +] diff --git a/lib/apprise/decorators/notify.py b/lib/apprise/decorators/notify.py new file mode 100644 index 0000000..07b4ceb --- /dev/null +++ b/lib/apprise/decorators/notify.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +from .CustomNotifyPlugin import CustomNotifyPlugin + + +def notify(on, name=None): + """ + @notify decorator allows you to map functions you've defined to be loaded + as a regular notify by Apprise. You must identify a protocol that + users will trigger your call by. + + @notify(on="foobar") + def your_declaration(body, title, notify_type, meta, *args, **kwargs): + ... + + You can optionally provide the name to associate with the plugin which + is what calling functions via the API will receive. + + @notify(on="foobar", name="My Foobar Process") + def your_action(body, title, notify_type, meta, *args, **kwargs): + ... + + The meta variable is actually the processed URL contents found in + configuration files that landed you in this function you wrote in + the first place. It's very easily tokenized already for you so + that you can bend the notification logic to your hearts content. + + @notify(on="foobar", name="My Foobar Process") + def your_action(body, title, notify_type, body_format, meta, attach, + *args, **kwargs): + ... + + Arguments break down as follows: + body: The message body associated with the notification + title: The message title associated with the notification + notify_type: The message type (info, success, warning, and failure) + body_format: The format of the incoming notification body. This is + either text, html, or markdown. + meta: Combines the URL arguments specified on the `on` call + with the ones loaded from a users configuration. This + is a dictionary that presents itself like this: + { + 'schema': 'http', + 'url': 'http://hostname', + 'host': 'hostname', + + 'user': 'john', + 'password': 'doe', + 'port': 80, + 'path': '/', + 'fullpath': '/test.php', + 'query': 'test.php', + + 'qsd': {'key': 'value', 'key2': 'value2'}, + + 'asset': , + 'tag': set(), + } + + Meta entries are ONLY present if found. A simple URL + such as foobar:// would only produce the following: + { + 'schema': 'foobar', + 'url': 'foobar://', + + 'asset': , + 'tag': set(), + } + + attach: An array AppriseAttachment objects (if any were provided) + + body_format: Defaults to the expected format output; By default this + will be TEXT unless over-ridden in the Apprise URL + + + If you don't intend on using all of the parameters, your @notify() call + # can be greatly simplified to just: + + @notify(on="foobar", name="My Foobar Process") + def your_action(body, title, *args, **kwargs) + + Always end your wrappers declaration with *args and **kwargs to be future + proof with newer versions of Apprise. + + Your wrapper should return True if processed the send() function as you + expected and return False if not. If nothing is returned, then this is + treated as as success (True). + + """ + def wrapper(func): + """ + Instantiate our custom (notification) plugin + """ + + # Generate + CustomNotifyPlugin.instantiate_plugin( + url=on, send_func=func, name=name) + + return func + + return wrapper diff --git a/lib/apprise/i18n/__init__.py b/lib/apprise/i18n/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/apprise/i18n/en/LC_MESSAGES/apprise.mo b/lib/apprise/i18n/en/LC_MESSAGES/apprise.mo new file mode 100644 index 0000000000000000000000000000000000000000..0236722fd244ec05141fa5179d8aa6cd3772af5b GIT binary patch literal 3959 zcmeH|?~7Db7{^bkrIS{!M1c}Vuqff~&Tg*S=&G|n)Ybhl?aZc8gm-7o&R)H9&vfsd zbvGg^iC$zuU|=ak^umIEy(p-ppnM@h7T6E$MIjMV22oJyi-^A8d!O0er2im?v*&Z3 z=RD7Ip7We@&wkf3|9OSq{rFpfzu&K>H2eE>fl}8(JPl^RufQeXIpHthV#t4i*MWdLB=@@-Uz-5((Zlmdhi5@rp};a zoNq+_9%Md0fz104kbc|@^Ur`tS=|EC?pBcgt7ZN!na_*74P?HfB9DQzuY>c!X^|fV zY4;R}CDkF2@sEI9_ZY}^-xd9Fkaj1*rQl~E^F1f?KZD%oMUZ~iU{TI52C2VUcst1W zognMf12T`2=v|QUElB^zLDu!4=#PSo_om1nf{gb$$T;7E+{XnFsi;3e+W!sGZV8Hu zp;`_y{wk39wt=+o6m|*wgo7aMM?uzc56CzML{ovtQ(%%e$an{ZheUrAWS+-B=JO87 zx_$!A15b&38a#59QeT3MQ^%$lcM7E60pW8Xe?STG27i29W2q6Qtb(AnQ5~GXDn1IFE?@B#0%{L6G?z2I=<- z$o$^`S@+{0?LG$Se_H0hmiZqEey_>=TcZC!cnU;QXVI}v=RxLiQS|c>gz;NI z#$N@pu5F^<45F!fMBW23UI;SIV<7kO49NTN0*IzwMn}81LDu6VknvA~%=ZjP`?JDx z!XJgdg0#N`vX1kR6yq!ed0tCJUJfRCgN)ZETqpX?AoJV-GM`-_>uNyWYhUC#sPct= zz0*vqe7S6*=(3^i?`=*LO*u4iQ!3egCZJ#&L9B~3t5kHNja0r~_r0^Yv~}J@0oCe!1$5su|d?RnJ`&rMvz5% zO>D}srvLE%z=W#TOnPO*P4#+F-FK(;R%>HU*qZAFs<#m)S*X5A6U329XZ3)sTD3K_ zHQfg@KOH6+CCb8t4U8VLYFlU#HFf zd*e(Ythuoo4y$e;RZzo|CLH(eeho{b3T_nbw_!yUY#6IT=%HNkGzNCk!z#(%M@$vv zRw>eQjhLF9G|fX9v9=be;vkYMnh^0-u{>dFu1brg=bXQ2cq=e$geFZRb<-uUW^5x? zMI3#FeTb6oKut?-ST(Vhvza5tthmgkNAPcH&U9K}u}y@iY1s~8?u>h)na!h!JBM6ZUU4b8)j6?Cw(gaiQ=<{em6{=*nTkmkx26_Tpo4`YVov%xSk?4?U#S-nyo> zGc#i9D48=DRlSPS-Ka)R$>y|=i{4SSeq~FQ-$rZM)-r|RlG78K;pbDe9Q?3!#AhRz5+Np9eByESmhWwWW>fE&e5DRhI#cVipoP;n%td)yE& z%*OvnVN+(n4XO>i9yx7-8Nn$Tw?>WLI54oq_gZaO-IOW#`0Ra$-(i%~K|K+pu8s{l z9d_Lj=#^c%{mu=U!T!O%+4{7%W;1wmV!RbjX}XTJV%&{3JS<+YL6;}oFfwshBOZ6w z&)RVp<0f>H>+A*9oL)cX#hGme4-`s;(oW}ew>xHh-O<|4C9XWtSDtA8@4fOw|G%H; Fe*o8VrcwX^ literal 0 HcmV?d00001 diff --git a/lib/apprise/logger.py b/lib/apprise/logger.py new file mode 100644 index 0000000..6a594ec --- /dev/null +++ b/lib/apprise/logger.py @@ -0,0 +1,201 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 os +import logging +from io import StringIO + +# The root identifier needed to monitor 'apprise' logging +LOGGER_NAME = 'apprise' + +# Define a verbosity level that is a noisier then debug mode +logging.TRACE = logging.DEBUG - 1 + +# Define a verbosity level that is always used even when no verbosity is set +# from the command line. The idea here is to allow for deprecation notices +logging.DEPRECATE = logging.ERROR + 1 + +# Assign our Levels into our logging object +logging.addLevelName(logging.DEPRECATE, "DEPRECATION WARNING") +logging.addLevelName(logging.TRACE, "TRACE") + + +def trace(self, message, *args, **kwargs): + """ + Verbose Debug Logging - Trace + """ + if self.isEnabledFor(logging.TRACE): + self._log(logging.TRACE, message, args, **kwargs) + + +def deprecate(self, message, *args, **kwargs): + """ + Deprication Warning Logging + """ + if self.isEnabledFor(logging.DEPRECATE): + self._log(logging.DEPRECATE, message, args, **kwargs) + + +# Assign our Loggers for use in Apprise +logging.Logger.trace = trace +logging.Logger.deprecate = deprecate + +# Create ourselve a generic (singleton) logging reference +logger = logging.getLogger(LOGGER_NAME) + + +class LogCapture: + """ + A class used to allow one to instantiate loggers that write to + memory for temporary purposes. e.g.: + + 1. with LogCapture() as captured: + 2. + 3. # Send our notification(s) + 4. aobj.notify("hello world") + 5. + 6. # retrieve our logs produced by the above call via our + 7. # `captured` StringIO object we have access to within the `with` + 8. # block here: + 9. print(captured.getvalue()) + + """ + def __init__(self, path=None, level=None, name=LOGGER_NAME, delete=True, + fmt='%(asctime)s - %(levelname)s - %(message)s'): + """ + Instantiate a temporary log capture object + + If a path is specified, then log content is sent to that file instead + of a StringIO object. + + You can optionally specify a logging level such as logging.INFO if you + wish, otherwise by default the script uses whatever logging has been + set globally. If you set delete to `False` then when using log files, + they are not automatically cleaned up afterwards. + + Optionally over-ride the fmt as well if you wish. + + """ + # Our memory buffer placeholder + self.__buffer_ptr = StringIO() + + # Store our file path as it will determine whether or not we write to + # memory and a file + self.__path = path + self.__delete = delete + + # Our logging level tracking + self.__level = level + self.__restore_level = None + + # Acquire a pointer to our logger + self.__logger = logging.getLogger(name) + + # Prepare our handler + self.__handler = logging.StreamHandler(self.__buffer_ptr) \ + if not self.__path else logging.FileHandler( + self.__path, mode='a', encoding='utf-8') + + # Use the specified level, otherwise take on the already + # effective level of our logger + self.__handler.setLevel( + self.__level if self.__level is not None + else self.__logger.getEffectiveLevel()) + + # Prepare our formatter + self.__handler.setFormatter(logging.Formatter(fmt)) + + def __enter__(self): + """ + Allows logger manipulation within a 'with' block + """ + + if self.__level is not None: + # Temporary adjust our log level if required + self.__restore_level = self.__logger.getEffectiveLevel() + if self.__restore_level > self.__level: + # Bump our log level up for the duration of our `with` + self.__logger.setLevel(self.__level) + + else: + # No restoration required + self.__restore_level = None + + else: + # Do nothing but enforce that we have nothing to restore to + self.__restore_level = None + + if self.__path: + # If a path has been identified, ensure we can write to the path + # and that the file exists + with open(self.__path, 'a'): + os.utime(self.__path, None) + + # Update our buffer pointer + self.__buffer_ptr = open(self.__path, 'r') + + # Add our handler + self.__logger.addHandler(self.__handler) + + # return our memory pointer + return self.__buffer_ptr + + def __exit__(self, exc_type, exc_value, tb): + """ + removes the handler gracefully when the with block has completed + """ + + # Flush our content + self.__handler.flush() + self.__buffer_ptr.flush() + + # Drop our handler + self.__logger.removeHandler(self.__handler) + + if self.__restore_level is not None: + # Restore level + self.__logger.setLevel(self.__restore_level) + + if self.__path: + # Close our file pointer + self.__buffer_ptr.close() + self.__handler.close() + if self.__delete: + try: + # Always remove file afterwards + os.unlink(self.__path) + + except OSError: + # It's okay if the file does not exist + pass + + if exc_type is not None: + # pass exception on if one was generated + return False + + return True diff --git a/lib/apprise/plugins/NotifyAppriseAPI.py b/lib/apprise/plugins/NotifyAppriseAPI.py new file mode 100644 index 0000000..3c85b8a --- /dev/null +++ b/lib/apprise/plugins/NotifyAppriseAPI.py @@ -0,0 +1,483 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 re +import requests +from json import dumps +import base64 + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..utils import parse_list +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +class AppriseAPIMethod: + """ + Defines the method to post data tot he remote server + """ + JSON = 'json' + FORM = 'form' + + +APPRISE_API_METHODS = ( + AppriseAPIMethod.FORM, + AppriseAPIMethod.JSON, +) + + +class NotifyAppriseAPI(NotifyBase): + """ + A wrapper for Apprise (Persistent) API Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Apprise API' + + # The services URL + service_url = 'https://github.com/caronc/apprise-api' + + # The default protocol + protocol = 'apprise' + + # The default secure protocol + secure_protocol = 'apprises' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_apprise_api' + + # Support attachments + attachment_support = True + + # Depending on the number of transactions/notifications taking place, this + # could take a while. 30 seconds should be enough to perform the task + socket_read_timeout = 30.0 + + # Disable throttle rate for Apprise API requests since they are normally + # local anyway + request_rate_per_sec = 0.0 + + # Define object templates + templates = ( + '{schema}://{host}/{token}', + '{schema}://{host}:{port}/{token}', + '{schema}://{user}@{host}/{token}', + '{schema}://{user}@{host}:{port}/{token}', + '{schema}://{user}:{password}@{host}/{token}', + '{schema}://{user}:{password}@{host}:{port}/{token}', + ) + + # Define our tokens; these are the minimum tokens required required to + # be passed into this function (as arguments). The syntax appends any + # previously defined in the base package and builds onto them + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'user': { + 'name': _('Username'), + 'type': 'string', + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + }, + 'token': { + 'name': _('Token'), + 'type': 'string', + 'required': True, + 'private': True, + 'regex': (r'^[A-Z0-9_-]{1,32}$', 'i'), + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'tags': { + 'name': _('Tags'), + 'type': 'string', + }, + 'method': { + 'name': _('Query Method'), + 'type': 'choice:string', + 'values': APPRISE_API_METHODS, + 'default': APPRISE_API_METHODS[0], + }, + 'to': { + 'alias_of': 'token', + }, + }) + + # Define any kwargs we're using + template_kwargs = { + 'headers': { + 'name': _('HTTP Header'), + 'prefix': '+', + }, + } + + def __init__(self, token=None, tags=None, method=None, headers=None, + **kwargs): + """ + Initialize Apprise API Object + + headers can be a dictionary of key/value pairs that you want to + additionally include as part of the server headers to post with + + """ + super().__init__(**kwargs) + + self.token = validate_regex( + token, *self.template_tokens['token']['regex']) + if not self.token: + msg = 'The Apprise API token specified ({}) is invalid.'\ + .format(token) + self.logger.warning(msg) + raise TypeError(msg) + + self.method = self.template_args['method']['default'] \ + if not isinstance(method, str) else method.lower() + + if self.method not in APPRISE_API_METHODS: + msg = 'The method specified ({}) is invalid.'.format(method) + self.logger.warning(msg) + raise TypeError(msg) + + # Build list of tags + self.__tags = parse_list(tags) + + self.headers = {} + if headers: + # Store our extra headers + self.headers.update(headers) + + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'method': self.method, + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Append our headers into our parameters + params.update({'+{}'.format(k): v for k, v in self.headers.items()}) + + if self.__tags: + params['tags'] = ','.join([x for x in self.__tags]) + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=NotifyAppriseAPI.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=NotifyAppriseAPI.quote(self.user, safe=''), + ) + + default_port = 443 if self.secure else 80 + + fullpath = self.fullpath.strip('/') + return '{schema}://{auth}{hostname}{port}{fullpath}{token}' \ + '/?{params}'.format( + schema=self.secure_protocol + if self.secure else self.protocol, + auth=auth, + # never encode hostname since we're expecting it to be a + # valid one + hostname=self.host, + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + fullpath='/{}/'.format(NotifyAppriseAPI.quote( + fullpath, safe='/')) if fullpath else '/', + token=self.pprint(self.token, privacy, safe=''), + params=NotifyAppriseAPI.urlencode(params)) + + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): + """ + Perform Apprise API Notification + """ + + # Prepare HTTP Headers + headers = { + 'User-Agent': self.app_id, + } + + # Apply any/all header over-rides defined + headers.update(self.headers) + + attachments = [] + files = [] + if attach and self.attachment_support: + for no, attachment in enumerate(attach, start=1): + # 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: + if self.method == AppriseAPIMethod.JSON: + with open(attachment.path, 'rb') as f: + # Output must be in a DataURL format (that's what + # PushSafer calls it): + attachments.append({ + 'filename': attachment.name, + 'base64': base64.b64encode(f.read()) + .decode('utf-8'), + 'mimetype': attachment.mimetype, + }) + + else: # AppriseAPIMethod.FORM + files.append(( + 'file{:02d}'.format(no), + ( + attachment.name, + open(attachment.path, 'rb'), + attachment.mimetype, + ) + )) + + 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 Apprise API Object + payload = { + # Apprise API Payload + 'title': title, + 'body': body, + 'type': notify_type, + 'format': self.notify_format, + } + + if self.method == AppriseAPIMethod.JSON: + headers['Content-Type'] = 'application/json' + + if attachments: + payload['attachments'] = attachments + + payload = dumps(payload) + + if self.__tags: + payload['tag'] = self.__tags + + auth = None + if self.user: + auth = (self.user, self.password) + + # Set our schema + schema = 'https' if self.secure else 'http' + + url = '%s://%s' % (schema, self.host) + if isinstance(self.port, int): + url += ':%d' % self.port + + fullpath = self.fullpath.strip('/') + url += '{}'.format('/' + fullpath) if fullpath else '' + url += '/notify/{}'.format(self.token) + + # Some entries can not be over-ridden + headers.update({ + # Our response to be in JSON format always + 'Accept': 'application/json', + # Pass our Source UUID4 Identifier + 'X-Apprise-ID': self.asset._uid, + # Pass our current recursion count to our upstream server + 'X-Apprise-Recursion-Count': str(self.asset._recursion + 1), + }) + + self.logger.debug('Apprise API POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('Apprise API Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + url, + data=payload, + headers=headers, + auth=auth, + files=files if files else None, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyAppriseAPI.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send Apprise API notification: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info( + 'Sent Apprise API notification; method=%s.', self.method) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Apprise API ' + 'notification to %s.' % self.host) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while reading one of the ' + 'attached files.') + self.logger.debug('I/O Exception: %s' % str(e)) + return False + + finally: + for file in files: + # Ensure all files are closed + file[1][1].close() + + return True + + @staticmethod + def parse_native_url(url): + """ + Support http://hostname/notify/token and + http://hostname/path/notify/token + """ + + result = re.match( + r'^http(?Ps?)://(?P[A-Z0-9._-]+)' + r'(:(?P[0-9]+))?' + r'(?P/[^?]+?)?/notify/(?P[A-Z0-9_-]{1,32})/?' + r'(?P\?.+)?$', url, re.I) + + if result: + return NotifyAppriseAPI.parse_url( + '{schema}://{hostname}{port}{path}/{token}/{params}'.format( + schema=NotifyAppriseAPI.secure_protocol + if result.group('secure') else NotifyAppriseAPI.protocol, + hostname=result.group('hostname'), + port='' if not result.group('port') + else ':{}'.format(result.group('port')), + path='' if not result.group('path') + else result.group('path'), + token=result.group('token'), + params='' if not result.group('params') + else '?{}'.format(result.group('params')))) + + return None + + @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 + + # Add our headers that the user can potentially over-ride if they wish + # to to our returned result set and tidy entries by unquoting them + results['headers'] = \ + {NotifyAppriseAPI.unquote(x): NotifyAppriseAPI.unquote(y) + for x, y in results['qsd+'].items()} + + # Support the passing of tags in the URL + if 'tags' in results['qsd'] and len(results['qsd']['tags']): + results['tags'] = \ + NotifyAppriseAPI.parse_list(results['qsd']['tags']) + + # Support the 'to' & 'token' variable so that we can support rooms + # this way too. + if 'token' in results['qsd'] and len(results['qsd']['token']): + results['token'] = \ + NotifyAppriseAPI.unquote(results['qsd']['token']) + + elif 'to' in results['qsd'] and len(results['qsd']['to']): + results['token'] = NotifyAppriseAPI.unquote(results['qsd']['to']) + + else: + # Start with a list of path entries to work with + entries = NotifyAppriseAPI.split_path(results['fullpath']) + if entries: + # use our last entry found + results['token'] = entries[-1] + + # pop our last entry off + entries = entries[:-1] + + # re-assemble our full path + results['fullpath'] = '/'.join(entries) + + # Set method if specified + if 'method' in results['qsd'] and len(results['qsd']['method']): + results['method'] = \ + NotifyAppriseAPI.unquote(results['qsd']['method']) + + return results diff --git a/lib/apprise/plugins/NotifyBark.py b/lib/apprise/plugins/NotifyBark.py new file mode 100644 index 0000000..edef82b --- /dev/null +++ b/lib/apprise/plugins/NotifyBark.py @@ -0,0 +1,517 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# +# API: https://github.com/Finb/bark-server/blob/master/docs/API_V2.md#python +# +import requests +import json + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyImageSize +from ..common import NotifyType +from ..utils import parse_list +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + + +# Sounds generated off of: https://github.com/Finb/Bark/tree/master/Sounds +BARK_SOUNDS = ( + "alarm.caf", + "anticipate.caf", + "bell.caf", + "birdsong.caf", + "bloom.caf", + "calypso.caf", + "chime.caf", + "choo.caf", + "descent.caf", + "electronic.caf", + "fanfare.caf", + "glass.caf", + "gotosleep.caf", + "healthnotification.caf", + "horn.caf", + "ladder.caf", + "mailsent.caf", + "minuet.caf", + "multiwayinvitation.caf", + "newmail.caf", + "newsflash.caf", + "noir.caf", + "paymentsuccess.caf", + "shake.caf", + "sherwoodforest.caf", + "silence.caf", + "spell.caf", + "suspense.caf", + "telegraph.caf", + "tiptoes.caf", + "typewriters.caf", + "update.caf", +) + + +# Supported Level Entries +class NotifyBarkLevel: + """ + Defines the Bark Level options + """ + ACTIVE = 'active' + + TIME_SENSITIVE = 'timeSensitive' + + PASSIVE = 'passive' + + +BARK_LEVELS = ( + NotifyBarkLevel.ACTIVE, + NotifyBarkLevel.TIME_SENSITIVE, + NotifyBarkLevel.PASSIVE, +) + + +class NotifyBark(NotifyBase): + """ + A wrapper for Notify Bark Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Bark' + + # The services URL + service_url = 'https://github.com/Finb/Bark' + + # The default protocol + protocol = 'bark' + + # The default secure protocol + secure_protocol = 'barks' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_bark' + + # Allows the user to specify the NotifyImageSize object; this is supported + # through the webhook + image_size = NotifyImageSize.XY_128 + + # Define object templates + templates = ( + '{schema}://{host}/{targets}', + '{schema}://{host}:{port}/{targets}', + '{schema}://{user}:{password}@{host}/{targets}', + '{schema}://{user}:{password}@{host}:{port}/{targets}', + ) + + # Define our template arguments + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'user': { + 'name': _('Username'), + 'type': 'string', + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + }, + 'target_device': { + 'name': _('Target Device'), + 'type': 'string', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'sound': { + 'name': _('Sound'), + 'type': 'choice:string', + 'values': BARK_SOUNDS, + }, + 'level': { + 'name': _('Level'), + 'type': 'choice:string', + 'values': BARK_LEVELS, + }, + 'click': { + 'name': _('Click'), + 'type': 'string', + }, + 'badge': { + 'name': _('Badge'), + 'type': 'int', + 'min': 0, + }, + 'category': { + 'name': _('Category'), + 'type': 'string', + }, + 'group': { + 'name': _('Group'), + 'type': 'string', + }, + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': True, + 'map_to': 'include_image', + }, + }) + + def __init__(self, targets=None, include_image=True, sound=None, + category=None, group=None, level=None, click=None, + badge=None, **kwargs): + """ + Initialize Notify Bark Object + """ + super().__init__(**kwargs) + + # Prepare our URL + self.notify_url = '%s://%s%s/push' % ( + 'https' if self.secure else 'http', + self.host, + ':{}'.format(self.port) + if (self.port and isinstance(self.port, int)) else '', + ) + + # Assign our category + self.category = \ + category if isinstance(category, str) else None + + # Assign our group + self.group = group if isinstance(group, str) else None + + # Initialize device list + self.targets = parse_list(targets) + + # Place an image inline with the message body + self.include_image = include_image + + # A clickthrough option for notifications + self.click = click + + # Badge + try: + # Acquire our badge count if we can: + # - We accept both the integer form as well as a string + # representation + self.badge = int(badge) + if self.badge < 0: + raise ValueError() + + except TypeError: + # NoneType means use Default; this is an okay exception + self.badge = None + + except ValueError: + self.badge = None + self.logger.warning( + 'The specified Bark badge ({}) is not valid ', badge) + + # Sound (easy-lookup) + self.sound = None if not sound else next( + (f for f in BARK_SOUNDS if f.startswith(sound.lower())), None) + if sound and not self.sound: + self.logger.warning( + 'The specified Bark sound ({}) was not found ', sound) + + # Level + self.level = None if not level else next( + (f for f in BARK_LEVELS if f[0] == level[0]), None) + if level and not self.level: + self.logger.warning( + 'The specified Bark level ({}) is not valid ', level) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Bark Notification + """ + + # error tracking (used for function return) + has_error = False + + if not self.targets: + # We have nothing to notify; we're done + self.logger.warning('There are no Bark devices to notify') + return False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json; charset=utf-8', + } + + # Prepare our payload (sample below) + # { + # "body": "Test Bark Server", + # "device_key": "nysrshcqielvoxsa", + # "title": "bleem", + # "category": "category", + # "sound": "minuet.caf", + # "badge": 1, + # "icon": "https://day.app/assets/images/avatar.jpg", + # "group": "test", + # "url": "https://mritd.com" + # } + payload = { + 'title': title if title else self.app_desc, + 'body': body, + } + + # 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: + payload['icon'] = image_url + + if self.sound: + payload['sound'] = self.sound + + if self.click: + payload['url'] = self.click + + if self.badge: + payload['badge'] = self.badge + + if self.level: + payload['level'] = self.level + + if self.category: + payload['category'] = self.category + + if self.group: + payload['group'] = self.group + + auth = None + if self.user: + auth = (self.user, self.password) + + # Create a copy of the targets + targets = list(self.targets) + + while len(targets) > 0: + # Retrieve our device key + target = targets.pop() + + payload['device_key'] = target + self.logger.debug('Bark POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('Bark Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + self.notify_url, + data=json.dumps(payload), + headers=headers, + auth=auth, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyBark.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send Bark notification to {}: ' + '{}{}error={}.'.format( + target, + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent Bark notification to {}.'.format(target)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Bark ' + 'notification to {}.'.format(target)) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + return not has_error + + def url(self, 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', + } + + if self.sound: + params['sound'] = self.sound + + if self.click: + params['click'] = self.click + + if self.badge: + params['badge'] = str(self.badge) + + if self.level: + params['level'] = self.level + + if self.category: + params['category'] = self.category + + if self.group: + params['group'] = self.group + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=NotifyBark.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=NotifyBark.quote(self.user, safe=''), + ) + + default_port = 443 if self.secure else 80 + + return '{schema}://{auth}{hostname}{port}/{targets}?{params}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + auth=auth, + # never encode hostname since we're expecting it to be a valid one + hostname=self.host, + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + targets='/'.join( + [NotifyBark.quote('{}'.format(x)) for x in self.targets]), + params=NotifyBark.urlencode(params), + ) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return 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) + if not results: + # We're done early as we couldn't load the results + return results + + # Apply our targets + results['targets'] = NotifyBark.split_path(results['fullpath']) + + # Category + if 'category' in results['qsd'] and results['qsd']['category']: + results['category'] = NotifyBark.unquote( + results['qsd']['category'].strip()) + + # Group + if 'group' in results['qsd'] and results['qsd']['group']: + results['group'] = NotifyBark.unquote( + results['qsd']['group'].strip()) + + # Badge + if 'badge' in results['qsd'] and results['qsd']['badge']: + results['badge'] = NotifyBark.unquote( + results['qsd']['badge'].strip()) + + # Level + if 'level' in results['qsd'] and results['qsd']['level']: + results['level'] = NotifyBark.unquote( + results['qsd']['level'].strip()) + + # Click (URL) + if 'click' in results['qsd'] and results['qsd']['click']: + results['click'] = NotifyBark.unquote( + results['qsd']['click'].strip()) + + # Sound + if 'sound' in results['qsd'] and results['qsd']['sound']: + results['sound'] = NotifyBark.unquote( + results['qsd']['sound'].strip()) + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyBark.parse_list(results['qsd']['to']) + + # use image= for consistency with the other plugins + results['include_image'] = \ + parse_bool(results['qsd'].get('image', True)) + + return results diff --git a/lib/apprise/plugins/NotifyBase.py b/lib/apprise/plugins/NotifyBase.py new file mode 100644 index 0000000..5138c15 --- /dev/null +++ b/lib/apprise/plugins/NotifyBase.py @@ -0,0 +1,569 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 asyncio +import re +from functools import partial + +from ..URLBase import URLBase +from ..common import NotifyType +from ..common import NOTIFY_TYPES +from ..common import NotifyFormat +from ..common import NOTIFY_FORMATS +from ..common import OverflowMode +from ..common import OVERFLOW_MODES +from ..AppriseLocale import gettext_lazy as _ +from ..AppriseAttachment import AppriseAttachment + + +class NotifyBase(URLBase): + """ + This is the base class for all notification services + """ + + # An internal flag used to test the state of the plugin. If set to + # False, then the plugin is not used. Plugins can disable themselves + # due to enviroment issues (such as missing libraries, or platform + # dependencies that are not present). By default all plugins are + # enabled. + enabled = True + + # The category allows for parent inheritance of this object to alter + # this when it's function/use is intended to behave differently. The + # following category types exist: + # + # native: Is a native plugin written/stored in `apprise/plugins/Notify*` + # custom: Is a custom plugin written/stored in a users plugin directory + # that they loaded at execution time. + category = 'native' + + # Some plugins may require additional packages above what is provided + # already by Apprise. + # + # Use this section to relay this information to the users of the script to + # help guide them with what they need to know if they plan on using your + # plugin. The below configuration should otherwise accomodate all normal + # situations and will not requrie any updating: + requirements = { + # Use the description to provide a human interpretable description of + # what is required to make the plugin work. This is only nessisary + # if there are package dependencies. Setting this to default will + # cause a general response to be returned. Only set this if you plan + # on over-riding the default. Always consider language support here. + # So before providing a value do the following in your code base: + # + # from apprise.AppriseLocale import gettext_lazy as _ + # + # 'details': _('My detailed requirements') + 'details': None, + + # Define any required packages needed for the plugin to run. This is + # an array of strings that simply look like lines residing in a + # `requirements.txt` file... + # + # As an example, an entry may look like: + # 'packages_required': [ + # 'cryptography < 3.4`, + # ] + 'packages_required': [], + + # Recommended packages identify packages that are not required to make + # your plugin work, but would improve it's use or grant it access to + # full functionality (that might otherwise be limited). + + # Similar to `packages_required`, you would identify each entry in + # the array as you would in a `requirements.txt` file. + # + # - Do not re-provide entries already in the `packages_required` + 'packages_recommended': [], + } + + # The services URL + service_url = None + + # A URL that takes you to the setup/help of the specific protocol + setup_url = None + + # Most Servers do not like more then 1 request per 5 seconds, so 5.5 gives + # us a safe play range. Override the one defined already in the URLBase + request_rate_per_sec = 5.5 + + # Allows the user to specify the NotifyImageSize object + image_size = None + + # The maximum allowable characters allowed in the body per message + body_maxlen = 32768 + + # Defines the maximum allowable characters in the title; set this to zero + # if a title can't be used. Titles that are not used but are defined are + # automatically placed into the body + title_maxlen = 250 + + # Set the maximum line count; if this is set to anything larger then zero + # the message (prior to it being sent) will be truncated to this number + # of lines. Setting this to zero disables this feature. + body_max_line_count = 0 + + # Default Notify Format + notify_format = NotifyFormat.TEXT + + # Default Overflow Mode + overflow_mode = OverflowMode.UPSTREAM + + # Support Attachments; this defaults to being disabled. + # Since apprise allows you to send attachments without a body or title + # defined, by letting Apprise know the plugin won't support attachments + # up front, it can quickly pass over and ignore calls to these end points. + + # You must set this to true if your application can handle attachments. + # You must also consider a flow change to your notification if this is set + # to True as well as now there will be cases where both the body and title + # may not be set. There will never be a case where a body, or attachment + # isn't set in the same call to your notify() function. + attachment_support = False + + # Default Title HTML Tagging + # When a title is specified for a notification service that doesn't accept + # titles, by default apprise tries to give a plesant view and convert the + # title so that it can be placed into the body. The default is to just + # use a tag. The below causes the title to get generated: + default_html_tag_id = 'b' + + # Here is where we define all of the arguments we accept on the url + # such as: schema://whatever/?overflow=upstream&format=text + # These act the same way as tokens except they are optional and/or + # have default values set if mandatory. This rule must be followed + template_args = dict(URLBase.template_args, **{ + 'overflow': { + 'name': _('Overflow Mode'), + 'type': 'choice:string', + 'values': OVERFLOW_MODES, + # Provide a default + 'default': overflow_mode, + # look up default using the following parent class value at + # runtime. The variable name identified here (in this case + # overflow_mode) is checked and it's result is placed over-top of + # the 'default'. This is done because once a parent class inherits + # this one, the overflow_mode already set as a default 'could' be + # potentially over-ridden and changed to a different value. + '_lookup_default': 'overflow_mode', + }, + 'format': { + 'name': _('Notify Format'), + 'type': 'choice:string', + 'values': NOTIFY_FORMATS, + # Provide a default + 'default': notify_format, + # look up default using the following parent class value at + # runtime. + '_lookup_default': 'notify_format', + }, + }) + + def __init__(self, **kwargs): + """ + Initialize some general configuration that will keep things consistent + when working with the notifiers that will inherit this class. + + """ + + super().__init__(**kwargs) + + if 'format' in kwargs: + # Store the specified format if specified + notify_format = kwargs.get('format', '') + if notify_format.lower() not in NOTIFY_FORMATS: + msg = 'Invalid notification format {}'.format(notify_format) + self.logger.error(msg) + raise TypeError(msg) + + # Provide override + self.notify_format = notify_format + + if 'overflow' in kwargs: + # Store the specified format if specified + overflow = kwargs.get('overflow', '') + if overflow.lower() not in OVERFLOW_MODES: + msg = 'Invalid overflow method {}'.format(overflow) + self.logger.error(msg) + raise TypeError(msg) + + # Provide override + self.overflow_mode = overflow + + def image_url(self, notify_type, logo=False, extension=None, + image_size=None): + """ + Returns Image URL if possible + """ + + if not self.image_size: + return None + + if notify_type not in NOTIFY_TYPES: + return None + + return self.asset.image_url( + notify_type=notify_type, + image_size=self.image_size if image_size is None else image_size, + logo=logo, + extension=extension, + ) + + def image_path(self, notify_type, extension=None): + """ + Returns the path of the image if it can + """ + if not self.image_size: + return None + + if notify_type not in NOTIFY_TYPES: + return None + + return self.asset.image_path( + notify_type=notify_type, + image_size=self.image_size, + extension=extension, + ) + + def image_raw(self, notify_type, extension=None): + """ + Returns the raw image if it can + """ + if not self.image_size: + return None + + if notify_type not in NOTIFY_TYPES: + return None + + return self.asset.image_raw( + notify_type=notify_type, + image_size=self.image_size, + extension=extension, + ) + + def color(self, notify_type, color_type=None): + """ + Returns the html color (hex code) associated with the notify_type + """ + if notify_type not in NOTIFY_TYPES: + return None + + return self.asset.color( + notify_type=notify_type, + color_type=color_type, + ) + + def notify(self, *args, **kwargs): + """ + Performs notification + """ + try: + # Build a list of dictionaries that can be used to call send(). + send_calls = list(self._build_send_calls(*args, **kwargs)) + + except TypeError: + # Internal error + return False + + else: + # Loop through each call, one at a time. (Use a list rather than a + # generator to call all the partials, even in case of a failure.) + the_calls = [self.send(**kwargs2) for kwargs2 in send_calls] + return all(the_calls) + + async def async_notify(self, *args, **kwargs): + """ + Performs notification for asynchronous callers + """ + try: + # Build a list of dictionaries that can be used to call send(). + send_calls = list(self._build_send_calls(*args, **kwargs)) + + except TypeError: + # Internal error + return False + + else: + loop = asyncio.get_event_loop() + + # Wrap each call in a coroutine that uses the default executor. + # TODO: In the future, allow plugins to supply a native + # async_send() method. + async def do_send(**kwargs2): + send = partial(self.send, **kwargs2) + result = await loop.run_in_executor(None, send) + return result + + # gather() all calls in parallel. + the_cors = (do_send(**kwargs2) for kwargs2 in send_calls) + return all(await asyncio.gather(*the_cors)) + + def _build_send_calls(self, body=None, title=None, + notify_type=NotifyType.INFO, overflow=None, + attach=None, body_format=None, **kwargs): + """ + Get a list of dictionaries that can be used to call send() or + (in the future) async_send(). + """ + + if not self.enabled: + # Deny notifications issued to services that are disabled + msg = f"{self.service_name} is currently disabled on this system." + self.logger.warning(msg) + raise TypeError(msg) + + # Prepare attachments if required + if attach is not None and not isinstance(attach, AppriseAttachment): + try: + attach = AppriseAttachment(attach, asset=self.asset) + + except TypeError: + # bad attachments + raise + + # Handle situations where the body is None + body = '' if not body else body + + elif not (body or attach): + # If there is not an attachment at the very least, a body must be + # present + msg = "No message body or attachment was specified." + self.logger.warning(msg) + raise TypeError(msg) + + if not body and not self.attachment_support: + # If no body was specified, then we know that an attachment + # was. This is logic checked earlier in the code. + # + # Knowing this, if the plugin itself doesn't support sending + # attachments, there is nothing further to do here, just move + # along. + msg = f"{self.service_name} does not support attachments; " \ + " service skipped" + self.logger.warning(msg) + raise TypeError(msg) + + # Handle situations where the title is None + title = '' if not title else title + + # Apply our overflow (if defined) + for chunk in self._apply_overflow( + body=body, title=title, overflow=overflow, + body_format=body_format): + + # Send notification + yield dict( + body=chunk['body'], title=chunk['title'], + notify_type=notify_type, attach=attach, + body_format=body_format + ) + + def _apply_overflow(self, body, title=None, overflow=None, + body_format=None): + """ + Takes the message body and title as input. This function then + applies any defined overflow restrictions associated with the + notification service and may alter the message if/as required. + + The function will always return a list object in the following + structure: + [ + { + title: 'the title goes here', + body: 'the message body goes here', + }, + { + title: 'the title goes here', + body: 'the message body goes here', + }, + + ] + """ + + response = list() + + # tidy + title = '' if not title else title.strip() + body = '' if not body else body.rstrip() + + if overflow is None: + # default + overflow = self.overflow_mode + + if self.title_maxlen <= 0 and len(title) > 0: + + if self.notify_format == NotifyFormat.HTML: + # Content is appended to body as html + body = '<{open_tag}>{title}' \ + '
\r\n{body}'.format( + open_tag=self.default_html_tag_id, + title=title, + close_tag=self.default_html_tag_id, + body=body) + + elif self.notify_format == NotifyFormat.MARKDOWN and \ + body_format == NotifyFormat.TEXT: + # Content is appended to body as markdown + title = title.lstrip('\r\n \t\v\f#-') + if title: + # Content is appended to body as text + body = '# {}\r\n{}'.format(title, body) + + else: + # Content is appended to body as text + body = '{}\r\n{}'.format(title, body) + + title = '' + + # Enforce the line count first always + if self.body_max_line_count > 0: + # Limit results to just the first 2 line otherwise + # there is just to much content to display + body = re.split(r'\r*\n', body) + body = '\r\n'.join(body[0:self.body_max_line_count]) + + if overflow == OverflowMode.UPSTREAM: + # Nothing more to do + response.append({'body': body, 'title': title}) + return response + + elif len(title) > self.title_maxlen: + # Truncate our Title + title = title[:self.title_maxlen] + + if self.body_maxlen > 0 and len(body) <= self.body_maxlen: + response.append({'body': body, 'title': title}) + return response + + if overflow == OverflowMode.TRUNCATE: + # Truncate our body and return + response.append({ + 'body': body[:self.body_maxlen], + 'title': title, + }) + # For truncate mode, we're done now + return response + + # If we reach here, then we are in SPLIT mode. + # For here, we want to split the message as many times as we have to + # in order to fit it within the designated limits. + response = [{ + 'body': body[i: i + self.body_maxlen], + 'title': title} for i in range(0, len(body), self.body_maxlen)] + + return response + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Should preform the actual notification itself. + + """ + raise NotImplementedError( + "send() is not implimented by the child class.") + + def url_parameters(self, *args, **kwargs): + """ + Provides a default set of parameters to work with. This can greatly + simplify URL construction in the acommpanied url() function in all + defined plugin services. + """ + + params = { + 'format': self.notify_format, + 'overflow': self.overflow_mode, + } + + params.update(super().url_parameters(*args, **kwargs)) + + # return default parameters + return params + + @staticmethod + def parse_url(url, verify_host=True, plus_to_space=False): + """Parses the URL and returns it broken apart into a dictionary. + + This is very specific and customized for Apprise. + + + Args: + url (str): The URL you want to fully parse. + verify_host (:obj:`bool`, optional): a flag kept with the parsed + URL which some child classes will later use to verify SSL + keys (if SSL transactions take place). Unless under very + specific circumstances, it is strongly recomended that + you leave this default value set to True. + + Returns: + A dictionary is returned containing the URL fully parsed if + successful, otherwise None is returned. + """ + results = URLBase.parse_url( + url, verify_host=verify_host, plus_to_space=plus_to_space) + + if not results: + # We're done; we failed to parse our url + return results + + # Allow overriding the default format + if 'format' in results['qsd']: + results['format'] = results['qsd'].get('format') + if results['format'] not in NOTIFY_FORMATS: + URLBase.logger.warning( + 'Unsupported format specified {}'.format( + results['format'])) + del results['format'] + + # Allow overriding the default overflow + if 'overflow' in results['qsd']: + results['overflow'] = results['qsd'].get('overflow') + if results['overflow'] not in OVERFLOW_MODES: + URLBase.logger.warning( + 'Unsupported overflow specified {}'.format( + results['overflow'])) + del results['overflow'] + + return results + + @staticmethod + def parse_native_url(url): + """ + This is a base class that can be optionally over-ridden by child + classes who can build their Apprise URL based on the one provided + by the notification service they choose to use. + + The intent of this is to make Apprise a little more userfriendly + to people who aren't familiar with constructing URLs and wish to + use the ones that were just provied by their notification serivice + that they're using. + + This function will return None if the passed in URL can't be matched + as belonging to the notification service. Otherwise this function + should return the same set of results that parse_url() does. + """ + return None diff --git a/lib/apprise/plugins/NotifyBase.pyi b/lib/apprise/plugins/NotifyBase.pyi new file mode 100644 index 0000000..9cf3e40 --- /dev/null +++ b/lib/apprise/plugins/NotifyBase.pyi @@ -0,0 +1 @@ +class NotifyBase: ... \ No newline at end of file diff --git a/lib/apprise/plugins/NotifyBoxcar.py b/lib/apprise/plugins/NotifyBoxcar.py new file mode 100644 index 0000000..9d3be6a --- /dev/null +++ b/lib/apprise/plugins/NotifyBoxcar.py @@ -0,0 +1,399 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 re +import requests +import hmac +from json import dumps +from time import time +from hashlib import sha1 +from itertools import chain +try: + from urlparse import urlparse + +except ImportError: + from urllib.parse import urlparse + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..utils import parse_bool +from ..utils import parse_list +from ..utils import validate_regex +from ..common import NotifyType +from ..common import NotifyImageSize +from ..AppriseLocale import gettext_lazy as _ + +# Default to sending to all devices if nothing is specified +DEFAULT_TAG = '@all' + +# The tags value is an structure containing an array of strings defining the +# list of tagged devices that the notification need to be send to, and a +# boolean operator (‘and’ / ‘or’) that defines the criteria to match devices +# against those tags. +IS_TAG = re.compile(r'^[@]?(?P[A-Z0-9]{1,63})$', re.I) + +# Device tokens are only referenced when developing. +# It's not likely you'll send a message directly to a device, but if you do; +# this plugin supports it. +IS_DEVICETOKEN = re.compile(r'^[A-Z0-9]{64}$', re.I) + +# Used to break apart list of potential tags by their delimiter into a useable +# list. +TAGS_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') + + +class NotifyBoxcar(NotifyBase): + """ + A wrapper for Boxcar Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Boxcar' + + # The services URL + service_url = 'https://boxcar.io/' + + # All boxcar notifications are secure + secure_protocol = 'boxcar' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_boxcar' + + # Boxcar URL + notify_url = 'https://boxcar-api.io/api/push/' + + # 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 = 10000 + + # Define object templates + templates = ( + '{schema}://{access_key}/{secret_key}/', + '{schema}://{access_key}/{secret_key}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'access_key': { + 'name': _('Access Key'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[A-Z0-9_-]{64}$', 'i'), + 'map_to': 'access', + }, + 'secret_key': { + 'name': _('Secret Key'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[A-Z0-9_-]{64}$', 'i'), + 'map_to': 'secret', + }, + 'target_tag': { + 'name': _('Target Tag ID'), + 'type': 'string', + 'prefix': '@', + 'regex': (r'^[A-Z0-9]{1,63}$', 'i'), + 'map_to': 'targets', + }, + 'target_device': { + 'name': _('Target Device ID'), + 'type': 'string', + 'regex': (r'^[A-Z0-9]{64}$', 'i'), + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': True, + 'map_to': 'include_image', + }, + 'to': { + 'alias_of': 'targets', + }, + 'access': { + 'alias_of': 'access_key', + }, + 'secret': { + 'alias_of': 'secret_key', + }, + }) + + def __init__(self, access, secret, targets=None, include_image=True, + **kwargs): + """ + Initialize Boxcar Object + """ + super().__init__(**kwargs) + + # Initialize tag list + self._tags = list() + + # Initialize device_token list + self.device_tokens = list() + + # Access Key (associated with project) + self.access = validate_regex( + access, *self.template_tokens['access_key']['regex']) + if not self.access: + msg = 'An invalid Boxcar Access Key ' \ + '({}) was specified.'.format(access) + self.logger.warning(msg) + raise TypeError(msg) + + # Secret Key (associated with project) + self.secret = validate_regex( + secret, *self.template_tokens['secret_key']['regex']) + if not self.secret: + msg = 'An invalid Boxcar Secret Key ' \ + '({}) was specified.'.format(secret) + self.logger.warning(msg) + raise TypeError(msg) + + if not targets: + self._tags.append(DEFAULT_TAG) + targets = [] + + # Validate targets and drop bad ones: + for target in parse_list(targets): + result = IS_TAG.match(target) + if result: + # store valid tag/alias + self._tags.append(result.group('name')) + continue + + result = IS_DEVICETOKEN.match(target) + if result: + # store valid device + self.device_tokens.append(target) + continue + + self.logger.warning( + 'Dropped invalid tag/alias/device_token ' + '({}) specified.'.format(target), + ) + + # Track whether or not we want to send an image with our notification + # or not. + self.include_image = include_image + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Boxcar Notification + """ + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json' + } + + # prepare Boxcar Object + payload = { + 'aps': { + 'badge': 'auto', + 'alert': '', + }, + 'expires': str(int(time() + 30)), + } + + if title: + payload['aps']['@title'] = title + + payload['aps']['alert'] = body + + if self._tags: + payload['tags'] = {'or': self._tags} + + if self.device_tokens: + payload['device_tokens'] = self.device_tokens + + # Source picture should be <= 450 DP wide, ~2:1 aspect. + image_url = None if not self.include_image \ + else self.image_url(notify_type) + + if image_url: + # Set our image + payload['@img'] = image_url + + # Acquire our hostname + host = urlparse(self.notify_url).hostname + + # Calculate signature. + str_to_sign = "%s\n%s\n%s\n%s" % ( + "POST", host, "/api/push", dumps(payload)) + + h = hmac.new( + bytearray(self.secret, 'utf-8'), + bytearray(str_to_sign, 'utf-8'), + sha1, + ) + + params = NotifyBoxcar.urlencode({ + "publishkey": self.access, + "signature": h.hexdigest(), + }) + + notify_url = '%s?%s' % (self.notify_url, params) + self.logger.debug('Boxcar POST URL: %s (cert_verify=%r)' % ( + notify_url, self.verify_certificate, + )) + self.logger.debug('Boxcar Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + notify_url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + # Boxcar returns 201 (Created) when successful + if r.status_code != requests.codes.created: + # We had a problem + status_str = \ + NotifyBoxcar.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send Boxcar notification: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info('Sent Boxcar notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Boxcar ' + 'notification to %s.' % (host)) + + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + 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)) + + return '{schema}://{access}/{secret}/{targets}?{params}'.format( + schema=self.secure_protocol, + access=self.pprint(self.access, privacy, safe=''), + secret=self.pprint( + self.secret, privacy, mode=PrivacyMode.Secret, safe=''), + targets='/'.join([ + NotifyBoxcar.quote(x, safe='') for x in chain( + self._tags, self.device_tokens) if x != DEFAULT_TAG]), + params=NotifyBoxcar.urlencode(params), + ) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self._tags) + len(self.device_tokens) + # DEFAULT_TAG is set if no tokens/tags are otherwise set + return targets if targets > 0 else 1 + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns it broken apart into a dictionary. + + """ + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early + return None + + # The first token is stored in the hostname + results['access'] = NotifyBoxcar.unquote(results['host']) + + # Get our entries; split_path() looks after unquoting content for us + # by default + entries = NotifyBoxcar.split_path(results['fullpath']) + + # Now fetch the remaining tokens + results['secret'] = entries.pop(0) if entries else None + + # Our recipients make up the remaining entries of our array + results['targets'] = entries + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyBoxcar.parse_list(results['qsd'].get('to')) + + # Access + if 'access' in results['qsd'] and results['qsd']['access']: + results['access'] = NotifyBoxcar.unquote( + results['qsd']['access'].strip()) + + # Secret + if 'secret' in results['qsd'] and results['qsd']['secret']: + results['secret'] = NotifyBoxcar.unquote( + results['qsd']['secret'].strip()) + + # Include images with our message + results['include_image'] = \ + parse_bool(results['qsd'].get('image', True)) + + return results diff --git a/lib/apprise/plugins/NotifyBulkSMS.py b/lib/apprise/plugins/NotifyBulkSMS.py new file mode 100644 index 0000000..cf82a87 --- /dev/null +++ b/lib/apprise/plugins/NotifyBulkSMS.py @@ -0,0 +1,480 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# To use this service you will need a BulkSMS account +# You will need credits (new accounts start with a few) +# https://www.bulksms.com/account/ +# +# API is documented here: +# - https://www.bulksms.com/developer/json/v1/#tag/Message +import re +import requests +import json +from itertools import chain +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..utils import is_phone_no +from ..utils import parse_phone_no +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + + +IS_GROUP_RE = re.compile( + r'^(@?(?P[A-Z0-9_-]+))$', + re.IGNORECASE, +) + + +class BulkSMSRoutingGroup(object): + """ + The different categories of routing + """ + ECONOMY = "ECONOMY" + STANDARD = "STANDARD" + PREMIUM = "PREMIUM" + + +# Used for verification purposes +BULKSMS_ROUTING_GROUPS = ( + BulkSMSRoutingGroup.ECONOMY, + BulkSMSRoutingGroup.STANDARD, + BulkSMSRoutingGroup.PREMIUM, +) + + +class BulkSMSEncoding(object): + """ + The different categories of routing + """ + TEXT = "TEXT" + UNICODE = "UNICODE" + BINARY = "BINARY" + + +class NotifyBulkSMS(NotifyBase): + """ + A wrapper for BulkSMS Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'BulkSMS' + + # The services URL + service_url = 'https://bulksms.com/' + + # All notification requests are secure + secure_protocol = 'bulksms' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_bulksms' + + # BulkSMS uses the http protocol with JSON requests + notify_url = 'https://api.bulksms.com/v1/messages' + + # The maximum length of the body + body_maxlen = 160 + + # The maximum amount of texts that can go out in one batch + default_batch_size = 4000 + + # A title can not be used for SMS Messages. Setting this to zero will + # cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + + # Define object templates + templates = ( + '{schema}://{user}:{password}@{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'user': { + 'name': _('User Name'), + 'type': 'string', + 'required': True, + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'target_phone': { + 'name': _('Target Phone No'), + 'type': 'string', + 'prefix': '+', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + 'map_to': 'targets', + }, + 'target_group': { + 'name': _('Target Group'), + 'type': 'string', + 'prefix': '+', + 'regex': (r'^[A-Z0-9 _-]+$', 'i'), + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'from': { + 'name': _('From Phone No'), + 'type': 'string', + 'regex': (r'^\+?[0-9\s)(+-]+$', 'i'), + 'map_to': 'source', + }, + 'route': { + 'name': _('Route Group'), + 'type': 'choice:string', + 'values': BULKSMS_ROUTING_GROUPS, + 'default': BulkSMSRoutingGroup.STANDARD, + }, + 'unicode': { + # Unicode characters + 'name': _('Unicode Characters'), + 'type': 'bool', + 'default': True, + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + }) + + def __init__(self, source=None, targets=None, unicode=None, batch=None, + route=None, **kwargs): + """ + Initialize BulkSMS Object + """ + super(NotifyBulkSMS, self).__init__(**kwargs) + + self.source = None + if source: + result = is_phone_no(source) + if not result: + msg = 'The Account (From) Phone # specified ' \ + '({}) is invalid.'.format(source) + self.logger.warning(msg) + raise TypeError(msg) + + # Tidy source + self.source = '+{}'.format(result['full']) + + # Setup our route + self.route = self.template_args['route']['default'] \ + if not isinstance(route, str) else route.upper() + if self.route not in BULKSMS_ROUTING_GROUPS: + msg = 'The route specified ({}) is invalid.'.format(route) + self.logger.warning(msg) + raise TypeError(msg) + + # Define whether or not we should set the unicode flag + self.unicode = self.template_args['unicode']['default'] \ + if unicode is None else bool(unicode) + + # Define whether or not we should operate in a batch mode + self.batch = self.template_args['batch']['default'] \ + if batch is None else bool(batch) + + # Parse our targets + self.targets = list() + self.groups = list() + + for target in parse_phone_no(targets): + # Parse each phone number we found + result = is_phone_no(target) + if result: + self.targets.append('+{}'.format(result['full'])) + continue + + group_re = IS_GROUP_RE.match(target) + if group_re and not target.isdigit(): + # If the target specified is all digits, it MUST have a @ + # in front of it to eliminate any ambiguity + self.groups.append(group_re.group('group')) + continue + + self.logger.warning( + 'Dropped invalid phone # and/or Group ' + '({}) specified.'.format(target), + ) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform BulkSMS Notification + """ + + if not (self.password and self.user): + self.logger.warning( + 'There were no valid login credentials provided') + return False + + if not (self.targets or self.groups): + # We have nothing to notify + self.logger.warning('There are no Twist targets to notify') + return False + + # Send in batches if identified to do so + batch_size = 1 if not self.batch else self.default_batch_size + + # error tracking (used for function return) + has_error = False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + } + + # Prepare our payload + payload = { + # The To gets populated in the loop below + 'to': None, + 'body': body, + 'routingGroup': self.route, + 'encoding': BulkSMSEncoding.UNICODE \ + if self.unicode else BulkSMSEncoding.TEXT, + # Options are NONE, ALL and ERRORS + 'deliveryReports': "ERRORS" + } + + if self.source: + payload.update({ + 'from': self.source, + }) + + # Authentication + auth = (self.user, self.password) + + # Prepare our targets + targets = list(self.targets) if batch_size == 1 else \ + [self.targets[index:index + batch_size] + for index in range(0, len(self.targets), batch_size)] + targets += [{"type": "GROUP", "name": g} for g in self.groups] + + while len(targets): + # Get our target to notify + target = targets.pop(0) + + # Prepare our user + payload['to'] = target + + # Printable reference + if isinstance(target, dict): + p_target = target['name'] + + elif isinstance(target, list): + p_target = '{} targets'.format(len(target)) + + else: + p_target = target + + # Some Debug Logging + self.logger.debug('BulkSMS POST URL: {} (cert_verify={})'.format( + self.notify_url, self.verify_certificate)) + self.logger.debug('BulkSMS Payload: {}' .format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + self.notify_url, + data=json.dumps(payload), + headers=headers, + auth=auth, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + # The responsne might look like: + # [ + # { + # "id": "string", + # "type": "SENT", + # "from": "string", + # "to": "string", + # "body": null, + # "encoding": "TEXT", + # "protocolId": 0, + # "messageClass": 0, + # "numberOfParts": 0, + # "creditCost": 0, + # "submission": {...}, + # "status": {...}, + # "relatedSentMessageId": "string", + # "userSuppliedId": "string" + # } + # ] + + if r.status_code not in ( + requests.codes.created, requests.codes.ok): + # We had a problem + status_str = \ + NotifyBase.http_response_code_lookup(r.status_code) + + # set up our status code to use + status_code = r.status_code + + self.logger.warning( + 'Failed to send BulkSMS notification to {}: ' + '{}{}error={}.'.format( + p_target, + status_str, + ', ' if status_str else '', + status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent BulkSMS notification to {}.'.format(p_target)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending BulkSMS: to %s ', + p_target) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + 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 = { + 'unicode': 'yes' if self.unicode else 'no', + 'batch': 'yes' if self.batch else 'no', + 'route': self.route, + } + + if self.source: + params['from'] = self.source + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{user}:{password}@{targets}/?{params}'.format( + schema=self.secure_protocol, + user=self.pprint(self.user, privacy, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + targets='/'.join(chain( + [NotifyBulkSMS.quote('{}'.format(x), safe='+') + for x in self.targets], + [NotifyBulkSMS.quote('@{}'.format(x), safe='@') + for x in self.groups])), + params=NotifyBulkSMS.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + + # + # Factor batch into calculation + # + # Note: Groups always require a separate request (and can not be + # included in batch calculations) + batch_size = 1 if not self.batch else self.default_batch_size + targets = len(self.targets) + if batch_size > 1: + targets = int(targets / batch_size) + \ + (1 if targets % batch_size else 0) + + return targets + len(self.groups) + + @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 + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'] = [ + NotifyBulkSMS.unquote(results['host']), + *NotifyBulkSMS.split_path(results['fullpath'])] + + # Support the 'from' and 'source' variable so that we can support + # targets this way too. + # The 'from' makes it easier to use yaml configuration + if 'from' in results['qsd'] and len(results['qsd']['from']): + results['source'] = \ + NotifyBulkSMS.unquote(results['qsd']['from']) + + # Support the 'to' variable so that we can support targets this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyBulkSMS.parse_phone_no(results['qsd']['to']) + + # Unicode Characters + results['unicode'] = \ + parse_bool(results['qsd'].get( + 'unicode', NotifyBulkSMS.template_args['unicode']['default'])) + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get( + 'batch', NotifyBulkSMS.template_args['batch']['default'])) + + # Allow one to define a route group + if 'route' in results['qsd'] and len(results['qsd']['route']): + results['route'] = \ + NotifyBulkSMS.unquote(results['qsd']['route']) + + return results diff --git a/lib/apprise/plugins/NotifyBurstSMS.py b/lib/apprise/plugins/NotifyBurstSMS.py new file mode 100644 index 0000000..59219b3 --- /dev/null +++ b/lib/apprise/plugins/NotifyBurstSMS.py @@ -0,0 +1,460 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# Sign-up with https://burstsms.com/ +# +# Define your API Secret here and acquire your API Key +# - https://can.transmitsms.com/profile +# +import requests + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..utils import is_phone_no +from ..utils import parse_phone_no +from ..utils import parse_bool +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +class BurstSMSCountryCode: + # Australia + AU = 'au' + # New Zeland + NZ = 'nz' + # United Kingdom + UK = 'gb' + # United States + US = 'us' + + +BURST_SMS_COUNTRY_CODES = ( + BurstSMSCountryCode.AU, + BurstSMSCountryCode.NZ, + BurstSMSCountryCode.UK, + BurstSMSCountryCode.US, +) + + +class NotifyBurstSMS(NotifyBase): + """ + A wrapper for Burst SMS Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Burst SMS' + + # The services URL + service_url = 'https://burstsms.com/' + + # The default protocol + secure_protocol = 'burstsms' + + # The maximum amount of SMS Messages that can reside within a single + # batch transfer based on: + # https://developer.transmitsms.com/#74911cf8-dec6-4319-a499-7f535a7fd08c + default_batch_size = 500 + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_burst_sms' + + # Burst SMS uses the http protocol with JSON requests + notify_url = 'https://api.transmitsms.com/send-sms.json' + + # The maximum length of the body + body_maxlen = 160 + + # A title can not be used for SMS Messages. Setting this to zero will + # cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + + # Define object templates + templates = ( + '{schema}://{apikey}:{secret}@{sender_id}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'required': True, + 'regex': (r'^[a-z0-9]+$', 'i'), + 'private': True, + }, + 'secret': { + 'name': _('API Secret'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[a-z0-9]+$', 'i'), + }, + 'sender_id': { + 'name': _('Sender ID'), + 'type': 'string', + 'required': True, + 'map_to': 'source', + }, + 'target_phone': { + 'name': _('Target Phone No'), + 'type': 'string', + 'prefix': '+', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'from': { + 'alias_of': 'sender_id', + }, + 'key': { + 'alias_of': 'apikey', + }, + 'secret': { + 'alias_of': 'secret', + }, + 'country': { + 'name': _('Country'), + 'type': 'choice:string', + 'values': BURST_SMS_COUNTRY_CODES, + 'default': BurstSMSCountryCode.US, + }, + # Validity + # Expire a message send if it is undeliverable (defined in minutes) + # If set to Zero (0); this is the default and sets the max validity + # period + 'validity': { + 'name': _('validity'), + 'type': 'int', + 'default': 0 + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + }) + + def __init__(self, apikey, secret, source, targets=None, country=None, + validity=None, batch=None, **kwargs): + """ + Initialize Burst SMS Object + """ + super().__init__(**kwargs) + + # API Key (associated with project) + self.apikey = validate_regex( + apikey, *self.template_tokens['apikey']['regex']) + if not self.apikey: + msg = 'An invalid Burst SMS API Key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # API Secret (associated with project) + self.secret = validate_regex( + secret, *self.template_tokens['secret']['regex']) + if not self.secret: + msg = 'An invalid Burst SMS API Secret ' \ + '({}) was specified.'.format(secret) + self.logger.warning(msg) + raise TypeError(msg) + + if not country: + self.country = self.template_args['country']['default'] + + else: + self.country = country.lower().strip() + if country not in BURST_SMS_COUNTRY_CODES: + msg = 'An invalid Burst SMS country ' \ + '({}) was specified.'.format(country) + self.logger.warning(msg) + raise TypeError(msg) + + # Set our Validity + self.validity = self.template_args['validity']['default'] + if validity: + try: + self.validity = int(validity) + + except (ValueError, TypeError): + msg = 'The Burst SMS Validity specified ({}) is invalid.'\ + .format(validity) + self.logger.warning(msg) + raise TypeError(msg) + + # Prepare Batch Mode Flag + self.batch = self.template_args['batch']['default'] \ + if batch is None else batch + + # The Sender ID + self.source = validate_regex(source) + if not self.source: + msg = 'The Account Sender ID specified ' \ + '({}) is invalid.'.format(source) + self.logger.warning(msg) + raise TypeError(msg) + + # Parse our targets + self.targets = list() + + for target in parse_phone_no(targets): + # Validate targets and drop bad ones: + result = is_phone_no(target) + if not result: + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) + continue + + # store valid phone number + self.targets.append(result['full']) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Burst SMS Notification + """ + + if not self.targets: + self.logger.warning( + 'There are no valid Burst SMS targets to notify.') + return False + + # error tracking (used for function return) + has_error = False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Accept': 'application/json', + } + + # Prepare our authentication + auth = (self.apikey, self.secret) + + # Prepare our payload + payload = { + 'countrycode': self.country, + 'message': body, + + # Sender ID + 'from': self.source, + + # The to gets populated in the loop below + 'to': None, + } + + # Send in batches if identified to do so + batch_size = 1 if not self.batch else self.default_batch_size + + # Create a copy of the targets list + targets = list(self.targets) + + for index in range(0, len(targets), batch_size): + + # Prepare our user + payload['to'] = ','.join(self.targets[index:index + batch_size]) + + # Some Debug Logging + self.logger.debug('Burst SMS POST URL: {} (cert_verify={})'.format( + self.notify_url, self.verify_certificate)) + self.logger.debug('Burst SMS Payload: {}' .format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + self.notify_url, + data=payload, + headers=headers, + auth=auth, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyBurstSMS.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send Burst SMS notification to {} ' + 'target(s): {}{}error={}.'.format( + len(self.targets[index:index + batch_size]), + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent Burst SMS notification to %d target(s).' % + len(self.targets[index:index + batch_size])) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Burst SMS ' + 'notification to %d target(s).' % + len(self.targets[index:index + batch_size])) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + 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 = { + 'country': self.country, + 'batch': 'yes' if self.batch else 'no', + } + + if self.validity: + params['validity'] = str(self.validity) + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{key}:{secret}@{source}/{targets}/?{params}'.format( + schema=self.secure_protocol, + key=self.pprint(self.apikey, privacy, safe=''), + secret=self.pprint( + self.secret, privacy, mode=PrivacyMode.Secret, safe=''), + source=NotifyBurstSMS.quote(self.source, safe=''), + targets='/'.join( + [NotifyBurstSMS.quote(x, safe='') for x in self.targets]), + params=NotifyBurstSMS.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + batch_size = 1 if not self.batch else self.default_batch_size + targets = len(self.targets) + if batch_size > 1: + targets = int(targets / batch_size) + \ + (1 if targets % batch_size else 0) + + return targets if targets > 0 else 1 + + @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 + + # The hostname is our source (Sender ID) + results['source'] = NotifyBurstSMS.unquote(results['host']) + + # Get any remaining targets + results['targets'] = NotifyBurstSMS.split_path(results['fullpath']) + + # Get our account_side and auth_token from the user/pass config + results['apikey'] = NotifyBurstSMS.unquote(results['user']) + results['secret'] = NotifyBurstSMS.unquote(results['password']) + + # API Key + if 'key' in results['qsd'] and len(results['qsd']['key']): + # Extract the API Key from an argument + results['apikey'] = \ + NotifyBurstSMS.unquote(results['qsd']['key']) + + # API Secret + if 'secret' in results['qsd'] and len(results['qsd']['secret']): + # Extract the API Secret from an argument + results['secret'] = \ + NotifyBurstSMS.unquote(results['qsd']['secret']) + + # Support the 'from' and 'source' variable so that we can support + # targets this way too. + # The 'from' makes it easier to use yaml configuration + if 'from' in results['qsd'] and len(results['qsd']['from']): + results['source'] = \ + NotifyBurstSMS.unquote(results['qsd']['from']) + if 'source' in results['qsd'] and len(results['qsd']['source']): + results['source'] = \ + NotifyBurstSMS.unquote(results['qsd']['source']) + + # Support country + if 'country' in results['qsd'] and len(results['qsd']['country']): + results['country'] = \ + NotifyBurstSMS.unquote(results['qsd']['country']) + + # Support validity value + if 'validity' in results['qsd'] and len(results['qsd']['validity']): + results['validity'] = \ + NotifyBurstSMS.unquote(results['qsd']['validity']) + + # Get Batch Mode Flag + if 'batch' in results['qsd'] and len(results['qsd']['batch']): + results['batch'] = parse_bool(results['qsd']['batch']) + + # 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'] += \ + NotifyBurstSMS.parse_phone_no(results['qsd']['to']) + + return results diff --git a/lib/apprise/plugins/NotifyClickSend.py b/lib/apprise/plugins/NotifyClickSend.py new file mode 100644 index 0000000..670e74e --- /dev/null +++ b/lib/apprise/plugins/NotifyClickSend.py @@ -0,0 +1,331 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# To use this plugin, simply signup with clicksend: +# https://www.clicksend.com/ +# +# You're done at this point, you only need to know your user/pass that +# you signed up with. + +# The following URLs would be accepted by Apprise: +# - clicksend://{user}:{password}@{phoneno} +# - clicksend://{user}:{password}@{phoneno1}/{phoneno2} + +# The API reference used to build this plugin was documented here: +# https://developers.clicksend.com/docs/rest/v3/ +# +import requests +from json import dumps +from base64 import b64encode + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..utils import is_phone_no +from ..utils import parse_phone_no +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + +# Extend HTTP Error Messages +CLICKSEND_HTTP_ERROR_MAP = { + 401: 'Unauthorized - Invalid Token.', +} + + +class NotifyClickSend(NotifyBase): + """ + A wrapper for ClickSend Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'ClickSend' + + # The services URL + service_url = 'https://clicksend.com/' + + # The default secure protocol + secure_protocol = 'clicksend' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_clicksend' + + # ClickSend uses the http protocol with JSON requests + notify_url = 'https://rest.clicksend.com/v3/sms/send' + + # The maximum length of the body + body_maxlen = 160 + + # A title can not be used for SMS Messages. Setting this to zero will + # cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + + # The maximum SMS batch size accepted by the ClickSend API + default_batch_size = 1000 + + # Define object templates + templates = ( + '{schema}://{user}:{password}@{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'user': { + 'name': _('User Name'), + 'type': 'string', + 'required': True, + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'target_phone': { + 'name': _('Target Phone No'), + 'type': 'string', + 'prefix': '+', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + }) + + def __init__(self, targets=None, batch=False, **kwargs): + """ + Initialize ClickSend Object + """ + super().__init__(**kwargs) + + # Prepare Batch Mode Flag + self.batch = batch + + # Parse our targets + self.targets = list() + + if not (self.user and self.password): + msg = 'A ClickSend user/pass was not provided.' + self.logger.warning(msg) + raise TypeError(msg) + + for target in parse_phone_no(targets): + # Validate targets and drop bad ones: + result = is_phone_no(target) + if not result: + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) + continue + + # store valid phone number + self.targets.append(result['full']) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform ClickSend Notification + """ + + if len(self.targets) == 0: + # There were no services to notify + self.logger.warning('There were no ClickSend targets to notify.') + return False + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json; charset=utf-8', + 'Authorization': 'Basic {}'.format( + b64encode('{}:{}'.format( + self.user, self.password).encode('utf-8'))), + } + + # error tracking (used for function return) + has_error = False + + # prepare JSON Object + payload = { + 'messages': [] + } + + # Send in batches if identified to do so + default_batch_size = 1 if not self.batch else self.default_batch_size + + for index in range(0, len(self.targets), default_batch_size): + payload['messages'] = [{ + 'source': 'php', + 'body': body, + 'to': '+{}'.format(to), + } for to in self.targets[index:index + default_batch_size]] + + self.logger.debug('ClickSend POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('ClickSend Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + self.notify_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 = \ + NotifyClickSend.http_response_code_lookup( + r.status_code, CLICKSEND_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send {} ClickSend notification{}: ' + '{}{}error={}.'.format( + len(payload['messages']), + ' to {}'.format(self.targets[index]) + if default_batch_size == 1 else '(s)', + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent {} ClickSend notification{}.' + .format( + len(payload['messages']), + ' to {}'.format(self.targets[index]) + if default_batch_size == 1 else '(s)', + )) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending {} ClickSend ' + 'notification(s).'.format(len(payload['messages']))) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + 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 = { + 'batch': 'yes' if self.batch else 'no', + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Setup Authentication + auth = '{user}:{password}@'.format( + user=NotifyClickSend.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + + return '{schema}://{auth}{targets}?{params}'.format( + schema=self.secure_protocol, + auth=auth, + targets='/'.join( + [NotifyClickSend.quote(x, safe='') for x in self.targets]), + params=NotifyClickSend.urlencode(params), + ) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + batch_size = 1 if not self.batch else self.default_batch_size + targets = len(self.targets) + if batch_size > 1: + targets = int(targets / batch_size) + \ + (1 if targets % batch_size else 0) + + return 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 + + # All elements are targets + results['targets'] = [NotifyClickSend.unquote(results['host'])] + + # All entries after the hostname are additional targets + results['targets'].extend( + NotifyClickSend.split_path(results['fullpath'])) + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get('batch', False)) + + # 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'] += \ + NotifyClickSend.parse_phone_no(results['qsd']['to']) + + return results diff --git a/lib/apprise/plugins/NotifyD7Networks.py b/lib/apprise/plugins/NotifyD7Networks.py new file mode 100644 index 0000000..3e7787d --- /dev/null +++ b/lib/apprise/plugins/NotifyD7Networks.py @@ -0,0 +1,429 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# To use this service you will need a D7 Networks account from their website +# at https://d7networks.com/ +# +# After you've established your account you can get your api login credentials +# (both user and password) from the API Details section from within your +# account profile area: https://d7networks.com/accounts/profile/ +# +# API Reference: https://d7networks.com/docs/Messages/Send_Message/ + +import requests +from json import dumps +from json import loads + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import is_phone_no +from ..utils import parse_phone_no +from ..utils import validate_regex +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + +# Extend HTTP Error Messages +D7NETWORKS_HTTP_ERROR_MAP = { + 401: 'Invalid Argument(s) Specified.', + 403: 'Unauthorized - Authentication Failure.', + 412: 'A Routing Error Occured', + 500: 'A Serverside Error Occured Handling the Request.', +} + + +class NotifyD7Networks(NotifyBase): + """ + A wrapper for D7 Networks Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'D7 Networks' + + # The services URL + service_url = 'https://d7networks.com/' + + # All notification requests are secure + secure_protocol = 'd7sms' + + # Allow 300 requests per minute. + # 60/300 = 0.2 + request_rate_per_sec = 0.20 + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_d7networks' + + # D7 Networks single notification URL + notify_url = 'https://api.d7networks.com/messages/v1/send' + + # The maximum length of the body + body_maxlen = 160 + + # A title can not be used for SMS Messages. Setting this to zero will + # cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + + # Define object templates + templates = ( + '{schema}://{token}@{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'token': { + 'name': _('API Access Token'), + 'type': 'string', + 'required': True, + 'private': True, + }, + 'target_phone': { + 'name': _('Target Phone No'), + 'type': 'string', + 'prefix': '+', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'unicode': { + # Unicode characters (default is 'auto') + 'name': _('Unicode Characters'), + 'type': 'bool', + 'default': False, + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + 'to': { + 'alias_of': 'targets', + }, + 'source': { + # Originating address,In cases where the rewriting of the sender's + # address is supported or permitted by the SMS-C. This is used to + # transmit the message, this number is transmitted as the + # originating address and is completely optional. + 'name': _('Originating Address'), + 'type': 'string', + 'map_to': 'source', + + }, + 'from': { + 'alias_of': 'source', + }, + }) + + def __init__(self, token=None, targets=None, source=None, + batch=False, unicode=None, **kwargs): + """ + Initialize D7 Networks Object + """ + super().__init__(**kwargs) + + # Prepare Batch Mode Flag + self.batch = batch + + # Setup our source address (if defined) + self.source = None \ + if not isinstance(source, str) else source.strip() + + # Define whether or not we should set the unicode flag + self.unicode = self.template_args['unicode']['default'] \ + if unicode is None else bool(unicode) + + # The token associated with the account + self.token = validate_regex(token) + if not self.token: + msg = 'The D7 Networks token specified ({}) is invalid.'\ + .format(token) + self.logger.warning(msg) + raise TypeError(msg) + + # Parse our targets + self.targets = list() + for target in parse_phone_no(targets): + # Validate targets and drop bad ones: + result = result = is_phone_no(target) + if not result: + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) + continue + + # store valid phone number + self.targets.append(result['full']) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Depending on whether we are set to batch mode or single mode this + redirects to the appropriate handling + """ + + if len(self.targets) == 0: + # There were no services to notify + self.logger.warning('There were no D7 Networks targets to notify.') + return False + + # error tracking (used for function return) + has_error = False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': f'Bearer {self.token}', + } + + payload = { + 'message_globals': { + 'channel': 'sms', + }, + 'messages': [{ + # Populated later on + 'recipients': None, + 'content': body, + 'data_coding': + # auto is a better substitute over 'text' as text is easier to + # detect from a post than `unicode` is. + 'auto' if not self.unicode else 'unicode', + }], + } + + # use the list directly + targets = list(self.targets) + + if self.source: + payload['message_globals']['originator'] = self.source + + target = None + while len(targets): + + if self.batch: + # Prepare our payload + payload['messages'][0]['recipients'] = self.targets + + # Reset our targets so we don't keep going. This is required + # because we're in batch mode; we only need to loop once. + targets = [] + + else: + # We're not in a batch mode; so get our next target + # Get our target(s) to notify + target = targets.pop(0) + + # Prepare our payload + payload['messages'][0]['recipients'] = [target] + + # Some Debug Logging + self.logger.debug( + 'D7 Networks POST URL: {} (cert_verify={})'.format( + self.notify_url, self.verify_certificate)) + self.logger.debug('D7 Networks Payload: {}' .format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + self.notify_url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + if r.status_code not in ( + requests.codes.created, requests.codes.ok): + # We had a problem + status_str = \ + NotifyBase.http_response_code_lookup( + r.status_code, D7NETWORKS_HTTP_ERROR_MAP) + + try: + # Update our status response if we can + json_response = loads(r.content) + status_str = json_response.get('message', status_str) + + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + + # We could not parse JSON response. + # We will just use the status we already have. + pass + + self.logger.warning( + 'Failed to send D7 Networks SMS notification to {}: ' + '{}{}error={}.'.format( + ', '.join(target) if self.batch else target, + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + else: + + if self.batch: + self.logger.info( + 'Sent D7 Networks batch SMS notification to ' + '{} target(s).'.format(len(self.targets))) + + else: + self.logger.info( + 'Sent D7 Networks SMS notification to {}.'.format( + target)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending D7 Networks:%s ' % ( + ', '.join(self.targets)) + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + # Mark our failure + has_error = True + continue + + 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 = { + 'batch': 'yes' if self.batch else 'no', + 'unicode': 'yes' if self.unicode else 'no', + } + + if self.source: + params['from'] = self.source + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{token}@{targets}/?{params}'.format( + schema=self.secure_protocol, + token=self.pprint(self.token, privacy, safe=''), + targets='/'.join( + [NotifyD7Networks.quote(x, safe='') for x in self.targets]), + params=NotifyD7Networks.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + return len(self.targets) if not self.batch else 1 + + @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 + + if 'token' in results['qsd'] and len(results['qsd']['token']): + results['token'] = \ + NotifyD7Networks.unquote(results['qsd']['token']) + + elif results['user']: + results['token'] = NotifyD7Networks.unquote(results['user']) + + if results['password']: + # Support token containing a colon (:) + results['token'] += \ + ':' + NotifyD7Networks.unquote(results['password']) + + elif results['password']: + # Support token starting with a colon (:) + results['token'] = \ + ':' + NotifyD7Networks.unquote(results['password']) + + # Initialize our targets + results['targets'] = list() + + # The store our first target stored in the hostname + results['targets'].append(NotifyD7Networks.unquote(results['host'])) + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'].extend( + NotifyD7Networks.split_path(results['fullpath'])) + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get('batch', False)) + + # Get Unicode Flag + results['unicode'] = \ + parse_bool(results['qsd'].get('unicode', False)) + + # Support the 'to' variable so that we can support targets this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyD7Networks.parse_phone_no(results['qsd']['to']) + + # Support the 'from' and source variable + if 'from' in results['qsd'] and len(results['qsd']['from']): + results['source'] = \ + NotifyD7Networks.unquote(results['qsd']['from']) + + elif 'source' in results['qsd'] and len(results['qsd']['source']): + results['source'] = \ + NotifyD7Networks.unquote(results['qsd']['source']) + + return results diff --git a/lib/apprise/plugins/NotifyDBus.py b/lib/apprise/plugins/NotifyDBus.py new file mode 100644 index 0000000..7d357aa --- /dev/null +++ b/lib/apprise/plugins/NotifyDBus.py @@ -0,0 +1,451 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +from __future__ import absolute_import +from __future__ import print_function + +import sys +from .NotifyBase import NotifyBase +from ..common import NotifyImageSize +from ..common import NotifyType +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + +# Default our global support flag +NOTIFY_DBUS_SUPPORT_ENABLED = False + +# Image support is dependant on the GdkPixbuf library being available +NOTIFY_DBUS_IMAGE_SUPPORT = False + +# Initialize our mainloops +LOOP_GLIB = None +LOOP_QT = None + + +try: + # dbus essentials + from dbus import SessionBus + from dbus import Interface + from dbus import Byte + from dbus import ByteArray + from dbus import DBusException + + # + # now we try to determine which mainloop(s) we can access + # + + # glib + try: + from dbus.mainloop.glib import DBusGMainLoop + LOOP_GLIB = DBusGMainLoop() + + except ImportError: # pragma: no cover + # No problem + pass + + # qt + try: + from dbus.mainloop.qt import DBusQtMainLoop + LOOP_QT = DBusQtMainLoop(set_as_default=True) + + except ImportError: + # No problem + pass + + # We're good as long as at least one + NOTIFY_DBUS_SUPPORT_ENABLED = ( + LOOP_GLIB is not None or LOOP_QT is not None) + + # ImportError: When using gi.repository you must not import static modules + # like "gobject". Please change all occurrences of "import gobject" to + # "from gi.repository import GObject". + # See: https://bugzilla.gnome.org/show_bug.cgi?id=709183 + if "gobject" in sys.modules: # pragma: no cover + del sys.modules["gobject"] + + try: + # The following is required for Image/Icon loading only + import gi + gi.require_version('GdkPixbuf', '2.0') + from gi.repository import GdkPixbuf + NOTIFY_DBUS_IMAGE_SUPPORT = True + + except (ImportError, ValueError, AttributeError): + # No problem; this will get caught in outer try/catch + + # A ValueError will get thrown upon calling gi.require_version() if + # GDK/GTK isn't installed on the system but gi is. + pass + +except ImportError: + # No problem; we just simply can't support this plugin; we could + # be in microsoft windows, or we just don't have the python-gobject + # library available to us (or maybe one we don't support)? + pass + +# Define our supported protocols and the loop to assign them. +# The key to value pairs are the actual supported schema's matched +# up with the Main Loop they should reference when accessed. +MAINLOOP_MAP = { + 'qt': LOOP_QT, + 'kde': LOOP_QT, + 'glib': LOOP_GLIB, + 'dbus': LOOP_QT if LOOP_QT else LOOP_GLIB, +} + + +# Urgencies +class DBusUrgency: + LOW = 0 + NORMAL = 1 + HIGH = 2 + + +DBUS_URGENCIES = { + # Note: This also acts as a reverse lookup mapping + DBusUrgency.LOW: 'low', + DBusUrgency.NORMAL: 'normal', + DBusUrgency.HIGH: 'high', +} + +DBUS_URGENCY_MAP = { + # Maps against string 'low' + 'l': DBusUrgency.LOW, + # Maps against string 'moderate' + 'm': DBusUrgency.LOW, + # Maps against string 'normal' + 'n': DBusUrgency.NORMAL, + # Maps against string 'high' + 'h': DBusUrgency.HIGH, + # Maps against string 'emergency' + 'e': DBusUrgency.HIGH, + + # Entries to additionally support (so more like DBus's API) + '0': DBusUrgency.LOW, + '1': DBusUrgency.NORMAL, + '2': DBusUrgency.HIGH, +} + + +class NotifyDBus(NotifyBase): + """ + A wrapper for local DBus/Qt Notifications + """ + + # Set our global enabled flag + enabled = NOTIFY_DBUS_SUPPORT_ENABLED + + requirements = { + # Define our required packaging in order to work + 'details': _('libdbus-1.so.x must be installed.') + } + + # The default descriptive name associated with the Notification + service_name = _('DBus Notification') + + # The services URL + service_url = 'http://www.freedesktop.org/Software/dbus/' + + # The default protocols + # Python 3 keys() does not return a list object, it is its own dict_keys() + # object if we were to reference, we wouldn't be backwards compatible with + # Python v2. So converting the result set back into a list makes us + # compatible + # TODO: Review after dropping support for Python 2. + protocol = list(MAINLOOP_MAP.keys()) + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_dbus' + + # No throttling required for DBus queries + request_rate_per_sec = 0 + + # Allows the user to specify the NotifyImageSize object + image_size = NotifyImageSize.XY_128 + + # The number of milliseconds to keep the message present for + message_timeout_ms = 13000 + + # Limit results to just the first 10 line otherwise there is just to much + # content to display + body_max_line_count = 10 + + # The following are required to hook into the notifications: + dbus_interface = 'org.freedesktop.Notifications' + dbus_setting_location = '/org/freedesktop/Notifications' + + # Define object templates + templates = ( + '{schema}://', + ) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'urgency': { + 'name': _('Urgency'), + 'type': 'choice:int', + 'values': DBUS_URGENCIES, + 'default': DBusUrgency.NORMAL, + }, + 'priority': { + # Apprise uses 'priority' everywhere; it's just a nice consistent + # feel to be able to use it here as well. Just map the + # value back to 'priority' + 'alias_of': 'urgency', + }, + 'x': { + 'name': _('X-Axis'), + 'type': 'int', + 'min': 0, + 'map_to': 'x_axis', + }, + 'y': { + 'name': _('Y-Axis'), + 'type': 'int', + 'min': 0, + 'map_to': 'y_axis', + }, + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': True, + 'map_to': 'include_image', + }, + }) + + def __init__(self, urgency=None, x_axis=None, y_axis=None, + include_image=True, **kwargs): + """ + Initialize DBus Object + """ + + super().__init__(**kwargs) + + # Track our notifications + self.registry = {} + + # Store our schema; default to dbus + self.schema = kwargs.get('schema', 'dbus') + + if self.schema not in MAINLOOP_MAP: + msg = 'The schema specified ({}) is not supported.' \ + .format(self.schema) + self.logger.warning(msg) + raise TypeError(msg) + + # The urgency of the message + self.urgency = int( + NotifyDBus.template_args['urgency']['default'] + if urgency is None else + next(( + v for k, v in DBUS_URGENCY_MAP.items() + if str(urgency).lower().startswith(k)), + NotifyDBus.template_args['urgency']['default'])) + + # Our x/y axis settings + if x_axis or y_axis: + try: + self.x_axis = int(x_axis) + self.y_axis = int(y_axis) + + except (TypeError, ValueError): + # Invalid x/y values specified + msg = 'The x,y coordinates specified ({},{}) are invalid.'\ + .format(x_axis, y_axis) + self.logger.warning(msg) + raise TypeError(msg) + else: + self.x_axis = None + self.y_axis = None + + # Track whether we want to add an image to the notification. + self.include_image = include_image + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform DBus Notification + """ + # Acquire our session + try: + session = SessionBus(mainloop=MAINLOOP_MAP[self.schema]) + + except DBusException as e: + # Handle exception + self.logger.warning('Failed to send DBus notification.') + self.logger.debug(f'DBus Exception: {e}') + return False + + # If there is no title, but there is a body, swap the two to get rid + # of the weird whitespace + if not title: + title = body + body = '' + + # acquire our dbus object + dbus_obj = session.get_object( + self.dbus_interface, + self.dbus_setting_location, + ) + + # Acquire our dbus interface + dbus_iface = Interface( + dbus_obj, + dbus_interface=self.dbus_interface, + ) + + # image path + icon_path = None if not self.include_image \ + else self.image_path(notify_type, extension='.ico') + + # Our meta payload + meta_payload = { + "urgency": Byte(self.urgency) + } + + if not (self.x_axis is None and self.y_axis is None): + # Set x/y access if these were set + meta_payload['x'] = self.x_axis + meta_payload['y'] = self.y_axis + + if NOTIFY_DBUS_IMAGE_SUPPORT and icon_path: + try: + # Use Pixbuf to create the proper image type + image = GdkPixbuf.Pixbuf.new_from_file(icon_path) + + # Associate our image to our notification + meta_payload['icon_data'] = ( + image.get_width(), + image.get_height(), + image.get_rowstride(), + image.get_has_alpha(), + image.get_bits_per_sample(), + image.get_n_channels(), + ByteArray(image.get_pixels()) + ) + + except Exception as e: + self.logger.warning( + "Could not load notification icon (%s).", icon_path) + self.logger.debug(f'DBus Exception: {e}') + + try: + # Always call throttle() before any remote execution is made + self.throttle() + + dbus_iface.Notify( + # Application Identifier + self.app_id, + # Message ID (0 = New Message) + 0, + # Icon (str) - not used + '', + # Title + str(title), + # Body + str(body), + # Actions + list(), + # Meta + meta_payload, + # Message Timeout + self.message_timeout_ms, + ) + + self.logger.info('Sent DBus notification.') + + except Exception as e: + self.logger.warning('Failed to send DBus notification.') + self.logger.debug(f'DBus Exception: {e}') + return False + + return True + + 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', + 'urgency': + DBUS_URGENCIES[self.template_args['urgency']['default']] + if self.urgency not in DBUS_URGENCIES + else DBUS_URGENCIES[self.urgency], + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # x in (x,y) screen coordinates + if self.x_axis: + params['x'] = str(self.x_axis) + + # y in (x,y) screen coordinates + if self.y_axis: + params['y'] = str(self.y_axis) + + return '{schema}://_/?{params}'.format( + schema=self.schema, + params=NotifyDBus.urlencode(params), + ) + + @staticmethod + def parse_url(url): + """ + There are no parameters nessisary for this protocol; simply having + gnome:// is all you need. This function just makes sure that + is in place. + + """ + + results = NotifyBase.parse_url(url, verify_host=False) + + # Include images with our message + results['include_image'] = \ + parse_bool(results['qsd'].get('image', True)) + + # DBus supports urgency, but we we also support the keyword priority + # so that it is consistent with some of the other plugins + if 'priority' in results['qsd'] and len(results['qsd']['priority']): + # We intentionally store the priority in the urgency section + results['urgency'] = \ + NotifyDBus.unquote(results['qsd']['priority']) + + if 'urgency' in results['qsd'] and len(results['qsd']['urgency']): + results['urgency'] = \ + NotifyDBus.unquote(results['qsd']['urgency']) + + # handle x,y coordinates + if 'x' in results['qsd'] and len(results['qsd']['x']): + results['x_axis'] = NotifyDBus.unquote(results['qsd'].get('x')) + + if 'y' in results['qsd'] and len(results['qsd']['y']): + results['y_axis'] = NotifyDBus.unquote(results['qsd'].get('y')) + + return results diff --git a/lib/apprise/plugins/NotifyDapnet.py b/lib/apprise/plugins/NotifyDapnet.py new file mode 100644 index 0000000..5848b68 --- /dev/null +++ b/lib/apprise/plugins/NotifyDapnet.py @@ -0,0 +1,405 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# To use this plugin, sign up with Hampager (you need to be a licensed +# ham radio operator +# http://www.hampager.de/ +# +# You're done at this point, you only need to know your user/pass that +# you signed up with. + +# The following URLs would be accepted by Apprise: +# - dapnet://{user}:{password}@{callsign} +# - dapnet://{user}:{password}@{callsign1}/{callsign2} + +# Optional parameters: +# - priority (NORMAL or EMERGENCY). Default: NORMAL +# - txgroups --> comma-separated list of DAPNET transmitter +# groups. Default: 'dl-all' +# https://hampager.de/#/transmitters/groups + +from json import dumps + +# The API reference used to build this plugin was documented here: +# https://hampager.de/dokuwiki/doku.php#dapnet_api +# +import requests +from requests.auth import HTTPBasicAuth + +from .NotifyBase import NotifyBase +from ..AppriseLocale import gettext_lazy as _ +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..utils import is_call_sign +from ..utils import parse_call_sign +from ..utils import parse_list +from ..utils import parse_bool + + +class DapnetPriority: + NORMAL = 0 + EMERGENCY = 1 + + +DAPNET_PRIORITIES = { + DapnetPriority.NORMAL: 'normal', + DapnetPriority.EMERGENCY: 'emergency', +} + + +DAPNET_PRIORITY_MAP = { + # Maps against string 'normal' + 'n': DapnetPriority.NORMAL, + # Maps against string 'emergency' + 'e': DapnetPriority.EMERGENCY, + + # Entries to additionally support (so more like Dapnet's API) + '0': DapnetPriority.NORMAL, + '1': DapnetPriority.EMERGENCY, +} + + +class NotifyDapnet(NotifyBase): + """ + A wrapper for DAPNET / Hampager Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Dapnet' + + # The services URL + service_url = 'https://hampager.de/' + + # The default secure protocol + secure_protocol = 'dapnet' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_dapnet' + + # Dapnet uses the http protocol with JSON requests + notify_url = 'http://www.hampager.de:8080/calls' + + # The maximum length of the body + body_maxlen = 80 + + # A title can not be used for Dapnet Messages. Setting this to zero will + # cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + + # The maximum amount of emails that can reside within a single transmission + default_batch_size = 50 + + # Define object templates + templates = ('{schema}://{user}:{password}@{targets}',) + + # Define our template tokens + template_tokens = dict( + NotifyBase.template_tokens, + **{ + 'user': { + 'name': _('User Name'), + 'type': 'string', + 'required': True, + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'target_callsign': { + 'name': _('Target Callsign'), + 'type': 'string', + 'regex': ( + r'^[a-z0-9]{2,5}(-[a-z0-9]{1,2})?$', 'i', + ), + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + 'required': True, + }, + } + ) + + # Define our template arguments + template_args = dict( + NotifyBase.template_args, + **{ + 'to': { + 'name': _('Target Callsign'), + 'type': 'string', + 'map_to': 'targets', + }, + 'priority': { + 'name': _('Priority'), + 'type': 'choice:int', + 'values': DAPNET_PRIORITIES, + 'default': DapnetPriority.NORMAL, + }, + 'txgroups': { + 'name': _('Transmitter Groups'), + 'type': 'string', + 'default': 'dl-all', + 'private': True, + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + } + ) + + def __init__(self, targets=None, priority=None, txgroups=None, + batch=False, **kwargs): + """ + Initialize Dapnet Object + """ + super().__init__(**kwargs) + + # Parse our targets + self.targets = list() + + # The Priority of the message + self.priority = int( + NotifyDapnet.template_args['priority']['default'] + if priority is None else + next(( + v for k, v in DAPNET_PRIORITY_MAP.items() + if str(priority).lower().startswith(k)), + NotifyDapnet.template_args['priority']['default'])) + + if not (self.user and self.password): + msg = 'A Dapnet user/pass was not provided.' + self.logger.warning(msg) + raise TypeError(msg) + + # Get the transmitter group + self.txgroups = parse_list( + NotifyDapnet.template_args['txgroups']['default'] + if not txgroups else txgroups) + + # Prepare Batch Mode Flag + self.batch = batch + + for target in parse_call_sign(targets): + # Validate targets and drop bad ones: + result = is_call_sign(target) + if not result: + self.logger.warning( + 'Dropping invalid Amateur radio call sign ({}).'.format( + target), + ) + continue + + # Store callsign without SSID and ignore duplicates + if result['callsign'] not in self.targets: + self.targets.append(result['callsign']) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Dapnet Notification + """ + + if not self.targets: + # There is no one to email; we're done + self.logger.warning( + 'There are no Amateur radio callsigns to notify') + return False + + # Send in batches if identified to do so + batch_size = 1 if not self.batch else self.default_batch_size + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json; charset=utf-8', + } + + # error tracking (used for function return) + has_error = False + + # Create a copy of the targets list + targets = list(self.targets) + + for index in range(0, len(targets), batch_size): + + # prepare JSON payload + payload = { + 'text': body, + 'callSignNames': targets[index:index + batch_size], + 'transmitterGroupNames': self.txgroups, + 'emergency': (self.priority == DapnetPriority.EMERGENCY), + } + + self.logger.debug('DAPNET POST URL: %s' % self.notify_url) + self.logger.debug('DAPNET Payload: %s' % dumps(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + self.notify_url, + data=dumps(payload), + headers=headers, + auth=HTTPBasicAuth( + username=self.user, password=self.password), + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.created: + # We had a problem + + self.logger.warning( + 'Failed to send DAPNET notification {} to {}: ' + 'error={}.'.format( + payload['text'], + ' to {}'.format(self.targets), + r.status_code + ) + ) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + + else: + self.logger.info( + 'Sent \'{}\' DAPNET notification {}'.format( + payload['text'], 'to {}'.format(self.targets) + ) + ) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending DAPNET ' + 'notification to {}'.format(self.targets) + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + + 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 = { + 'priority': + DAPNET_PRIORITIES[self.template_args['priority']['default']] + if self.priority not in DAPNET_PRIORITIES + else DAPNET_PRIORITIES[self.priority], + 'batch': 'yes' if self.batch else 'no', + 'txgroups': ','.join(self.txgroups), + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Setup Authentication + auth = '{user}:{password}@'.format( + user=NotifyDapnet.quote(self.user, safe=""), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe='' + ), + ) + + return '{schema}://{auth}{targets}?{params}'.format( + schema=self.secure_protocol, + auth=auth, + targets='/'.join([self.pprint(x, privacy, safe='') + for x in self.targets]), + params=NotifyDapnet.urlencode(params), + ) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + batch_size = 1 if not self.batch else self.default_batch_size + targets = len(self.targets) + if batch_size > 1: + targets = int(targets / batch_size) + \ + (1 if targets % batch_size else 0) + + return 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 + + # All elements are targets + results['targets'] = [NotifyDapnet.unquote(results['host'])] + + # All entries after the hostname are additional targets + results['targets'].extend(NotifyDapnet.split_path(results['fullpath'])) + + # 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'] += \ + NotifyDapnet.parse_list(results['qsd']['to']) + + # Set our priority + if 'priority' in results['qsd'] and len(results['qsd']['priority']): + results['priority'] = \ + NotifyDapnet.unquote(results['qsd']['priority']) + + # Check for one or multiple transmitter groups (comma separated) + # and split them up, when necessary + if 'txgroups' in results['qsd']: + results['txgroups'] = \ + [x.lower() for x in + NotifyDapnet.parse_list(results['qsd']['txgroups'])] + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get( + 'batch', NotifyDapnet.template_args['batch']['default'])) + + return results diff --git a/lib/apprise/plugins/NotifyDingTalk.py b/lib/apprise/plugins/NotifyDingTalk.py new file mode 100644 index 0000000..91bfcd6 --- /dev/null +++ b/lib/apprise/plugins/NotifyDingTalk.py @@ -0,0 +1,358 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 re +import time +import hmac +import hashlib +import base64 +import requests +from json import dumps + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyFormat +from ..common import NotifyType +from ..utils import parse_list +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + +# Register at https://dingtalk.com +# - Download their PC based software as it is the only way you can create +# a custom robot. You can create a custom robot per group. You will +# be provided an access_token that Apprise will need. + +# Syntax: +# dingtalk://{access_token}/ +# dingtalk://{access_token}/{optional_phone_no} +# dingtalk://{access_token}/{phone_no_1}/{phone_no_2}/{phone_no_N/ + +# Some Phone Number Detection +IS_PHONE_NO = re.compile(r'^\+?(?P[0-9\s)(+-]+)\s*$') + + +class NotifyDingTalk(NotifyBase): + """ + A wrapper for DingTalk Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'DingTalk' + + # The services URL + service_url = 'https://www.dingtalk.com/' + + # All notification requests are secure + secure_protocol = 'dingtalk' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_dingtalk' + + # DingTalk API + notify_url = 'https://oapi.dingtalk.com/robot/send?access_token={token}' + + # Do not set title_maxlen as it is set in a property value below + # since the length varies depending if we are doing a markdown + # based message or a text based one. + # title_maxlen = see below @propery defined + + # Define object templates + templates = ( + '{schema}://{token}/', + '{schema}://{token}/{targets}/', + '{schema}://{secret}@{token}/', + '{schema}://{secret}@{token}/{targets}/', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'token': { + 'name': _('Token'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[a-z0-9]+$', 'i'), + }, + 'secret': { + 'name': _('Secret'), + 'type': 'string', + 'private': True, + 'regex': (r'^[a-z0-9]+$', 'i'), + }, + 'target_phone_no': { + 'name': _('Target Phone No'), + 'type': 'string', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'token': { + 'alias_of': 'token', + }, + 'secret': { + 'alias_of': 'secret', + }, + }) + + def __init__(self, token, targets=None, secret=None, **kwargs): + """ + Initialize DingTalk Object + """ + super().__init__(**kwargs) + + # Secret Key (associated with project) + self.token = validate_regex( + token, *self.template_tokens['token']['regex']) + if not self.token: + msg = 'An invalid DingTalk API Token ' \ + '({}) was specified.'.format(token) + self.logger.warning(msg) + raise TypeError(msg) + + self.secret = None + if secret: + self.secret = validate_regex( + secret, *self.template_tokens['secret']['regex']) + if not self.secret: + msg = 'An invalid DingTalk Secret ' \ + '({}) was specified.'.format(token) + self.logger.warning(msg) + raise TypeError(msg) + + # Parse our targets + self.targets = list() + + for target in parse_list(targets): + # Validate targets and drop bad ones: + result = IS_PHONE_NO.match(target) + if result: + # Further check our phone # for it's digit count + result = ''.join(re.findall(r'\d+', result.group('phone'))) + if len(result) < 11 or len(result) > 14: + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) + continue + + # store valid phone number + self.targets.append(result) + continue + + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) + + return + + def get_signature(self): + """ + Calculates time-based signature so that we can send arbitrary messages. + """ + timestamp = str(round(time.time() * 1000)) + secret_enc = self.secret.encode('utf-8') + str_to_sign_enc = \ + "{}\n{}".format(timestamp, self.secret).encode('utf-8') + hmac_code = hmac.new( + secret_enc, str_to_sign_enc, digestmod=hashlib.sha256).digest() + signature = NotifyDingTalk.quote(base64.b64encode(hmac_code), safe='') + return timestamp, signature + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform DingTalk Notification + """ + + payload = { + 'msgtype': 'text', + 'at': { + 'atMobiles': self.targets, + 'isAtAll': False, + } + } + + if self.notify_format == NotifyFormat.MARKDOWN: + payload['markdown'] = { + 'title': title, + 'text': body, + } + + else: + payload['text'] = { + 'content': body, + } + + # Our Notification URL + notify_url = self.notify_url.format(token=self.token) + + params = None + if self.secret: + timestamp, signature = self.get_signature() + params = { + 'timestamp': timestamp, + 'sign': signature, + } + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json' + } + + # Some Debug Logging + self.logger.debug('DingTalk URL: {} (cert_verify={})'.format( + notify_url, self.verify_certificate)) + self.logger.debug('DingTalk Payload: {}' .format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + notify_url, + data=dumps(payload), + headers=headers, + params=params, + verify=self.verify_certificate, + ) + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyDingTalk.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send DingTalk notification: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + return False + + else: + self.logger.info('Sent DingTalk notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending DingTalk ' + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + return True + + @property + def title_maxlen(self): + """ + The title isn't used when not in markdown mode. + """ + return NotifyBase.title_maxlen \ + if self.notify_format == NotifyFormat.MARKDOWN else 0 + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + 'overflow': self.overflow_mode, + 'verify': 'yes' if self.verify_certificate else 'no', + } + + return '{schema}://{secret}{token}/{targets}/?{args}'.format( + schema=self.secure_protocol, + secret='' if not self.secret else '{}@'.format(self.pprint( + self.secret, privacy, mode=PrivacyMode.Secret, safe='')), + token=self.pprint(self.token, privacy, safe=''), + targets='/'.join( + [NotifyDingTalk.quote(x, safe='') for x in self.targets]), + args=NotifyDingTalk.urlencode(args)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets > 0 else 1 + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to substantiate this object. + + """ + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + results['token'] = NotifyDingTalk.unquote(results['host']) + + # if a user has been defined, use it's value as the secret + if results.get('user'): + results['secret'] = results.get('user') + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'] = NotifyDingTalk.split_path(results['fullpath']) + + # Support the use of the `token` keyword argument + if 'token' in results['qsd'] and len(results['qsd']['token']): + results['token'] = \ + NotifyDingTalk.unquote(results['qsd']['token']) + + # Support the use of the `secret` keyword argument + if 'secret' in results['qsd'] and len(results['qsd']['secret']): + results['secret'] = \ + NotifyDingTalk.unquote(results['qsd']['secret']) + + # Support the 'to' variable so that we can support targets this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyDingTalk.parse_list(results['qsd']['to']) + + return results diff --git a/lib/apprise/plugins/NotifyDiscord.py b/lib/apprise/plugins/NotifyDiscord.py new file mode 100644 index 0000000..f87b669 --- /dev/null +++ b/lib/apprise/plugins/NotifyDiscord.py @@ -0,0 +1,709 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# For this to work correctly you need to create a webhook. To do this just +# click on the little gear icon next to the channel you're part of. From +# here you'll be able to access the Webhooks menu and create a new one. +# +# When you've completed, you'll get a URL that looks a little like this: +# https://discord.com/api/webhooks/417429632418316298/\ +# JHZ7lQml277CDHmQKMHI8qBe7bk2ZwO5UKjCiOAF7711o33MyqU344Qpgv7YTpadV_js +# +# Simplified, it looks like this: +# https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN +# +# This plugin will simply work using the url of: +# discord://WEBHOOK_ID/WEBHOOK_TOKEN +# +# API Documentation on Webhooks: +# - https://discord.com/developers/docs/resources/webhook +# +import re +import requests +from json import dumps +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 parse_bool +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ +from ..attachment.AttachBase import AttachBase + + +class NotifyDiscord(NotifyBase): + """ + A wrapper to Discord Notifications + + """ + # The default descriptive name associated with the Notification + service_name = 'Discord' + + # The services URL + service_url = 'https://discord.com/' + + # The default secure protocol + secure_protocol = 'discord' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_discord' + + # Discord Webhook + notify_url = 'https://discord.com/api/webhooks' + + # Support attachments + attachment_support = True + + # Allows the user to specify the NotifyImageSize object + image_size = NotifyImageSize.XY_256 + + # Discord 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 = 0 + + # Taken right from google.auth.helpers: + clock_skew = timedelta(seconds=10) + + # The maximum allowable characters allowed in the body per message + body_maxlen = 2000 + + # Discord has a limit of the number of fields you can include in an + # embeds message. This value allows the discord message to safely + # break into multiple messages to handle these cases. + discord_max_fields = 10 + + # Define object templates + templates = ( + '{schema}://{webhook_id}/{webhook_token}', + '{schema}://{botname}@{webhook_id}/{webhook_token}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'botname': { + 'name': _('Bot Name'), + 'type': 'string', + 'map_to': 'user', + }, + 'webhook_id': { + 'name': _('Webhook ID'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'webhook_token': { + 'name': _('Webhook Token'), + 'type': 'string', + 'private': True, + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'tts': { + 'name': _('Text To Speech'), + 'type': 'bool', + 'default': False, + }, + 'avatar': { + 'name': _('Avatar Image'), + 'type': 'bool', + 'default': True, + }, + 'avatar_url': { + 'name': _('Avatar URL'), + 'type': 'string', + }, + 'href': { + 'name': _('URL'), + 'type': 'string', + }, + 'url': { + 'alias_of': 'href', + }, + # Send a message to the specified thread within a webhook's channel. + # The thread will automatically be unarchived. + 'thread': { + 'name': _('Thread ID'), + 'type': 'string', + }, + 'footer': { + 'name': _('Display Footer'), + 'type': 'bool', + 'default': False, + }, + 'footer_logo': { + 'name': _('Footer Logo'), + 'type': 'bool', + 'default': True, + }, + 'fields': { + 'name': _('Use Fields'), + 'type': 'bool', + 'default': True, + }, + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': False, + 'map_to': 'include_image', + }, + }) + + def __init__(self, webhook_id, webhook_token, tts=False, avatar=True, + footer=False, footer_logo=True, include_image=False, + fields=True, avatar_url=None, href=None, thread=None, + **kwargs): + """ + Initialize Discord Object + + """ + super().__init__(**kwargs) + + # Webhook ID (associated with project) + self.webhook_id = validate_regex(webhook_id) + if not self.webhook_id: + msg = 'An invalid Discord Webhook ID ' \ + '({}) was specified.'.format(webhook_id) + self.logger.warning(msg) + raise TypeError(msg) + + # Webhook Token (associated with project) + self.webhook_token = validate_regex(webhook_token) + if not self.webhook_token: + msg = 'An invalid Discord Webhook Token ' \ + '({}) was specified.'.format(webhook_token) + self.logger.warning(msg) + raise TypeError(msg) + + # Text To Speech + self.tts = tts + + # Over-ride Avatar Icon + self.avatar = avatar + + # Place a footer + self.footer = footer + + # include a footer_logo in footer + self.footer_logo = footer_logo + + # Place a thumbnail image inline with the message body + self.include_image = include_image + + # Use Fields + self.fields = fields + + # Specified Thread ID + self.thread_id = thread + + # Avatar URL + # This allows a user to provide an over-ride to the otherwise + # dynamically generated avatar url images + self.avatar_url = avatar_url + + # A URL to have the title link to + self.href = href + + # 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, attach=None, + **kwargs): + """ + Perform Discord Notification + """ + + payload = { + # Text-To-Speech + 'tts': self.tts, + + # If Text-To-Speech is set to True, then we do not want to wait + # for the whole message before continuing. Otherwise, we wait + 'wait': self.tts is False, + } + + # Acquire image_url + image_url = self.image_url(notify_type) + + if self.avatar and (image_url or self.avatar_url): + payload['avatar_url'] = \ + self.avatar_url if self.avatar_url else image_url + + if self.user: + # Optionally override the default username of the webhook + payload['username'] = self.user + + # Associate our thread_id with our message + params = {'thread_id': self.thread_id} if self.thread_id else None + + if body: + # our fields variable + fields = [] + + if self.notify_format == NotifyFormat.MARKDOWN: + # Use embeds for payload + payload['embeds'] = [{ + 'author': { + 'name': self.app_id, + 'url': self.app_url, + }, + 'title': title, + 'description': body, + + # Our color associated with our notification + 'color': self.color(notify_type, int), + }] + + if self.href: + payload['embeds'][0]['url'] = self.href + + if self.footer: + # Acquire logo URL + logo_url = self.image_url(notify_type, logo=True) + + # Set Footer text to our app description + payload['embeds'][0]['footer'] = { + 'text': self.app_desc, + } + + if self.footer_logo and logo_url: + payload['embeds'][0]['footer']['icon_url'] = logo_url + + if self.include_image and image_url: + payload['embeds'][0]['thumbnail'] = { + 'url': image_url, + 'height': 256, + 'width': 256, + } + + if self.fields: + # Break titles out so that we can sort them in embeds + description, fields = self.extract_markdown_sections(body) + + # Swap first entry for description + payload['embeds'][0]['description'] = description + if fields: + # Apply our additional parsing for a better + # presentation + payload['embeds'][0]['fields'] = \ + fields[:self.discord_max_fields] + + # Remove entry from head of fields + fields = fields[self.discord_max_fields:] + + else: + # not markdown + payload['content'] = \ + body if not title else "{}\r\n{}".format(title, body) + + if not self._send(payload, params=params): + # We failed to post our message + return False + + # Process any remaining fields IF set + if fields: + payload['embeds'][0]['description'] = '' + for i in range(0, len(fields), self.discord_max_fields): + payload['embeds'][0]['fields'] = \ + fields[i:i + self.discord_max_fields] + if not self._send(payload): + # We failed to post our message + return False + + if attach and self.attachment_support: + # Update our payload; the idea is to preserve it's other detected + # and assigned values for re-use here too + payload.update({ + # Text-To-Speech + 'tts': False, + # Wait until the upload has posted itself before continuing + 'wait': True, + }) + + # Remove our text/title based content for attachment use + if 'embeds' in payload: + # Markdown + del payload['embeds'] + + if 'content' in payload: + # Markdown + del payload['content'] + + # Send our attachments + for attachment in attach: + self.logger.info( + 'Posting Discord Attachment {}'.format(attachment.name)) + if not self._send(payload, params=params, attach=attachment): + # We failed to post our message + return False + + # Otherwise return + return True + + def _send(self, payload, attach=None, params=None, rate_limit=1, + **kwargs): + """ + Wrapper to the requests (post) object + """ + + # Our headers + headers = { + 'User-Agent': self.app_id, + } + + # Construct Notify URL + notify_url = '{0}/{1}/{2}'.format( + self.notify_url, + self.webhook_id, + self.webhook_token, + ) + + self.logger.debug('Discord POST URL: %s (cert_verify=%r)' % ( + notify_url, self.verify_certificate, + )) + self.logger.debug('Discord Payload: %s' % str(payload)) + + # By default set wait to None + wait = 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: + + now = datetime.now(timezone.utc).replace(tzinfo=None) + 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()) + + # Always call throttle before any remote server i/o is made; + self.throttle(wait=wait) + + # Perform some simple error checking + if isinstance(attach, AttachBase): + if not attach: + # We could not access the attachment + self.logger.error( + 'Could not access attachment {}.'.format( + attach.url(privacy=True))) + return False + + self.logger.debug( + 'Posting Discord attachment {}'.format( + attach.url(privacy=True))) + + # Our attachment path (if specified) + files = None + try: + + # Open our attachment path if required: + if attach: + files = {'file': (attach.name, open(attach.path, 'rb'))} + + else: + headers['Content-Type'] = 'application/json; charset=utf-8' + + r = requests.post( + notify_url, + params=params, + data=payload if files else dumps(payload), + headers=headers, + files=files, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + # Handle rate limiting (if specified) + try: + # Store our rate limiting (if provided) + self.ratelimit_remaining = \ + float(r.headers.get( + 'X-RateLimit-Remaining')) + self.ratelimit_reset = datetime.fromtimestamp( + int(r.headers.get('X-RateLimit-Reset')), + timezone.utc).replace(tzinfo=None) + + 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): + + # We had a problem + status_str = \ + NotifyBase.http_response_code_lookup(r.status_code) + + if r.status_code == requests.codes.too_many_requests \ + and rate_limit > 0: + + # handle rate limiting + self.logger.warning( + 'Discord rate limiting in effect; ' + 'blocking for %.2f second(s)', + self.ratelimit_remaining) + + # Try one more time before failing + return self._send( + payload=payload, attach=attach, params=params, + rate_limit=rate_limit - 1, **kwargs) + + self.logger.warning( + 'Failed to send {}to Discord notification: ' + '{}{}error={}.'.format( + attach.name if attach else '', + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info('Sent Discord {}.'.format( + 'attachment' if attach else 'notification')) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred posting {}to Discord.'.format( + attach.name if attach else '')) + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while reading {}.'.format( + attach.name if attach else 'attachment')) + self.logger.debug('I/O Exception: %s' % str(e)) + return False + + finally: + # Close our file (if it's open) stored in the second element + # of our files tuple (index 1) + if files: + files['file'][1].close() + + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'tts': 'yes' if self.tts else 'no', + 'avatar': 'yes' if self.avatar else 'no', + 'footer': 'yes' if self.footer else 'no', + 'footer_logo': 'yes' if self.footer_logo else 'no', + 'image': 'yes' if self.include_image else 'no', + 'fields': 'yes' if self.fields else 'no', + } + + if self.avatar_url: + params['avatar_url'] = self.avatar_url + + if self.href: + params['href'] = self.href + + if self.thread_id: + params['thread'] = self.thread_id + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{webhook_id}/{webhook_token}/?{params}'.format( + schema=self.secure_protocol, + webhook_id=self.pprint(self.webhook_id, privacy, safe=''), + webhook_token=self.pprint(self.webhook_token, privacy, safe=''), + params=NotifyDiscord.urlencode(params), + ) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + Syntax: + discord://webhook_id/webhook_token + + """ + 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 webhook ID + webhook_id = NotifyDiscord.unquote(results['host']) + + # Now fetch our tokens + try: + webhook_token = \ + NotifyDiscord.split_path(results['fullpath'])[0] + + except IndexError: + # Force some bad values that will get caught + # in parsing later + webhook_token = None + + results['webhook_id'] = webhook_id + results['webhook_token'] = webhook_token + + # Text To Speech + results['tts'] = parse_bool(results['qsd'].get('tts', False)) + + # Use sections + # effectively detect multiple fields and break them off + # into sections + results['fields'] = parse_bool(results['qsd'].get('fields', True)) + + # Use Footer + results['footer'] = parse_bool(results['qsd'].get('footer', False)) + + # Use Footer Logo + results['footer_logo'] = \ + parse_bool(results['qsd'].get('footer_logo', True)) + + # Update Avatar Icon + results['avatar'] = parse_bool(results['qsd'].get('avatar', True)) + + # Boolean to include an image or not + results['include_image'] = parse_bool(results['qsd'].get( + 'image', NotifyDiscord.template_args['image']['default'])) + + # Extract avatar url if it was specified + if 'avatar_url' in results['qsd']: + results['avatar_url'] = \ + NotifyDiscord.unquote(results['qsd']['avatar_url']) + + # Extract url if it was specified + if 'href' in results['qsd']: + results['href'] = \ + NotifyDiscord.unquote(results['qsd']['href']) + + elif 'url' in results['qsd']: + results['href'] = \ + NotifyDiscord.unquote(results['qsd']['url']) + # Markdown is implied + results['format'] = NotifyFormat.MARKDOWN + + # Extract thread id if it was specified + if 'thread' in results['qsd']: + results['thread'] = \ + NotifyDiscord.unquote(results['qsd']['thread']) + # Markdown is implied + results['format'] = NotifyFormat.MARKDOWN + + return results + + @staticmethod + def parse_native_url(url): + """ + Support https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN + Support Legacy URL as well: + https://discordapp.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN + """ + + result = re.match( + r'^https?://discord(app)?\.com/api/webhooks/' + r'(?P[0-9]+)/' + r'(?P[A-Z0-9_-]+)/?' + r'(?P\?.+)?$', url, re.I) + + if result: + return NotifyDiscord.parse_url( + '{schema}://{webhook_id}/{webhook_token}/{params}'.format( + schema=NotifyDiscord.secure_protocol, + webhook_id=result.group('webhook_id'), + webhook_token=result.group('webhook_token'), + params='' if not result.group('params') + else result.group('params'))) + + return None + + @staticmethod + def extract_markdown_sections(markdown): + """ + Takes a string in a markdown type format and extracts + the headers and their corresponding sections into individual + fields that get passed as an embed entry to Discord. + + """ + # Search for any header information found without it's own section + # identifier + match = re.match( + r'^\s*(?P[^\s#]+.*?)(?=\s*$|[\r\n]+\s*#)', + markdown, flags=re.S) + + description = match.group('desc').strip() if match else '' + if description: + # Strip description from our string since it has been handled + # now. + markdown = re.sub(re.escape(description), '', markdown, count=1) + + regex = re.compile( + r'\s*#[# \t\v]*(?P[^\n]+)(\n|\s*$)' + r'\s*((?P[^#].+?)(?=\s*$|[\r\n]+\s*#))?', flags=re.S) + + common = regex.finditer(markdown) + fields = list() + for el in common: + d = el.groupdict() + + fields.append({ + 'name': d.get('name', '').strip('#`* \r\n\t\v'), + 'value': '```{}\n{}```'.format( + 'md' if d.get('value') else '', + d.get('value').strip() + '\n' if d.get('value') else '', + ), + }) + + return description, fields diff --git a/lib/apprise/plugins/NotifyEmail.py b/lib/apprise/plugins/NotifyEmail.py new file mode 100644 index 0000000..db70c8e --- /dev/null +++ b/lib/apprise/plugins/NotifyEmail.py @@ -0,0 +1,1090 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 dataclasses +import re +import smtplib +import typing as t +from email.mime.text import MIMEText +from email.mime.application import MIMEApplication +from email.mime.multipart import MIMEMultipart +from email.utils import formataddr, make_msgid +from email.header import Header +from email import charset + +from socket import error as SocketError +from datetime import datetime +from datetime import timezone + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyFormat, NotifyType +from ..conversion import convert_between +from ..utils import is_email, parse_emails +from ..AppriseLocale import gettext_lazy as _ +from ..logger import logger + +# Globally Default encoding mode set to Quoted Printable. +charset.add_charset('utf-8', charset.QP, charset.QP, 'utf-8') + + +class WebBaseLogin: + """ + This class is just used in conjunction of the default emailers + to best formulate a login to it using the data detected + """ + # User Login must be Email Based + EMAIL = 'Email' + + # User Login must UserID Based + USERID = 'UserID' + + +# Secure Email Modes +class SecureMailMode: + INSECURE = "insecure" + SSL = "ssl" + STARTTLS = "starttls" + + +# Define all of the secure modes (used during validation) +SECURE_MODES = { + SecureMailMode.STARTTLS: { + 'default_port': 587, + }, + SecureMailMode.SSL: { + 'default_port': 465, + }, + SecureMailMode.INSECURE: { + 'default_port': 25, + }, +} + +# To attempt to make this script stupid proof, if we detect an email address +# that is part of the this table, we can pre-use a lot more defaults if they +# aren't otherwise specified on the users input. +EMAIL_TEMPLATES = ( + # Google GMail + ( + 'Google Mail', + re.compile( + r'^((?P

test

", + # "reblog":null, + # "application":{ + # "name":"Apprise Notifications", + # "website":"https://github.com/caronc/apprise" + # }, + # "account":{ + # "id":"109310334138718878", + # "username":"caronc", + # "acct":"caronc", + # "display_name":"Chris", + # "locked":false, + # "bot":false, + # "discoverable":false, + # "group":false, + # "created_at":"2022-11-08T00:00:00.000Z", + # "note":"content", + # "url":"https://host/@caronc", + # "avatar":"https://host/path/file.png", + # "avatar_static":"https://host/path/file.png", + # "header":"https://host/headers/original/missing.png", + # "header_static":"https://host/path/missing.png", + # "followers_count":0, + # "following_count":0, + # "statuses_count":15, + # "last_status_at":"2022-11-09", + # "emojis":[ + # + # ], + # "fields":[ + # + # ] + # }, + # "media_attachments":[ + # { + # "id":"109315796405707501", + # "type":"image", + # "url":"https://host/path/file.jpeg", + # "preview_url":"https://host/path/file.jpeg", + # "remote_url":null, + # "preview_remote_url":null, + # "text_url":null, + # "meta":{ + # "original":{ + # "width":640, + # "height":640, + # "size":"640x640", + # "aspect":1.0 + # }, + # "small":{ + # "width":400, + # "height":400, + # "size":"400x400", + # "aspect":1.0 + # } + # }, + # "description":null, + # "blurhash":"UmIsdJnT^mX4V@XQofnQ~Ebq%4o3ofnQjZbt" + # } + # ], + # "mentions":[ + # + # ], + # "tags":[ + # + # ], + # "emojis":[ + # + # ], + # "card":null, + # "poll":null + # } + + try: + url = '{}/web/@{}'.format( + self.api_url, + response['account']['username']) + + except (KeyError, TypeError): + url = 'unknown' + + self.logger.debug( + 'Mastodon [%.2d/%.2d] (%d attached) delivered to %s', + no, len(payloads), len(payload.get('media_ids', [])), url) + + self.logger.info( + 'Sent [%.2d/%.2d] Mastodon notification as public toot.', + no, len(payloads)) + + return not has_error + + def _whoami(self, lazy=True): + """ + Looks details of current authenticated user + + """ + + if lazy and self._whoami_cache is not None: + # Use cached response + return self._whoami_cache + + # Send Mastodon Whoami request + postokay, response = self._request( + self.mastodon_whoami, + method='GET', + ) + + if postokay: + # Sample Response: + # { + # 'id': '12345', + # 'username': 'caronc', + # 'acct': 'caronc', + # 'display_name': 'Chris', + # 'locked': False, + # 'bot': False, + # 'discoverable': False, + # 'group': False, + # 'created_at': '2022-11-08T00:00:00.000Z', + # 'note': 'details', + # 'url': 'https://noc.social/@caronc', + # 'avatar': 'https://host/path/image.png', + # 'avatar_static': 'https://host/path/image.png', + # 'header': 'https://host/path/missing.png', + # 'header_static': 'https://host/path/missing.png', + # 'followers_count': 0, + # 'following_count': 0, + # 'statuses_count': 2, + # 'last_status_at': '2022-11-09', + # 'source': { + # 'privacy': 'public', + # 'sensitive': False, + # 'language': None, + # 'note': 'details', + # 'fields': [], + # 'follow_requests_count': 0 + # }, + # 'emojis': [], + # 'fields': [] + # } + try: + # Cache our response for future references + self._whoami_cache = { + response['username']: response['id']} + + except (TypeError, KeyError): + pass + + elif response and 'authorized scopes' in response.get('error', ''): + self.logger.warning( + 'Failed to lookup Mastodon Auth details; ' + 'missing scope: read:accounts') + + return self._whoami_cache if postokay else {} + + def _request(self, path, payload=None, method='POST'): + """ + Wrapper to Mastodon API requests object + """ + + headers = { + 'User-Agent': self.app_id, + 'Authorization': f'Bearer {self.token}', + } + + data = None + files = None + + # Prepare our message + url = '{}{}'.format(self.api_url, path) + + # Some Debug Logging + self.logger.debug('Mastodon {} URL: {} (cert_verify={})'.format( + method, url, self.verify_certificate)) + + # Open our attachment path if required: + if isinstance(payload, AttachBase): + # prepare payload + files = { + 'file': (payload.name, open(payload.path, 'rb'), + 'application/octet-stream')} + + # Provide a description + data = { + 'description': payload.name, + } + + else: + headers['Content-Type'] = 'application/json' + data = dumps(payload) + self.logger.debug('Mastodon Payload: %s' % str(payload)) + + # Default content response object + content = {} + + # By default set wait to None + wait = None + + if self.ratelimit_remaining == 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 + # Mastodon server. One would hope we're on NTP and our clocks are + # the same allowing this to role smoothly: + + now = datetime.now(timezone.utc).replace(tzinfo=None) + if now < self.ratelimit_reset: + # We need to throttle for the difference in seconds + # We add 0.5 seconds to the end just to allow a grace + # period. + wait = (self.ratelimit_reset - now).total_seconds() + 0.5 + + # Always call throttle before any remote server i/o is made; + self.throttle(wait=wait) + + # acquire our request mode + fn = requests.post if method == 'POST' else requests.get + + try: + r = fn( + url, + data=data, + files=files, + 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 = {} + + if r.status_code not in ( + requests.codes.ok, requests.codes.created, + requests.codes.accepted): + + # We had a problem + status_str = \ + NotifyMastodon.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send Mastodon {} to {}: ' + '{}error={}.'.format( + method, + url, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + return (False, content) + + try: + # Capture rate limiting if possible + self.ratelimit_remaining = \ + int(r.headers.get('X-RateLimit-Remaining')) + self.ratelimit_reset = datetime.fromtimestamp( + int(r.headers.get('X-RateLimit-Limit')), timezone.utc + ).replace(tzinfo=None) + + except (TypeError, ValueError): + # This is returned if we could not retrieve this information + # gracefully accept this state and move on + pass + + except requests.RequestException as e: + self.logger.warning( + 'Exception received when sending Mastodon {} to {}: '. + format(method, url)) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + return (False, content) + + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while handling {}.'.format( + payload.name if isinstance(payload, AttachBase) + else payload)) + self.logger.debug('I/O Exception: %s' % str(e)) + return (False, content) + + finally: + # Close our file (if it's open) stored in the second element + # of our files tuple (index 1) + if files: + files['file'][1].close() + + return (True, content) + + @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 + + if 'token' in results['qsd'] and len(results['qsd']['token']): + results['token'] = NotifyMastodon.unquote(results['qsd']['token']) + + elif not results['password'] and results['user']: + results['token'] = NotifyMastodon.unquote(results['user']) + + # Apply our targets + results['targets'] = NotifyMastodon.split_path(results['fullpath']) + + # The defined Mastodon visibility + if 'visibility' in results['qsd'] and \ + len(results['qsd']['visibility']): + # Simplified version + results['visibility'] = \ + NotifyMastodon.unquote(results['qsd']['visibility']) + + elif results['schema'].startswith('toot'): + results['visibility'] = MastodonMessageVisibility.PUBLIC + + # Get Idempotency Key (if specified) + if 'key' in results['qsd'] and len(results['qsd']['key']): + results['key'] = \ + NotifyMastodon.unquote(results['qsd']['key']) + + # Get Spoiler Text + if 'spoiler' in results['qsd'] and len(results['qsd']['spoiler']): + results['spoiler'] = \ + NotifyMastodon.unquote(results['qsd']['spoiler']) + + # Get Language (if specified) + if 'language' in results['qsd'] and len(results['qsd']['language']): + results['language'] = \ + NotifyMastodon.unquote(results['qsd']['language']) + + # Get Sensitive Flag (for Attachments) + results['sensitive'] = \ + parse_bool(results['qsd'].get( + 'sensitive', + NotifyMastodon.template_args['sensitive']['default'])) + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get( + 'batch', NotifyMastodon.template_args['batch']['default'])) + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyMastodon.parse_list(results['qsd']['to']) + + return results diff --git a/lib/apprise/plugins/NotifyMatrix.py b/lib/apprise/plugins/NotifyMatrix.py new file mode 100644 index 0000000..8f3e77f --- /dev/null +++ b/lib/apprise/plugins/NotifyMatrix.py @@ -0,0 +1,1455 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# Great sources +# - https://github.com/matrix-org/matrix-python-sdk +# - https://github.com/matrix-org/synapse/blob/master/docs/reverse_proxy.rst +# +import re +import requests +from markdown import markdown +from json import dumps +from json import loads +from time import time + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..common import NotifyImageSize +from ..common import NotifyFormat +from ..utils import parse_bool +from ..utils import parse_list +from ..utils import is_hostname +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + +# Define default path +MATRIX_V1_WEBHOOK_PATH = '/api/v1/matrix/hook' +MATRIX_V2_API_PATH = '/_matrix/client/r0' +MATRIX_V3_API_PATH = '/_matrix/client/v3' +MATRIX_V3_MEDIA_PATH = '/_matrix/media/v3' +MATRIX_V2_MEDIA_PATH = '/_matrix/media/r0' + +# Extend HTTP Error Messages +MATRIX_HTTP_ERROR_MAP = { + 403: 'Unauthorized - Invalid Token.', + 429: 'Rate limit imposed; wait 2s and try again', +} + +# Matrix Room Syntax +IS_ROOM_ALIAS = re.compile( + r'^\s*(#|%23)?(?P[a-z0-9-]+)((:|%3A)' + r'(?P[a-z0-9.-]+))?\s*$', re.I) + +# Room ID MUST start with an exclamation to avoid ambiguity +IS_ROOM_ID = re.compile( + r'^\s*(!|!|%21)(?P[a-z0-9-]+)((:|%3A)' + r'(?P[a-z0-9.-]+))?\s*$', re.I) + + +class MatrixMessageType: + """ + The Matrix Message types + """ + TEXT = "text" + NOTICE = "notice" + + +# matrix message types are placed into this list for validation purposes +MATRIX_MESSAGE_TYPES = ( + MatrixMessageType.TEXT, + MatrixMessageType.NOTICE, +) + + +class MatrixVersion: + # Version 2 + V2 = "2" + + # Version 3 + V3 = "3" + + +# webhook modes are placed into this list for validation purposes +MATRIX_VERSIONS = ( + MatrixVersion.V2, + MatrixVersion.V3, +) + + +class MatrixWebhookMode: + # Webhook Mode is disabled + DISABLED = "off" + + # The default webhook mode is to just be set to Matrix + MATRIX = "matrix" + + # Support the slack webhook plugin + SLACK = "slack" + + # Support the t2bot webhook plugin + T2BOT = "t2bot" + + +# webhook modes are placed into this list for validation purposes +MATRIX_WEBHOOK_MODES = ( + MatrixWebhookMode.DISABLED, + MatrixWebhookMode.MATRIX, + MatrixWebhookMode.SLACK, + MatrixWebhookMode.T2BOT, +) + + +class NotifyMatrix(NotifyBase): + """ + A wrapper for Matrix Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Matrix' + + # The services URL + service_url = 'https://matrix.org/' + + # The default protocol + protocol = 'matrix' + + # The default secure protocol + secure_protocol = 'matrixs' + + # Support Attachments + attachment_support = True + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_matrix' + + # Allows the user to specify the NotifyImageSize object + image_size = NotifyImageSize.XY_32 + + # The maximum allowable characters allowed in the body per message + # https://spec.matrix.org/v1.6/client-server-api/#size-limits + # The complete event MUST NOT be larger than 65536 bytes, when formatted + # with the federation event format, including any signatures, and encoded + # as Canonical JSON. + # + # To gracefully allow for some overhead' we'll define a max body length + # of just slighty lower then the limit of the full message itself. + body_maxlen = 65000 + + # Throttle a wee-bit to avoid thrashing + request_rate_per_sec = 0.5 + + # Our Matrix API Version + matrix_api_version = '3' + + # How many retry attempts we'll make in the event the server asks us to + # throttle back. + default_retries = 2 + + # The number of micro seconds to wait if we get a 429 error code and + # the server doesn't remind us how long we shoul wait for + default_wait_ms = 1000 + + # Define object templates + templates = ( + # Targets are ignored when using t2bot mode; only a token is required + '{schema}://{token}', + '{schema}://{user}@{token}', + + # Disabled webhook + '{schema}://{user}:{password}@{host}/{targets}', + '{schema}://{user}:{password}@{host}:{port}/{targets}', + + # Webhook mode + '{schema}://{user}:{token}@{host}/{targets}', + '{schema}://{user}:{token}@{host}:{port}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'user': { + 'name': _('Username'), + 'type': 'string', + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + }, + 'token': { + 'name': _('Access Token'), + 'private': True, + 'map_to': 'password', + }, + 'target_user': { + 'name': _('Target User'), + 'type': 'string', + 'prefix': '@', + 'map_to': 'targets', + }, + 'target_room_id': { + 'name': _('Target Room ID'), + 'type': 'string', + 'prefix': '!', + 'map_to': 'targets', + }, + 'target_room_alias': { + 'name': _('Target Room Alias'), + 'type': 'string', + 'prefix': '!', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': False, + 'map_to': 'include_image', + }, + 'mode': { + 'name': _('Webhook Mode'), + 'type': 'choice:string', + 'values': MATRIX_WEBHOOK_MODES, + 'default': MatrixWebhookMode.DISABLED, + }, + 'version': { + 'name': _('Matrix API Verion'), + 'type': 'choice:string', + 'values': MATRIX_VERSIONS, + 'default': MatrixVersion.V3, + }, + 'msgtype': { + 'name': _('Message Type'), + 'type': 'choice:string', + 'values': MATRIX_MESSAGE_TYPES, + 'default': MatrixMessageType.TEXT, + }, + 'to': { + 'alias_of': 'targets', + }, + 'token': { + 'alias_of': 'token', + }, + }) + + def __init__(self, targets=None, mode=None, msgtype=None, version=None, + include_image=False, **kwargs): + """ + Initialize Matrix Object + """ + super().__init__(**kwargs) + + # Prepare a list of rooms to connect and notify + self.rooms = parse_list(targets) + + # our home server gets populated after a login/registration + self.home_server = None + + # our user_id gets populated after a login/registration + self.user_id = None + + # This gets initialized after a login/registration + self.access_token = None + + # Place an image inline with the message body + self.include_image = include_image + + # maintain a lookup of room alias's we already paired with their id + # to speed up future requests + self._room_cache = {} + + # Setup our mode + self.mode = self.template_args['mode']['default'] \ + if not isinstance(mode, str) else mode.lower() + if self.mode and self.mode not in MATRIX_WEBHOOK_MODES: + msg = 'The mode specified ({}) is invalid.'.format(mode) + self.logger.warning(msg) + raise TypeError(msg) + + # Setup our version + self.version = self.template_args['version']['default'] \ + if not isinstance(version, str) else version + if self.version not in MATRIX_VERSIONS: + msg = 'The version specified ({}) is invalid.'.format(version) + self.logger.warning(msg) + raise TypeError(msg) + + # Setup our message type + self.msgtype = self.template_args['msgtype']['default'] \ + if not isinstance(msgtype, str) else msgtype.lower() + if self.msgtype and self.msgtype not in MATRIX_MESSAGE_TYPES: + msg = 'The msgtype specified ({}) is invalid.'.format(msgtype) + self.logger.warning(msg) + raise TypeError(msg) + + if self.mode == MatrixWebhookMode.T2BOT: + # t2bot configuration requires that a webhook id is specified + self.access_token = validate_regex( + self.password, r'^[a-z0-9]{64}$', 'i') + if not self.access_token: + msg = 'An invalid T2Bot/Matrix Webhook ID ' \ + '({}) was specified.'.format(self.password) + self.logger.warning(msg) + raise TypeError(msg) + + elif not is_hostname(self.host): + msg = 'An invalid Matrix Hostname ({}) was specified'\ + .format(self.host) + self.logger.warning(msg) + raise TypeError(msg) + else: + # Verify port if specified + if self.port is not None and not ( + isinstance(self.port, int) + and self.port >= self.template_tokens['port']['min'] + and self.port <= self.template_tokens['port']['max']): + msg = 'An invalid Matrix Port ({}) was specified'\ + .format(self.port) + self.logger.warning(msg) + raise TypeError(msg) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Matrix Notification + """ + + # Call the _send_ function applicable to whatever mode we're in + # - calls _send_webhook_notification if the mode variable is set + # - calls _send_server_notification if the mode variable is not set + return getattr(self, '_send_{}_notification'.format( + 'webhook' if self.mode != MatrixWebhookMode.DISABLED + else 'server'))( + body=body, title=title, notify_type=notify_type, **kwargs) + + def _send_webhook_notification(self, body, title='', + notify_type=NotifyType.INFO, **kwargs): + """ + Perform Matrix Notification as a webhook + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + } + + if self.mode != MatrixWebhookMode.T2BOT: + # Acquire our access token from our URL + access_token = self.password if self.password else self.user + + default_port = 443 if self.secure else 80 + + # Prepare our URL + url = '{schema}://{hostname}:{port}{webhook_path}/{token}'.format( + schema='https' if self.secure else 'http', + hostname=self.host, + port='' if self.port is None + or self.port == default_port else self.port, + webhook_path=MATRIX_V1_WEBHOOK_PATH, + token=access_token, + ) + + else: + # + # t2bot Setup + # + + # Prepare our URL + url = 'https://webhooks.t2bot.io/api/v1/matrix/hook/' \ + '{token}'.format(token=self.access_token) + + # Retrieve our payload + payload = getattr(self, '_{}_webhook_payload'.format(self.mode))( + body=body, title=title, notify_type=notify_type, **kwargs) + + self.logger.debug('Matrix POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('Matrix 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 = \ + NotifyMatrix.http_response_code_lookup( + r.status_code, MATRIX_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send Matrix notification: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info('Sent Matrix notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Matrix notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + # Return; we're done + return False + + return True + + def _slack_webhook_payload(self, body, title='', + notify_type=NotifyType.INFO, **kwargs): + """ + Format the payload for a Slack based message + + """ + + if not hasattr(self, '_re_slack_formatting_rules'): + # Prepare some one-time slack formatting variables + + self._re_slack_formatting_map = { + # New lines must become the string version + r'\r\*\n': '\\n', + # Escape other special characters + r'&': '&', + r'<': '<', + r'>': '>', + } + + # Iterate over above list and store content accordingly + self._re_slack_formatting_rules = re.compile( + r'(' + '|'.join(self._re_slack_formatting_map.keys()) + r')', + re.IGNORECASE, + ) + + # Perform Formatting + title = self._re_slack_formatting_rules.sub( # pragma: no branch + lambda x: self._re_slack_formatting_map[x.group()], title, + ) + + body = self._re_slack_formatting_rules.sub( # pragma: no branch + lambda x: self._re_slack_formatting_map[x.group()], body, + ) + + # prepare JSON Object + payload = { + 'username': self.user if self.user else self.app_id, + # Use Markdown language + 'mrkdwn': (self.notify_format == NotifyFormat.MARKDOWN), + 'attachments': [{ + 'title': title, + 'text': body, + 'color': self.color(notify_type), + 'ts': time(), + 'footer': self.app_id, + }], + } + + return payload + + def _matrix_webhook_payload(self, body, title='', + notify_type=NotifyType.INFO, **kwargs): + """ + Format the payload for a Matrix based message + + """ + + payload = { + 'displayName': + self.user if self.user else self.app_id, + 'format': 'plain' if self.notify_format == NotifyFormat.TEXT + else 'html', + 'text': '', + } + + if self.notify_format == NotifyFormat.HTML: + payload['text'] = '{title}{body}'.format( + title='' if not title else '

{}

'.format( + NotifyMatrix.escape_html(title)), + body=body) + + elif self.notify_format == NotifyFormat.MARKDOWN: + payload['text'] = '{title}{body}'.format( + title='' if not title else '

{}

'.format( + NotifyMatrix.escape_html(title)), + body=markdown(body)) + + else: # NotifyFormat.TEXT + payload['text'] = \ + body if not title else '{}\r\n{}'.format(title, body) + + return payload + + def _t2bot_webhook_payload(self, body, title='', + notify_type=NotifyType.INFO, **kwargs): + """ + Format the payload for a T2Bot Matrix based messages + + """ + + # Retrieve our payload + payload = self._matrix_webhook_payload( + body=body, title=title, notify_type=notify_type, **kwargs) + + # Acquire our image url if we're configured to do so + image_url = None if not self.include_image else \ + self.image_url(notify_type) + + if image_url: + # t2bot can take an avatarUrl Entry + payload['avatarUrl'] = image_url + + return payload + + def _send_server_notification(self, body, title='', + notify_type=NotifyType.INFO, attach=None, + **kwargs): + """ + Perform Direct Matrix Server Notification (no webhook) + """ + + if self.access_token is None: + # We need to register + if not self._login(): + if not self._register(): + return False + + if len(self.rooms) == 0: + # Attempt to retrieve a list of already joined channels + self.rooms = self._joined_rooms() + + if len(self.rooms) == 0: + # Nothing to notify + self.logger.warning( + 'There were no Matrix rooms specified to notify.') + return False + + # Create a copy of our rooms to join and message + rooms = list(self.rooms) + + # Initiaize our error tracking + has_error = False + + attachments = None + if attach and self.attachment_support: + attachments = self._send_attachments(attach) + if attachments is False: + # take an early exit + return False + + while len(rooms) > 0: + + # Get our room + room = rooms.pop(0) + + # Get our room_id from our response + room_id = self._room_join(room) + if not room_id: + # Notify our user about our failure + self.logger.warning( + 'Could not join Matrix room {}.'.format((room))) + + # Mark our failure + has_error = True + continue + + # Acquire our image url if we're configured to do so + image_url = None if not self.include_image else \ + self.image_url(notify_type) + + # Build our path + if self.version == MatrixVersion.V3: + path = '/rooms/{}/send/m.room.message/0'.format( + NotifyMatrix.quote(room_id)) + + else: + path = '/rooms/{}/send/m.room.message'.format( + NotifyMatrix.quote(room_id)) + + if self.version == MatrixVersion.V2: + # + # Attachments don't work beyond V2 at this time + # + if image_url: + # Define our payload + image_payload = { + 'msgtype': 'm.image', + 'url': image_url, + 'body': '{}'.format( + notify_type if not title else title), + } + + # Post our content + postokay, response = self._fetch( + path, payload=image_payload) + if not postokay: + # Mark our failure + has_error = True + continue + + if attachments: + for attachment in attachments: + attachment['room_id'] = room_id + attachment['type'] = 'm.room.message' + + postokay, response = self._fetch( + path, payload=attachment) + if not postokay: + # Mark our failure + has_error = True + continue + + # Define our payload + payload = { + 'msgtype': 'm.{}'.format(self.msgtype), + 'body': '{title}{body}'.format( + title='' if not title else '# {}\r\n'.format(title), + body=body), + } + + # Update our payload advance formatting for the services that + # support them. + if self.notify_format == NotifyFormat.HTML: + payload.update({ + 'format': 'org.matrix.custom.html', + 'formatted_body': '{title}{body}'.format( + title='' if not title else '

{}

'.format(title), + body=body, + ) + }) + + elif self.notify_format == NotifyFormat.MARKDOWN: + payload.update({ + 'format': 'org.matrix.custom.html', + 'formatted_body': '{title}{body}'.format( + title='' if not title else '

{}

'.format( + NotifyMatrix.escape_html(title, whitespace=False)), + body=markdown(body), + ) + }) + + # Post our content + method = 'PUT' if self.version == MatrixVersion.V3 else 'POST' + postokay, response = self._fetch( + path, payload=payload, method=method) + if not postokay: + # Notify our user + self.logger.warning( + 'Could not send notification Matrix room {}.'.format(room)) + + # Mark our failure + has_error = True + continue + + return not has_error + + def _send_attachments(self, attach): + """ + Posts all of the provided attachments + """ + + payloads = [] + if self.version != MatrixVersion.V2: + self.logger.warning( + 'Add ?v=2 to Apprise URL to support Attachments') + return next((False for a in attach if not a), []) + + for attachment in attach: + if not attachment: + # invalid attachment (bad file) + return False + + if not re.match(r'^image/', attachment.mimetype, re.I): + # unsuppored at this time + continue + + postokay, response = \ + self._fetch('/upload', attachment=attachment) + if not (postokay and isinstance(response, dict)): + # Failed to perform upload + return False + + # If we get here, we'll have a response that looks like: + # { + # "content_uri": "mxc://example.com/a-unique-key" + # } + + if self.version == MatrixVersion.V3: + # Prepare our payload + payloads.append({ + "body": attachment.name, + "info": { + "mimetype": attachment.mimetype, + "size": len(attachment), + }, + "msgtype": "m.image", + "url": response.get('content_uri'), + }) + + else: + # Prepare our payload + payloads.append({ + "info": { + "mimetype": attachment.mimetype, + }, + "msgtype": "m.image", + "body": "tta.webp", + "url": response.get('content_uri'), + }) + + return payloads + + def _register(self): + """ + Register with the service if possible. + """ + + # Prepare our Registration Payload. This will only work if registration + # is enabled for the public + payload = { + 'kind': 'user', + 'auth': {'type': 'm.login.dummy'}, + } + + # parameters + params = { + 'kind': 'user', + } + + # If a user is not specified, one will be randomly generated for you. + # If you do not specify a password, you will be unable to login to the + # account if you forget the access_token. + if self.user: + payload['username'] = self.user + + if self.password: + payload['password'] = self.password + + # Register + postokay, response = \ + self._fetch('/register', payload=payload, params=params) + if not (postokay and isinstance(response, dict)): + # Failed to register + return False + + # Pull the response details + self.access_token = response.get('access_token') + self.home_server = response.get('home_server') + self.user_id = response.get('user_id') + + if self.access_token is not None: + self.logger.debug( + 'Registered successfully with Matrix server.') + return True + + return False + + def _login(self): + """ + Acquires the matrix token required for making future requests. If we + fail we return False, otherwise we return True + """ + + if self.access_token: + # Login not required; silently skip-over + return True + + if not (self.user and self.password): + # It's not possible to register since we need these 2 values to + # make the action possible. + self.logger.warning( + 'Failed to login to Matrix server: ' + 'user/pass combo is missing.') + return False + + # Prepare our Authentication Payload + if self.version == MatrixVersion.V3: + payload = { + 'type': 'm.login.password', + 'identifier': { + 'type': 'm.id.user', + 'user': self.user, + }, + 'password': self.password, + } + + else: + payload = { + 'type': 'm.login.password', + 'user': self.user, + 'password': self.password, + } + + # Build our URL + postokay, response = self._fetch('/login', payload=payload) + if not (postokay and isinstance(response, dict)): + # Failed to login + return False + + # Pull the response details + self.access_token = response.get('access_token') + self.home_server = response.get('home_server') + self.user_id = response.get('user_id') + + if not self.access_token: + return False + + self.logger.debug( + 'Authenticated successfully with Matrix server.') + return True + + def _logout(self): + """ + Relinquishes token from remote server + """ + + if not self.access_token: + # Login not required; silently skip-over + return True + + # Prepare our Registration Payload + payload = {} + + # Expire our token + postokay, response = self._fetch('/logout', payload=payload) + if not postokay: + # If we get here, the token was declared as having already + # been expired. The response looks like this: + # { + # u'errcode': u'M_UNKNOWN_TOKEN', + # u'error': u'Access Token unknown or expired', + # } + # + # In this case it's okay to safely return True because + # we're logged out in this case. + if response.get('errcode') != u'M_UNKNOWN_TOKEN': + return False + + # else: The response object looks like this if we were successful: + # {} + + # Pull the response details + self.access_token = None + self.home_server = None + self.user_id = None + + # Clear our room cache + self._room_cache = {} + + self.logger.debug( + 'Unauthenticated successfully with Matrix server.') + + return True + + def _room_join(self, room): + """ + Joins a matrix room if we're not already in it. Otherwise it attempts + to create it if it doesn't exist and always returns + the room_id if it was successful, otherwise it returns None + + """ + + if not self.access_token: + # We can't join a room if we're not logged in + return None + + if not isinstance(room, str): + # Not a supported string + return None + + # Prepare our Join Payload + payload = {} + + # Check if it's a room id... + result = IS_ROOM_ID.match(room) + if result: + # We detected ourselves the home_server + home_server = result.group('home_server') \ + if result.group('home_server') else self.home_server + + # It was a room ID; simple mapping: + room_id = "!{}:{}".format( + result.group('room'), + home_server, + ) + + # Check our cache for speed: + if room_id in self._room_cache: + # We're done as we've already joined the channel + return self._room_cache[room_id]['id'] + + # Build our URL + path = '/join/{}'.format(NotifyMatrix.quote(room_id)) + + # Make our query + postokay, _ = self._fetch(path, payload=payload) + if postokay: + # Cache our entry for fast access later + self._room_cache[room_id] = { + 'id': room_id, + 'home_server': home_server, + } + + return room_id if postokay else None + + # Try to see if it's an alias then... + result = IS_ROOM_ALIAS.match(room) + if not result: + # There is nothing else it could be + self.logger.warning( + 'Ignoring illegally formed room {} ' + 'from Matrix server list.'.format(room)) + return None + + # If we reach here, we're dealing with a channel alias + home_server = self.home_server \ + if not result.group('home_server') \ + else result.group('home_server') + + # tidy our room (alias) identifier + room = '#{}:{}'.format(result.group('room'), home_server) + + # Check our cache for speed: + if room in self._room_cache: + # We're done as we've already joined the channel + return self._room_cache[room]['id'] + + # If we reach here, we need to join the channel + + # Build our URL + path = '/join/{}'.format(NotifyMatrix.quote(room)) + + # Attempt to join the channel + postokay, response = self._fetch(path, payload=payload) + if postokay: + # Cache our entry for fast access later + self._room_cache[room] = { + 'id': response.get('room_id'), + 'home_server': home_server, + } + return self._room_cache[room]['id'] + + # Try to create the channel + return self._room_create(room) + + def _room_create(self, room): + """ + Creates a matrix room and return it's room_id if successful + otherwise None is returned. + """ + if not self.access_token: + # We can't create a room if we're not logged in + return None + + if not isinstance(room, str): + # Not a supported string + return None + + # Build our room if we have to: + result = IS_ROOM_ALIAS.match(room) + if not result: + # Illegally formed room + return None + + # Our home_server + home_server = result.group('home_server') \ + if result.group('home_server') else self.home_server + + # update our room details + room = '#{}:{}'.format(result.group('room'), home_server) + + # Prepare our Create Payload + payload = { + 'room_alias_name': result.group('room'), + # Set our channel name + 'name': '#{} - {}'.format(result.group('room'), self.app_desc), + # hide the room by default; let the user open it up if they wish + # to others. + 'visibility': 'private', + 'preset': 'trusted_private_chat', + } + + postokay, response = self._fetch('/createRoom', payload=payload) + if not postokay: + # Failed to create channel + # Typical responses: + # - {u'errcode': u'M_ROOM_IN_USE', + # u'error': u'Room alias already taken'} + # - {u'errcode': u'M_UNKNOWN', + # u'error': u'Internal server error'} + if (response and response.get('errcode') == 'M_ROOM_IN_USE'): + return self._room_id(room) + return None + + # Cache our entry for fast access later + self._room_cache[response.get('room_alias')] = { + 'id': response.get('room_id'), + 'home_server': home_server, + } + + return response.get('room_id') + + def _joined_rooms(self): + """ + Returns a list of the current rooms the logged in user + is a part of. + """ + + if not self.access_token: + # No list is possible + return list() + + postokay, response = self._fetch( + '/joined_rooms', payload=None, method='GET') + if not postokay: + # Failed to retrieve listings + return list() + + # Return our list of rooms + return response.get('joined_rooms', list()) + + def _room_id(self, room): + """Get room id from its alias. + Args: + room (str): The room alias name. + + Returns: + returns the room id if it can, otherwise it returns None + """ + + if not self.access_token: + # We can't get a room id if we're not logged in + return None + + if not isinstance(room, str): + # Not a supported string + return None + + # Build our room if we have to: + result = IS_ROOM_ALIAS.match(room) + if not result: + # Illegally formed room + return None + + # Our home_server + home_server = result.group('home_server') \ + if result.group('home_server') else self.home_server + + # update our room details + room = '#{}:{}'.format(result.group('room'), home_server) + + # Make our request + postokay, response = self._fetch( + "/directory/room/{}".format( + NotifyMatrix.quote(room)), payload=None, method='GET') + + if postokay: + return response.get("room_id") + + return None + + def _fetch(self, path, payload=None, params=None, attachment=None, + method='POST'): + """ + Wrapper to request.post() to manage it's response better and make + the send() function cleaner and easier to maintain. + + This function returns True if the _post was successful and False + if it wasn't. + """ + + # Define our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + } + + if self.access_token is not None: + headers["Authorization"] = 'Bearer %s' % self.access_token + + default_port = 443 if self.secure else 80 + + url = \ + '{schema}://{hostname}{port}'.format( + schema='https' if self.secure else 'http', + hostname=self.host, + port='' if self.port is None + or self.port == default_port else f':{self.port}') + + if path == '/upload': + if self.version == MatrixVersion.V3: + url += MATRIX_V3_MEDIA_PATH + path + + else: + url += MATRIX_V2_MEDIA_PATH + path + + params = {'filename': attachment.name} + with open(attachment.path, 'rb') as fp: + payload = fp.read() + + # Update our content type + headers['Content-Type'] = attachment.mimetype + + else: + if self.version == MatrixVersion.V3: + url += MATRIX_V3_API_PATH + path + + else: + url += MATRIX_V2_API_PATH + path + + # Our response object + response = {} + + # fetch function + fn = requests.post if method == 'POST' else ( + requests.put if method == 'PUT' else requests.get) + + # Define how many attempts we'll make if we get caught in a throttle + # event + retries = self.default_retries if self.default_retries > 0 else 1 + while retries > 0: + + # Decrement our throttle retry count + retries -= 1 + + self.logger.debug('Matrix POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('Matrix Payload: %s' % str(payload)) + + # Initialize our response object + r = None + + try: + r = fn( + url, + data=dumps(payload) if not attachment else payload, + params=params, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + self.logger.debug( + 'Matrix Response: code=%d, %s' % ( + r.status_code, str(r.content))) + response = loads(r.content) + + if r.status_code == 429: + wait = self.default_wait_ms / 1000 + try: + wait = response['retry_after_ms'] / 1000 + + except KeyError: + try: + errordata = response['error'] + wait = errordata['retry_after_ms'] / 1000 + except KeyError: + pass + + self.logger.warning( + 'Matrix server requested we throttle back {}ms; ' + 'retries left {}.'.format(wait, retries)) + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Throttle for specified wait + self.throttle(wait=wait) + + # Try again + continue + + elif r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyMatrix.http_response_code_lookup( + r.status_code, MATRIX_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to handshake with Matrix server: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return (False, response) + + except (AttributeError, TypeError, ValueError): + # This gets thrown if we can't parse our JSON Response + # - ValueError = r.content is Unparsable + # - TypeError = r.content is None + # - AttributeError = r is None + self.logger.warning('Invalid response from Matrix server.') + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + return (False, {}) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred while registering with Matrix' + ' server.') + self.logger.debug('Socket Exception: %s' % str(e)) + # Return; we're done + return (False, response) + + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while reading {}.'.format( + attachment.name if attachment else 'unknown file')) + self.logger.debug('I/O Exception: %s' % str(e)) + return (False, {}) + + return (True, response) + + # If we get here, we ran out of retries + return (False, {}) + + def __del__(self): + """ + Ensure we relinquish our token + """ + if self.mode == MatrixWebhookMode.T2BOT: + # nothing to do + return + + try: + self._logout() + + except LookupError: # pragma: no cover + # Python v3.5 call to requests can sometimes throw the exception + # "/usr/lib64/python3.7/socket.py", line 748, in getaddrinfo + # LookupError: unknown encoding: idna + # + # This occurs every time when running unit-tests against Apprise: + # LANG=C.UTF-8 PYTHONPATH=$(pwd) py.test-3.7 + # + # There has been an open issue on this since Jan 2017. + # - https://bugs.python.org/issue29288 + # + # A ~similar~ issue can be identified here in the requests + # ticket system as unresolved and has provided workarounds + # - https://github.com/kennethreitz/requests/issues/3578 + pass + + except ImportError: # pragma: no cover + # The actual exception is `ModuleNotFoundError` however ImportError + # grants us backwards compatibility with versions of Python older + # than v3.6 + + # Python code that makes early calls to sys.exit() can cause + # the __del__() code to run. However, in some newer versions of + # Python, this causes the `sys` library to no longer be + # available. The stack overflow also goes on to suggest that + # it's not wise to use the __del__() as a destructor + # which is the case here. + + # https://stackoverflow.com/questions/67218341/\ + # modulenotfounderror-import-of-time-halted-none-in-sys-\ + # modules-occured-when-obj?noredirect=1&lq=1 + # + # + # Also see: https://stackoverflow.com/questions\ + # /1481488/what-is-the-del-method-and-how-do-i-call-it + + # At this time it seems clean to try to log out (if we can) + # but not throw any unnecessary exceptions (like this one) to + # the end user if we don't have to. + pass + + 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', + 'mode': self.mode, + 'version': self.version, + 'msgtype': self.msgtype, + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + auth = '' + if self.mode != MatrixWebhookMode.T2BOT: + # Determine Authentication + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=NotifyMatrix.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, + safe=''), + ) + + elif self.user: + auth = '{user}@'.format( + user=NotifyMatrix.quote(self.user, safe=''), + ) + + default_port = 443 if self.secure else 80 + + return '{schema}://{auth}{hostname}{port}/{rooms}?{params}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + auth=auth, + hostname=NotifyMatrix.quote(self.host, safe='') + if self.mode != MatrixWebhookMode.T2BOT + else self.pprint(self.access_token, privacy, safe=''), + port='' if self.port is None + or self.port == default_port else ':{}'.format(self.port), + rooms=NotifyMatrix.quote('/'.join(self.rooms)), + params=NotifyMatrix.urlencode(params), + ) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.rooms) + return targets if targets > 0 else 1 + + @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 + + if not results.get('host'): + return None + + # Get our rooms + results['targets'] = NotifyMatrix.split_path(results['fullpath']) + + # 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'] += NotifyMatrix.parse_list(results['qsd']['to']) + + # Boolean to include an image or not + results['include_image'] = parse_bool(results['qsd'].get( + 'image', NotifyMatrix.template_args['image']['default'])) + + # Get our mode + results['mode'] = results['qsd'].get('mode') + + # t2bot detection... look for just a hostname, and/or just a user/host + # if we match this; we can go ahead and set the mode (but only if + # it was otherwise not set) + if results['mode'] is None \ + and not results['password'] \ + and not results['targets']: + + # Default mode to t2bot + results['mode'] = MatrixWebhookMode.T2BOT + + if results['mode'] and \ + results['mode'].lower() == MatrixWebhookMode.T2BOT: + # unquote our hostname and pass it in as the password/token + results['password'] = NotifyMatrix.unquote(results['host']) + + # Support the message type keyword + if 'msgtype' in results['qsd'] and len(results['qsd']['msgtype']): + results['msgtype'] = \ + NotifyMatrix.unquote(results['qsd']['msgtype']) + + # Support the use of the token= keyword + if 'token' in results['qsd'] and len(results['qsd']['token']): + results['password'] = NotifyMatrix.unquote(results['qsd']['token']) + + # Support the use of the version= or v= keyword + if 'version' in results['qsd'] and len(results['qsd']['version']): + results['version'] = \ + NotifyMatrix.unquote(results['qsd']['version']) + + elif 'v' in results['qsd'] and len(results['qsd']['v']): + results['version'] = NotifyMatrix.unquote(results['qsd']['v']) + + return results + + @staticmethod + def parse_native_url(url): + """ + Support https://webhooks.t2bot.io/api/v1/matrix/hook/WEBHOOK_TOKEN/ + """ + + result = re.match( + r'^https?://webhooks\.t2bot\.io/api/v[0-9]+/matrix/hook/' + r'(?P[A-Z0-9_-]+)/?' + r'(?P\?.+)?$', url, re.I) + + if result: + mode = 'mode={}'.format(MatrixWebhookMode.T2BOT) + + return NotifyMatrix.parse_url( + '{schema}://{webhook_token}/{params}'.format( + schema=NotifyMatrix.secure_protocol, + webhook_token=result.group('webhook_token'), + params='?{}'.format(mode) if not result.group('params') + else '{}&{}'.format(result.group('params'), mode))) + + return None diff --git a/lib/apprise/plugins/NotifyMattermost.py b/lib/apprise/plugins/NotifyMattermost.py new file mode 100644 index 0000000..859fed3 --- /dev/null +++ b/lib/apprise/plugins/NotifyMattermost.py @@ -0,0 +1,372 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# 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 diff --git a/lib/apprise/plugins/NotifyMessageBird.py b/lib/apprise/plugins/NotifyMessageBird.py new file mode 100644 index 0000000..4cb9d7b --- /dev/null +++ b/lib/apprise/plugins/NotifyMessageBird.py @@ -0,0 +1,356 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# Create an account https://messagebird.com if you don't already have one +# +# Get your (apikey) and api example from the dashboard here: +# - https://dashboard.messagebird.com/en/user/index +# + +import requests + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import is_phone_no +from ..utils import parse_phone_no +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +class NotifyMessageBird(NotifyBase): + """ + A wrapper for MessageBird Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'MessageBird' + + # The services URL + service_url = 'https://messagebird.com' + + # The default protocol + secure_protocol = 'msgbird' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_messagebird' + + # MessageBird uses the http protocol with JSON requests + notify_url = 'https://rest.messagebird.com/messages' + + # The maximum length of the body + body_maxlen = 160 + + # A title can not be used for SMS Messages. Setting this to zero will + # cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + + # Define object templates + templates = ( + '{schema}://{apikey}/{source}', + '{schema}://{apikey}/{source}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'required': True, + 'private': True, + 'regex': (r'^[a-z0-9]{25}$', 'i'), + }, + 'source': { + 'name': _('Source Phone No'), + 'type': 'string', + 'prefix': '+', + 'required': True, + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + }, + 'target_phone': { + 'name': _('Target Phone No'), + 'type': 'string', + 'prefix': '+', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + } + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'from': { + 'alias_of': 'source', + }, + }) + + def __init__(self, apikey, source, targets=None, **kwargs): + """ + Initialize MessageBird Object + """ + super().__init__(**kwargs) + + # API Key (associated with project) + self.apikey = validate_regex( + apikey, *self.template_tokens['apikey']['regex']) + if not self.apikey: + msg = 'An invalid MessageBird API Key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + result = is_phone_no(source) + if not result: + msg = 'The MessageBird source specified ({}) is invalid.'\ + .format(source) + self.logger.warning(msg) + raise TypeError(msg) + + # Store our source + self.source = result['full'] + + # Parse our targets + self.targets = list() + + targets = parse_phone_no(targets) + if not targets: + # No sources specified, use our own phone no + self.targets.append(self.source) + return + + # otherwise, store all of our target numbers + for target in targets: + # Validate targets and drop bad ones: + result = is_phone_no(target) + if not result: + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) + continue + + # store valid phone number + self.targets.append(result['full']) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform MessageBird Notification + """ + + if len(self.targets) == 0: + # There were no services to notify + self.logger.warning('There were no MessageBird targets to notify.') + return False + + # error tracking (used for function return) + has_error = False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': 'AccessKey {}'.format(self.apikey), + } + + # Prepare our payload + payload = { + 'originator': '+{}'.format(self.source), + 'recipients': None, + 'body': body, + + } + + # Create a copy of the targets list + targets = list(self.targets) + + while len(targets): + # Get our target to notify + target = targets.pop(0) + + # Prepare our user + payload['recipients'] = '+{}'.format(target) + + # Some Debug Logging + self.logger.debug( + 'MessageBird POST URL: {} (cert_verify={})'.format( + self.notify_url, self.verify_certificate)) + self.logger.debug('MessageBird Payload: {}' .format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + self.notify_url, + data=payload, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + # Sample output of a successful transmission + # { + # "originator": "+15553338888", + # "body": "test", + # "direction": "mt", + # "mclass": 1, + # "reference": null, + # "createdDatetime": "2019-08-22T01:32:18+00:00", + # "recipients": { + # "totalCount": 1, + # "totalSentCount": 1, + # "totalDeliveredCount": 0, + # "totalDeliveryFailedCount": 0, + # "items": [ + # { + # "status": "sent", + # "statusDatetime": "2019-08-22T01:32:18+00:00", + # "recipient": 15553338888, + # "messagePartCount": 1 + # } + # ] + # }, + # "validity": null, + # "gateway": 10, + # "typeDetails": {}, + # "href": "https://rest.messagebird.com/messages/\ + # b5d424244a5b4fd0b5b5728bccaafc23", + # "datacoding": "plain", + # "scheduledDatetime": null, + # "type": "sms", + # "id": "b5d424244a5b4fd0b5b5728bccaafc23" + # } + + if r.status_code not in ( + requests.codes.ok, requests.codes.created): + # We had a problem + status_str = \ + NotifyMessageBird.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send MessageBird notification to {}: ' + '{}{}error={}.'.format( + ','.join(target), + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent MessageBird notification to {}.'.format(target)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending MessageBird:%s ' % ( + target) + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + return not has_error + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Our URL parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + return '{schema}://{apikey}/{source}/{targets}/?{params}'.format( + schema=self.secure_protocol, + apikey=self.pprint(self.apikey, privacy, safe=''), + source=self.source, + targets='/'.join( + [NotifyMessageBird.quote(x, safe='') for x in self.targets]), + params=NotifyMessageBird.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets > 0 else 1 + + @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 + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'] = NotifyMessageBird.split_path(results['fullpath']) + + try: + # The first path entry is the source/originator + results['source'] = results['targets'].pop(0) + + except IndexError: + # No path specified... this URL is potentially un-parseable; we can + # hope for a from= entry + results['source'] = None + + # The hostname is our authentication key + results['apikey'] = NotifyMessageBird.unquote(results['host']) + + # Support the 'to' variable so that we can support targets this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyMessageBird.parse_phone_no(results['qsd']['to']) + + if 'from' in results['qsd'] and len(results['qsd']['from']): + results['source'] = \ + NotifyMessageBird.unquote(results['qsd']['from']) + + return results diff --git a/lib/apprise/plugins/NotifyMisskey.py b/lib/apprise/plugins/NotifyMisskey.py new file mode 100644 index 0000000..57633a5 --- /dev/null +++ b/lib/apprise/plugins/NotifyMisskey.py @@ -0,0 +1,307 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# 1. visit https://misskey-hub.net/ and see what it's all about if you want. +# Choose a service you want to create an account on from here: +# https://misskey-hub.net/en/instances.html +# +# - For this plugin, I tested using https://misskey.sda1.net and created an +# account. +# +# 2. Generate an API Key: +# - Settings > API > Generate Key +# - Name it whatever you want +# - Assign it 'AT LEAST': +# a. Compose or delete chat messages +# b. Compose or delete notes +# +# +# This plugin also supports taking the URL (as identified above) directly +# as well. + +import requests +from json import dumps + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +class MisskeyVisibility: + """ + The visibility of any note created + """ + # post will be public + PUBLIC = 'public' + + HOME = 'home' + + FOLLOWERS = 'followers' + + PRIVATE = 'private' + + SPECIFIED = 'specified' + + +# Define the types in a list for validation purposes +MISSKEY_VISIBILITIES = ( + MisskeyVisibility.PUBLIC, + MisskeyVisibility.HOME, + MisskeyVisibility.FOLLOWERS, + MisskeyVisibility.PRIVATE, + MisskeyVisibility.SPECIFIED, +) + + +class NotifyMisskey(NotifyBase): + """ + A wrapper for Misskey Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Misskey' + + # The services URL + service_url = 'https://misskey-hub.net/' + + # The default protocol + protocol = 'misskey' + + # The default secure protocol + secure_protocol = 'misskeys' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_misskey' + + # The title is not used + title_maxlen = 0 + + # The maximum allowable characters allowed in the body per message + body_maxlen = 512 + + # Define object templates + templates = ( + '{schema}://{project_id}/{msghook}', + ) + + # Define object templates + templates = ( + '{schema}://{token}@{host}', + '{schema}://{token}@{host}:{port}', + ) + + # Define our template arguments + # Define our template arguments + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'token': { + 'name': _('Access Token'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'token': { + 'alias_of': 'token', + }, + 'visibility': { + 'name': _('Visibility'), + 'type': 'choice:string', + 'values': MISSKEY_VISIBILITIES, + 'default': MisskeyVisibility.PUBLIC, + }, + }) + + def __init__(self, token=None, visibility=None, **kwargs): + """ + Initialize Misskey Object + """ + super().__init__(**kwargs) + + self.token = validate_regex(token) + if not self.token: + msg = 'An invalid Misskey Access Token was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + if visibility: + # Input is a string; attempt to get the lookup from our + # sound mapping + vis = 'invalid' if not isinstance(visibility, str) \ + else visibility.lower().strip() + + # This little bit of black magic allows us to match against + # against multiple versions of the same string ... etc + self.visibility = \ + next((v for v in MISSKEY_VISIBILITIES + if v.startswith(vis)), None) + + if self.visibility not in MISSKEY_VISIBILITIES: + msg = 'The Misskey visibility specified ({}) is invalid.' \ + .format(visibility) + self.logger.warning(msg) + raise TypeError(msg) + else: + self.visibility = self.template_args['visibility']['default'] + + # Prepare our URL + self.schema = 'https' if self.secure else 'http' + self.api_url = '%s://%s' % (self.schema, self.host) + + if isinstance(self.port, int): + self.api_url += ':%d' % self.port + + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + params = { + 'visibility': self.visibility, + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + host = self.host + if isinstance(self.port, int): + host += ':%d' % self.port + + return '{schema}://{token}@{host}/?{params}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + host=host, + token=self.pprint(self.token, privacy, safe=''), + params=NotifyMisskey.urlencode(params), + ) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + wrapper to _send since we can alert more then one channel + """ + + # prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + } + + # Prepare our payload + payload = { + 'i': self.token, + 'text': body, + 'visibility': self.visibility, + } + + api_url = f'{self.api_url}/api/notes/create' + self.logger.debug('Misskey GET URL: %s (cert_verify=%r)' % ( + api_url, self.verify_certificate)) + self.logger.debug('Misskey Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + api_url, + headers=headers, + data=dumps(payload), + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyMisskey.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send Misskey notification: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info('Sent Misskey notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Misskey ' + 'notification.') + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + @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 + + if 'token' in results['qsd'] and len(results['qsd']['token']): + results['token'] = NotifyMisskey.unquote(results['qsd']['token']) + + elif not results['password'] and results['user']: + results['token'] = NotifyMisskey.unquote(results['user']) + + # Capture visibility if specified + if 'visibility' in results['qsd'] and \ + len(results['qsd']['visibility']): + results['visibility'] = \ + NotifyMisskey.unquote(results['qsd']['visibility']) + + return results diff --git a/lib/apprise/plugins/NotifyNextcloud.py b/lib/apprise/plugins/NotifyNextcloud.py new file mode 100644 index 0000000..b1d623d --- /dev/null +++ b/lib/apprise/plugins/NotifyNextcloud.py @@ -0,0 +1,374 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..utils import parse_list +from ..AppriseLocale import gettext_lazy as _ + + +class NotifyNextcloud(NotifyBase): + """ + A wrapper for Nextcloud Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Nextcloud' + + # The services URL + service_url = 'https://nextcloud.com/' + + # Insecure protocol (for those self hosted requests) + protocol = 'ncloud' + + # The default protocol (this is secure for notica) + secure_protocol = 'nclouds' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_nextcloud' + + # Nextcloud title length + title_maxlen = 255 + + # Defines the maximum allowable characters per message. + body_maxlen = 4000 + + # Define object templates + templates = ( + '{schema}://{host}/{targets}', + '{schema}://{host}:{port}/{targets}', + '{schema}://{user}:{password}@{host}/{targets}', + '{schema}://{user}:{password}@{host}:{port}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'user': { + 'name': _('Username'), + 'type': 'string', + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + }, + 'target_user': { + 'name': _('Target User'), + 'type': 'string', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + # Nextcloud uses different API end points depending on the version + # being used however the (API) payload remains the same. Allow users + # to specify the version they are using: + 'version': { + 'name': _('Version'), + 'type': 'int', + 'min': 1, + 'default': 21, + }, + 'url_prefix': { + 'name': _('URL Prefix'), + 'type': 'string', + }, + 'to': { + 'alias_of': 'targets', + }, + }) + + # Define any kwargs we're using + template_kwargs = { + 'headers': { + 'name': _('HTTP Header'), + 'prefix': '+', + }, + } + + def __init__(self, targets=None, version=None, headers=None, + url_prefix=None, **kwargs): + """ + Initialize Nextcloud Object + """ + super().__init__(**kwargs) + + # Store our targets + self.targets = parse_list(targets) + + self.version = self.template_args['version']['default'] + if version is not None: + try: + self.version = int(version) + if self.version < self.template_args['version']['min']: + # Let upper exception handle this + raise ValueError() + + except (ValueError, TypeError): + msg = 'At invalid Nextcloud version ({}) was specified.'\ + .format(version) + self.logger.warning(msg) + raise TypeError(msg) + + # Support URL Prefix + self.url_prefix = '' if not url_prefix \ + else url_prefix.strip('/') + + self.headers = {} + if headers: + # Store our extra headers + self.headers.update(headers) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Nextcloud Notification + """ + + if len(self.targets) == 0: + # There were no services to notify + self.logger.warning('There were no Nextcloud targets to notify.') + return False + + # Prepare our Header + headers = { + 'User-Agent': self.app_id, + 'OCS-APIREQUEST': 'true', + } + + # Apply any/all header over-rides defined + headers.update(self.headers) + + # error tracking (used for function return) + has_error = False + + # Create a copy of the targets list + targets = list(self.targets) + while len(targets): + target = targets.pop(0) + + # Prepare our Payload + payload = { + 'shortMessage': title if title else self.app_desc, + } + if body: + # Only store the longMessage if a body was defined; nextcloud + # doesn't take kindly to empty longMessage entries. + payload['longMessage'] = body + + auth = None + if self.user: + auth = (self.user, self.password) + + # Nextcloud URL based on version used + notify_url = '{schema}://{host}/{url_prefix}/ocs/v2.php/'\ + 'apps/admin_notifications/' \ + 'api/v1/notifications/{target}' \ + if self.version < 21 else \ + '{schema}://{host}/{url_prefix}/ocs/v2.php/'\ + 'apps/notifications/'\ + 'api/v2/admin_notifications/{target}' + + notify_url = notify_url.format( + schema='https' if self.secure else 'http', + host=self.host if not isinstance(self.port, int) + else '{}:{}'.format(self.host, self.port), + url_prefix=self.url_prefix, + target=target, + ) + + self.logger.debug( + 'Nextcloud v%d POST URL: %s (cert_verify=%r)', + self.version, notify_url, self.verify_certificate) + self.logger.debug( + 'Nextcloud v%d Payload: %s', + self.version, str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + notify_url, + data=payload, + headers=headers, + auth=auth, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyNextcloud.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send Nextcloud v{} notification:' + '{}{}error={}.'.format( + self.version, + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + # track our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent Nextcloud %d notification.', self.version) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Nextcloud v%d' + 'notification.', self.version) + self.logger.debug('Socket Exception: %s' % str(e)) + + # track our failure + has_error = True + continue + + return not has_error + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Create URL parameters from our headers + params = {'+{}'.format(k): v for k, v in self.headers.items()} + + # Set our version + params['version'] = str(self.version) + + if self.url_prefix: + params['url_prefix'] = self.url_prefix + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=NotifyNextcloud.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=NotifyNextcloud.quote(self.user, safe=''), + ) + + default_port = 443 if self.secure else 80 + + return '{schema}://{auth}{hostname}{port}/{targets}?{params}' \ + .format( + schema=self.secure_protocol + if self.secure else self.protocol, + auth=auth, + # never encode hostname since we're expecting it to be a + # valid one + hostname=self.host, + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + targets='/'.join([NotifyNextcloud.quote(x) + for x in self.targets]), + params=NotifyNextcloud.urlencode(params), + ) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets else 1 + + @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 + + # Fetch our targets + results['targets'] = \ + NotifyNextcloud.split_path(results['fullpath']) + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyNextcloud.parse_list(results['qsd']['to']) + + # Allow users to over-ride the Nextcloud version being used + if 'version' in results['qsd'] and len(results['qsd']['version']): + results['version'] = \ + NotifyNextcloud.unquote(results['qsd']['version']) + + # Support URL Prefixes + if 'url_prefix' in results['qsd'] \ + and len(results['qsd']['url_prefix']): + results['url_prefix'] = \ + NotifyNextcloud.unquote(results['qsd']['url_prefix']) + + # Add our headers that the user can potentially over-ride if they wish + # to to our returned result set and tidy entries by unquoting them + results['headers'] = { + NotifyNextcloud.unquote(x): NotifyNextcloud.unquote(y) + for x, y in results['qsd+'].items()} + + return results diff --git a/lib/apprise/plugins/NotifyNextcloudTalk.py b/lib/apprise/plugins/NotifyNextcloudTalk.py new file mode 100644 index 0000000..4f6dc05 --- /dev/null +++ b/lib/apprise/plugins/NotifyNextcloudTalk.py @@ -0,0 +1,329 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 json import dumps +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..utils import parse_list +from ..AppriseLocale import gettext_lazy as _ + + +class NotifyNextcloudTalk(NotifyBase): + """ + A wrapper for Nextcloud Talk Notifications + """ + + # The default descriptive name associated with the Notification + service_name = _('Nextcloud Talk') + + # The services URL + service_url = 'https://nextcloud.com/talk' + + # Insecure protocol (for those self hosted requests) + protocol = 'nctalk' + + # The default protocol (this is secure for notica) + secure_protocol = 'nctalks' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_nextcloudtalk' + + # Nextcloud title length + title_maxlen = 255 + + # Defines the maximum allowable characters per message. + body_maxlen = 4000 + + # Define object templates + templates = ( + '{schema}://{user}:{password}@{host}/{targets}', + '{schema}://{user}:{password}@{host}:{port}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'user': { + 'name': _('Username'), + 'type': 'string', + 'required': True, + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'target_room_id': { + 'name': _('Room ID'), + 'type': 'string', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'url_prefix': { + 'name': _('URL Prefix'), + 'type': 'string', + }, + }) + + # Define any kwargs we're using + template_kwargs = { + 'headers': { + 'name': _('HTTP Header'), + 'prefix': '+', + }, + } + + def __init__(self, targets=None, headers=None, url_prefix=None, **kwargs): + """ + Initialize Nextcloud Talk Object + """ + super().__init__(**kwargs) + + if self.user is None or self.password is None: + msg = 'User and password have to be specified.' + self.logger.warning(msg) + raise TypeError(msg) + + # Store our targets + self.targets = parse_list(targets) + + # Support URL Prefix + self.url_prefix = '' if not url_prefix \ + else url_prefix.strip('/') + + self.headers = {} + if headers: + # Store our extra headers + self.headers.update(headers) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Nextcloud Talk Notification + """ + + if len(self.targets) == 0: + # There were no services to notify + self.logger.warning( + 'There were no Nextcloud Talk targets to notify.') + return False + + # Prepare our Header + headers = { + 'User-Agent': self.app_id, + 'OCS-APIRequest': 'true', + 'Accept': 'application/json', + 'Content-Type': 'application/json', + } + + # Apply any/all header over-rides defined + headers.update(self.headers) + + # error tracking (used for function return) + has_error = False + + # Create a copy of the targets list + targets = list(self.targets) + while len(targets): + target = targets.pop(0) + + # Prepare our Payload + if not body: + payload = { + 'message': title if title else self.app_desc, + } + else: + payload = { + 'message': title + '\r\n' + body + if title else self.app_desc + '\r\n' + body, + } + + # Nextcloud Talk URL + notify_url = '{schema}://{host}/{url_prefix}'\ + '/ocs/v2.php/apps/spreed/api/v1/chat/{target}' + + notify_url = notify_url.format( + schema='https' if self.secure else 'http', + host=self.host if not isinstance(self.port, int) + else '{}:{}'.format(self.host, self.port), + url_prefix=self.url_prefix, + target=target, + ) + + self.logger.debug( + 'Nextcloud Talk POST URL: %s (cert_verify=%r)', + notify_url, self.verify_certificate) + self.logger.debug( + 'Nextcloud Talk Payload: %s', + str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + notify_url, + data=dumps(payload), + headers=headers, + auth=(self.user, self.password), + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code not in ( + requests.codes.created, requests.codes.ok): + # We had a problem + status_str = \ + NotifyNextcloudTalk.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send Nextcloud Talk notification:' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + # track our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent Nextcloud Talk notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Nextcloud Talk ' + 'notification.') + self.logger.debug('Socket Exception: %s' % str(e)) + + # track our failure + has_error = True + continue + + return not has_error + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Our default set of parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + # Append our headers into our parameters + params.update({'+{}'.format(k): v for k, v in self.headers.items()}) + if self.url_prefix: + params['url_prefix'] = self.url_prefix + + # Determine Authentication + auth = '{user}:{password}@'.format( + user=NotifyNextcloudTalk.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + + default_port = 443 if self.secure else 80 + + return '{schema}://{auth}{hostname}{port}/{targets}?{params}' \ + .format( + schema=self.secure_protocol + if self.secure else self.protocol, + auth=auth, + # never encode hostname since we're expecting it to be a + # valid one + hostname=self.host, + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + targets='/'.join([NotifyNextcloudTalk.quote(x) + for x in self.targets]), + params=NotifyNextcloudTalk.urlencode(params), + ) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets else 1 + + @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 + + # Fetch our targets + results['targets'] = \ + NotifyNextcloudTalk.split_path(results['fullpath']) + + # Support URL Prefixes + if 'url_prefix' in results['qsd'] \ + and len(results['qsd']['url_prefix']): + results['url_prefix'] = \ + NotifyNextcloudTalk.unquote(results['qsd']['url_prefix']) + + # Add our headers that the user can potentially over-ride if they wish + # to to our returned result set and tidy entries by unquoting them + results['headers'] = { + NotifyNextcloudTalk.unquote(x): NotifyNextcloudTalk.unquote(y) + for x, y in results['qsd+'].items()} + + return results diff --git a/lib/apprise/plugins/NotifyNotica.py b/lib/apprise/plugins/NotifyNotica.py new file mode 100644 index 0000000..f95baba --- /dev/null +++ b/lib/apprise/plugins/NotifyNotica.py @@ -0,0 +1,396 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# 1. Simply visit https://notica.us +# 2. You'll be provided a new variation of the website which will look +# something like: https://notica.us/?abc123. +# ^ +# | +# token +# +# Your token is actually abc123 (do not include/grab the question mark) +# You can use that URL as is directly in Apprise, or you can follow +# the next step which shows you how to assemble the Apprise URL: +# +# 3. With respect to the above, your apprise URL would be: +# notica://abc123 +# +import re +import requests + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +class NoticaMode: + """ + Tracks if we're accessing the notica upstream server or a locally hosted + one. + """ + # We're dealing with a self hosted service + SELFHOSTED = 'selfhosted' + + # We're dealing with the official hosted service at https://notica.us + OFFICIAL = 'official' + + +# Define our Notica Modes +NOTICA_MODES = ( + NoticaMode.SELFHOSTED, + NoticaMode.OFFICIAL, +) + + +class NotifyNotica(NotifyBase): + """ + A wrapper for Notica Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Notica' + + # The services URL + service_url = 'https://notica.us/' + + # Insecure protocol (for those self hosted requests) + protocol = 'notica' + + # The default protocol (this is secure for notica) + secure_protocol = 'noticas' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_notica' + + # Notica URL + notify_url = 'https://notica.us/?{token}' + + # Notica does not support a title + title_maxlen = 0 + + # Define object templates + templates = ( + '{schema}://{token}', + + # Self-hosted notica servers + '{schema}://{host}/{token}', + '{schema}://{host}:{port}/{token}', + '{schema}://{user}@{host}/{token}', + '{schema}://{user}@{host}:{port}/{token}', + '{schema}://{user}:{password}@{host}/{token}', + '{schema}://{user}:{password}@{host}:{port}/{token}', + + # Self-hosted notica servers (with custom path) + '{schema}://{host}{path}/{token}', + '{schema}://{host}:{port}/{path}/{token}', + '{schema}://{user}@{host}/{path}/{token}', + '{schema}://{user}@{host}:{port}{path}/{token}', + '{schema}://{user}:{password}@{host}{path}/{token}', + '{schema}://{user}:{password}@{host}:{port}/{path}/{token}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'token': { + 'name': _('Token'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': r'^\?*(?P[^/]+)\s*$' + }, + 'host': { + 'name': _('Hostname'), + 'type': 'string', + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'user': { + 'name': _('Username'), + 'type': 'string', + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + }, + 'path': { + 'name': _('Path'), + 'type': 'string', + 'map_to': 'fullpath', + 'default': '/', + }, + }) + + # Define any kwargs we're using + template_kwargs = { + 'headers': { + 'name': _('HTTP Header'), + 'prefix': '+', + }, + } + + def __init__(self, token, headers=None, **kwargs): + """ + Initialize Notica Object + """ + super().__init__(**kwargs) + + # Token (associated with project) + self.token = validate_regex(token) + if not self.token: + msg = 'An invalid Notica Token ' \ + '({}) was specified.'.format(token) + self.logger.warning(msg) + raise TypeError(msg) + + # Setup our mode + self.mode = NoticaMode.SELFHOSTED if self.host else NoticaMode.OFFICIAL + + # prepare our fullpath + self.fullpath = kwargs.get('fullpath') + if not isinstance(self.fullpath, str): + self.fullpath = '/' + + self.headers = {} + if headers: + # Store our extra headers + self.headers.update(headers) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Notica Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/x-www-form-urlencoded' + } + + # Prepare our payload + payload = 'd:{}'.format(body) + + # Auth is used for SELFHOSTED queries + auth = None + + if self.mode is NoticaMode.OFFICIAL: + # prepare our notify url + notify_url = self.notify_url.format(token=self.token) + + else: + # Prepare our self hosted URL + + # Apply any/all header over-rides defined + headers.update(self.headers) + + if self.user: + auth = (self.user, self.password) + + # Set our schema + schema = 'https' if self.secure else 'http' + + # Prepare our notify_url + notify_url = '%s://%s' % (schema, self.host) + if isinstance(self.port, int): + notify_url += ':%d' % self.port + + notify_url += '{fullpath}?token={token}'.format( + fullpath=self.fullpath, + token=self.token) + + self.logger.debug('Notica POST URL: %s (cert_verify=%r)' % ( + notify_url, self.verify_certificate, + )) + self.logger.debug('Notica Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + notify_url.format(token=self.token), + data=payload, + headers=headers, + auth=auth, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyNotica.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send Notica notification:' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info('Sent Notica notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Notica notification.', + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Our URL parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + if self.mode == NoticaMode.OFFICIAL: + # Official URLs are easy to assemble + return '{schema}://{token}/?{params}'.format( + schema=self.protocol, + token=self.pprint(self.token, privacy, safe=''), + params=NotifyNotica.urlencode(params), + ) + + # If we reach here then we are assembling a self hosted URL + + # Append URL parameters from our headers + params.update({'+{}'.format(k): v for k, v in self.headers.items()}) + + # Authorization can be used for self-hosted sollutions + auth = '' + + # Determine Authentication + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=NotifyNotica.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=NotifyNotica.quote(self.user, safe=''), + ) + + default_port = 443 if self.secure else 80 + + return '{schema}://{auth}{hostname}{port}{fullpath}{token}/?{params}' \ + .format( + schema=self.secure_protocol + if self.secure else self.protocol, + auth=auth, + hostname=NotifyNotica.quote(self.host, safe=''), + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + fullpath=NotifyNotica.quote( + self.fullpath, safe='/'), + token=self.pprint(self.token, privacy, safe=''), + params=NotifyNotica.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, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + # Get unquoted entries + entries = NotifyNotica.split_path(results['fullpath']) + if not entries: + # If there are no path entries, then we're only dealing with the + # official website + results['mode'] = NoticaMode.OFFICIAL + + # Store our token using the host + results['token'] = NotifyNotica.unquote(results['host']) + + # Unset our host + results['host'] = None + + else: + # Otherwise we're running a self hosted instance + results['mode'] = NoticaMode.SELFHOSTED + + # The last element in the list is our token + results['token'] = entries.pop() + + # Re-assemble our full path + results['fullpath'] = \ + '/' if not entries else '/{}/'.format('/'.join(entries)) + + # Add our headers that the user can potentially over-ride if they + # wish to to our returned result set and tidy entries by unquoting + # them + results['headers'] = { + NotifyNotica.unquote(x): NotifyNotica.unquote(y) + for x, y in results['qsd+'].items()} + + return results + + @staticmethod + def parse_native_url(url): + """ + Support https://notica.us/?abc123 + """ + + result = re.match( + r'^https?://notica\.us/?' + r'\??(?P[^&]+)([&\s]*(?P.+))?$', url, re.I) + + if result: + return NotifyNotica.parse_url( + '{schema}://{token}/{params}'.format( + schema=NotifyNotica.protocol, + token=result.group('token'), + params='' if not result.group('params') + else '?{}'.format(result.group('params')))) + + return None diff --git a/lib/apprise/plugins/NotifyNotifiarr.py b/lib/apprise/plugins/NotifyNotifiarr.py new file mode 100644 index 0000000..748e3b7 --- /dev/null +++ b/lib/apprise/plugins/NotifyNotifiarr.py @@ -0,0 +1,472 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 re +import requests +from json import dumps +from itertools import chain + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..AppriseLocale import gettext_lazy as _ +from ..common import NotifyImageSize +from ..utils import parse_list, parse_bool +from ..utils import validate_regex + +# Used to break path apart into list of channels +CHANNEL_LIST_DELIM = re.compile(r'[ \t\r\n,#\\/]+') + +CHANNEL_REGEX = re.compile( + r'^\s*(\#|\%35)?(?P[0-9]+)', re.I) + +# For API Details see: +# https://notifiarr.wiki/Client/Installation + +# Another good example: +# https://notifiarr.wiki/en/Website/ \ +# Integrations/Passthrough#payload-example-1 + + +class NotifyNotifiarr(NotifyBase): + """ + A wrapper for Notifiarr Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Notifiarr' + + # The services URL + service_url = 'https://notifiarr.com/' + + # The default secure protocol + secure_protocol = 'notifiarr' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_notifiarr' + + # The Notification URL + notify_url = 'https://notifiarr.com/api/v1/notification/apprise' + + # Notifiarr Throttling (knowing in advance reduces 429 responses) + # define('NOTIFICATION_LIMIT_SECOND_USER', 5); + # define('NOTIFICATION_LIMIT_SECOND_PATRON', 15); + + # Throttle requests ever so slightly + request_rate_per_sec = 0.04 + + # Allows the user to specify the NotifyImageSize object + image_size = NotifyImageSize.XY_256 + + # Define object templates + templates = ( + '{schema}://{apikey}/{targets}', + ) + + # Define our apikeys; these are the minimum apikeys required required to + # be passed into this function (as arguments). The syntax appends any + # previously defined in the base package and builds onto them + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('Token'), + 'type': 'string', + 'required': True, + 'private': True, + }, + 'target_channel': { + 'name': _('Target Channel'), + 'type': 'string', + 'prefix': '#', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'key': { + 'alias_of': 'apikey', + }, + 'apikey': { + 'alias_of': 'apikey', + }, + 'discord_user': { + 'name': _('Ping Discord User'), + 'type': 'int', + }, + 'discord_role': { + 'name': _('Ping Discord Role'), + 'type': 'int', + }, + 'event': { + 'name': _('Discord Event ID'), + 'type': 'int', + }, + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': False, + 'map_to': 'include_image', + }, + 'source': { + 'name': _('Source'), + 'type': 'string', + }, + 'from': { + 'alias_of': 'source' + }, + 'to': { + 'alias_of': 'targets', + }, + }) + + def __init__(self, apikey=None, include_image=None, + discord_user=None, discord_role=None, + event=None, targets=None, source=None, **kwargs): + """ + Initialize Notifiarr Object + + headers can be a dictionary of key/value pairs that you want to + additionally include as part of the server headers to post with + + """ + super().__init__(**kwargs) + + self.apikey = apikey + if not self.apikey: + msg = 'An invalid Notifiarr APIKey ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # Place a thumbnail image inline with the message body + self.include_image = include_image \ + if isinstance(include_image, bool) \ + else self.template_args['image']['default'] + + # Set up our user if specified + self.discord_user = 0 + if discord_user: + try: + self.discord_user = int(discord_user) + + except (ValueError, TypeError): + msg = 'An invalid Notifiarr User ID ' \ + '({}) was specified.'.format(discord_user) + self.logger.warning(msg) + raise TypeError(msg) + + # Set up our role if specified + self.discord_role = 0 + if discord_role: + try: + self.discord_role = int(discord_role) + + except (ValueError, TypeError): + msg = 'An invalid Notifiarr Role ID ' \ + '({}) was specified.'.format(discord_role) + self.logger.warning(msg) + raise TypeError(msg) + + # Prepare our source (if set) + self.source = validate_regex(source) + + self.event = 0 + if event: + try: + self.event = int(event) + + except (ValueError, TypeError): + msg = 'An invalid Notifiarr Discord Event ID ' \ + '({}) was specified.'.format(event) + self.logger.warning(msg) + raise TypeError(msg) + + # Prepare our targets + self.targets = { + 'channels': [], + 'invalid': [], + } + + for target in parse_list(targets): + result = CHANNEL_REGEX.match(target) + if result: + # Store role information + self.targets['channels'].append(int(result.group('channel'))) + continue + + self.logger.warning( + 'Dropped invalid channel ' + '({}) specified.'.format(target), + ) + self.targets['invalid'].append(target) + + return + + 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', + } + + if self.source: + params['source'] = self.source + + if self.discord_user: + params['discord_user'] = self.discord_user + + if self.discord_role: + params['discord_role'] = self.discord_role + + if self.event: + params['event'] = self.event + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{apikey}' \ + '/{targets}?{params}'.format( + schema=self.secure_protocol, + apikey=self.pprint(self.apikey, privacy, safe=''), + targets='/'.join( + [NotifyNotifiarr.quote(x, safe='+#@') for x in chain( + # Channels + ['#{}'.format(x) for x in self.targets['channels']], + # Pass along the same invalid entries as were provided + self.targets['invalid'], + )]), + params=NotifyNotifiarr.urlencode(params), + ) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Notifiarr Notification + """ + + if not self.targets['channels']: + # There were no services to notify + self.logger.warning( + 'There were no Notifiarr channels to notify.') + return False + + # No error to start with + has_error = False + + # Acquire image_url + image_url = self.image_url(notify_type) + + for idx, channel in enumerate(self.targets['channels']): + # prepare Notifiarr Object + payload = { + 'source': self.source if self.source else self.app_id, + 'type': notify_type, + 'notification': { + 'update': True if self.event else False, + 'name': self.app_id, + 'event': str(self.event) + if self.event else "", + }, + 'discord': { + 'color': self.color(notify_type), + 'ping': { + 'pingUser': self.discord_user + if not idx and self.discord_user else 0, + 'pingRole': self.discord_role + if not idx and self.discord_role else 0, + }, + 'text': { + 'title': title, + 'content': '', + 'description': body, + 'footer': self.app_desc, + }, + 'ids': { + 'channel': channel, + } + } + } + + if self.include_image and image_url: + payload['discord']['text']['icon'] = image_url + payload['discord']['images'] = { + 'thumbnail': image_url, + } + + if not self._send(payload): + has_error = True + + return not has_error + + def _send(self, payload): + """ + Send notification + """ + self.logger.debug('Notifiarr POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('Notifiarr Payload: %s' % str(payload)) + + # Prepare HTTP Headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + 'Accept': 'text/plain', + 'X-api-Key': self.apikey, + } + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + self.notify_url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code < 200 or r.status_code >= 300: + # We had a problem + status_str = \ + NotifyNotifiarr.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send Notifiarr %s notification: ' + '%serror=%s.', + status_str, + ', ' if status_str else '', + str(r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info('Sent Notifiarr notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Notifiarr ' + 'Chat notification to %s.' % self.host) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets['channels']) + len(self.targets['invalid']) + return targets if targets > 0 else 1 + + @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 + + # Get channels + results['targets'] = NotifyNotifiarr.split_path(results['fullpath']) + + if 'discord_user' in results['qsd'] and \ + len(results['qsd']['discord_user']): + results['discord_user'] = \ + NotifyNotifiarr.unquote( + results['qsd']['discord_user']) + + if 'discord_role' in results['qsd'] and \ + len(results['qsd']['discord_role']): + results['discord_role'] = \ + NotifyNotifiarr.unquote(results['qsd']['discord_role']) + + if 'event' in results['qsd'] and \ + len(results['qsd']['event']): + results['event'] = \ + NotifyNotifiarr.unquote(results['qsd']['event']) + + # Include images with our message + results['include_image'] = \ + parse_bool(results['qsd'].get('image', False)) + + # Track if we need to extract the hostname as a target + host_is_potential_target = False + + if 'source' in results['qsd'] and len(results['qsd']['source']): + results['source'] = \ + NotifyNotifiarr.unquote(results['qsd']['source']) + + elif 'from' in results['qsd'] and len(results['qsd']['from']): + results['source'] = \ + NotifyNotifiarr.unquote(results['qsd']['from']) + + # Set our apikey if found as an argument + if 'apikey' in results['qsd'] and len(results['qsd']['apikey']): + results['apikey'] = \ + NotifyNotifiarr.unquote(results['qsd']['apikey']) + + host_is_potential_target = True + + elif 'key' in results['qsd'] and len(results['qsd']['key']): + results['apikey'] = \ + NotifyNotifiarr.unquote(results['qsd']['key']) + + host_is_potential_target = True + + else: + # Pop the first element (this is the api key) + results['apikey'] = \ + NotifyNotifiarr.unquote(results['host']) + + if host_is_potential_target is True and results['host']: + results['targets'].append(NotifyNotifiarr.unquote(results['host'])) + + # Support the 'to' variable so that we can support rooms this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += [x for x in filter( + bool, CHANNEL_LIST_DELIM.split( + NotifyNotifiarr.unquote(results['qsd']['to'])))] + + return results diff --git a/lib/apprise/plugins/NotifyNotifico.py b/lib/apprise/plugins/NotifyNotifico.py new file mode 100644 index 0000000..8636e2e --- /dev/null +++ b/lib/apprise/plugins/NotifyNotifico.py @@ -0,0 +1,382 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# Notifico allows you to relay notifications into IRC channels. +# +# 1. visit https://n.tkte.ch and sign up for an account +# 2. create a project; either manually or sync with github +# 3. from within the project, you can create a message hook +# +# the URL will look something like this: +# https://n.tkte.ch/h/2144/uJmKaBW9WFk42miB146ci3Kj +# ^ ^ +# | | +# project id message hook +# +# This plugin also supports taking the URL (as identified above) directly +# as well. + +import re +import requests + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import parse_bool +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +class NotificoFormat: + # Resets all formatting + Reset = '\x0F' + + # Formatting + Bold = '\x02' + Italic = '\x1D' + Underline = '\x1F' + BGSwap = '\x16' + + +class NotificoColor: + # Resets Color + Reset = '\x03' + + # Colors + White = '\x0300' + Black = '\x0301' + Blue = '\x0302' + Green = '\x0303' + Red = '\x0304' + Brown = '\x0305' + Purple = '\x0306' + Orange = '\x0307' + Yellow = '\x0308', + LightGreen = '\x0309' + Teal = '\x0310' + LightCyan = '\x0311' + LightBlue = '\x0312' + Violet = '\x0313' + Grey = '\x0314' + LightGrey = '\x0315' + + +class NotifyNotifico(NotifyBase): + """ + A wrapper for Notifico Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Notifico' + + # The services URL + service_url = 'https://n.tkte.ch' + + # The default protocol + protocol = 'notifico' + + # The default secure protocol + secure_protocol = 'notifico' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_notifico' + + # Plain Text Notification URL + notify_url = 'https://n.tkte.ch/h/{proj}/{hook}' + + # The title is not used + title_maxlen = 0 + + # The maximum allowable characters allowed in the body per message + body_maxlen = 512 + + # Define object templates + templates = ( + '{schema}://{project_id}/{msghook}', + ) + + # Define our template arguments + template_tokens = dict(NotifyBase.template_tokens, **{ + # The Project ID is found as the first part of the URL + # /1234/........................ + 'project_id': { + 'name': _('Project ID'), + 'type': 'string', + 'required': True, + 'private': True, + 'regex': (r'^[0-9]+$', ''), + }, + # The Message Hook follows the Project ID + # /..../AbCdEfGhIjKlMnOpQrStUvWX + 'msghook': { + 'name': _('Message Hook'), + 'type': 'string', + 'required': True, + 'private': True, + 'regex': (r'^[a-z0-9]+$', 'i'), + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + # You can optionally pass IRC colors into + 'color': { + 'name': _('IRC Colors'), + 'type': 'bool', + 'default': True, + }, + + # You can optionally pass IRC color into + 'prefix': { + 'name': _('Prefix'), + 'type': 'bool', + 'default': True, + }, + }) + + def __init__(self, project_id, msghook, color=True, prefix=True, + **kwargs): + """ + Initialize Notifico Object + """ + super().__init__(**kwargs) + + # Assign our message hook + self.project_id = validate_regex( + project_id, *self.template_tokens['project_id']['regex']) + if not self.project_id: + msg = 'An invalid Notifico Project ID ' \ + '({}) was specified.'.format(project_id) + self.logger.warning(msg) + raise TypeError(msg) + + # Assign our message hook + self.msghook = validate_regex( + msghook, *self.template_tokens['msghook']['regex']) + if not self.msghook: + msg = 'An invalid Notifico Message Token ' \ + '({}) was specified.'.format(msghook) + self.logger.warning(msg) + raise TypeError(msg) + + # Prefix messages with a [?] where ? identifies the message type + # such as if it's an error, warning, info, or success + self.prefix = prefix + + # Send colors + self.color = color + + # Prepare our notification URL now: + self.api_url = self.notify_url.format( + proj=self.project_id, + hook=self.msghook, + ) + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'color': 'yes' if self.color else 'no', + 'prefix': 'yes' if self.prefix else 'no', + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{proj}/{hook}/?{params}'.format( + schema=self.secure_protocol, + proj=self.pprint(self.project_id, privacy, safe=''), + hook=self.pprint(self.msghook, privacy, safe=''), + params=NotifyNotifico.urlencode(params), + ) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + wrapper to _send since we can alert more then one channel + """ + + # prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', + } + + # Prepare our IRC Prefix + color = '' + token = '' + if notify_type == NotifyType.INFO: + color = NotificoColor.Teal + token = 'i' + + elif notify_type == NotifyType.SUCCESS: + color = NotificoColor.LightGreen + token = '✔' + + elif notify_type == NotifyType.WARNING: + color = NotificoColor.Orange + token = '!' + + elif notify_type == NotifyType.FAILURE: + color = NotificoColor.Red + token = '✗' + + if self.color: + # Colors were specified, make sure we capture and correctly + # allow them to exist inline in the message + # \g<1> is less ambiguous than \1 + body = re.sub(r'\\x03(\d{0,2})', r'\\x03\g<1>', body) + + else: + # no colors specified, make sure we strip out any colors found + # to make the string read-able + body = re.sub(r'\\x03(\d{1,2}(,[0-9]{1,2})?)?', r'', body) + + # Prepare our payload + payload = { + 'payload': body if not self.prefix + else '{}[{}]{} {}{}{}: {}{}'.format( + # Token [?] at the head + color if self.color else '', + token, + NotificoColor.Reset if self.color else '', + # App ID + NotificoFormat.Bold if self.color else '', + self.app_id, + NotificoFormat.Reset if self.color else '', + # Message Body + body, + # Reset + NotificoFormat.Reset if self.color else '', + ), + } + + self.logger.debug('Notifico GET URL: %s (cert_verify=%r)' % ( + self.api_url, self.verify_certificate)) + self.logger.debug('Notifico Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.get( + self.api_url, + params=payload, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyNotifico.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send Notifico notification: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info('Sent Notifico notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Notifico ' + 'notification.') + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + @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 + + # The first token is stored in the hostname + results['project_id'] = NotifyNotifico.unquote(results['host']) + + # Get Message Hook + try: + results['msghook'] = NotifyNotifico.split_path( + results['fullpath'])[0] + + except IndexError: + results['msghook'] = None + + # Include Color + results['color'] = \ + parse_bool(results['qsd'].get('color', True)) + + # Include Prefix + results['prefix'] = \ + parse_bool(results['qsd'].get('prefix', True)) + + return results + + @staticmethod + def parse_native_url(url): + """ + Support https://n.tkte.ch/h/PROJ_ID/MESSAGE_HOOK/ + """ + + result = re.match( + r'^https?://n\.tkte\.ch/h/' + r'(?P[0-9]+)/' + r'(?P[A-Z0-9]+)/?' + r'(?P\?.+)?$', url, re.I) + + if result: + return NotifyNotifico.parse_url( + '{schema}://{proj}/{hook}/{params}'.format( + schema=NotifyNotifico.secure_protocol, + proj=result.group('proj'), + hook=result.group('hook'), + params='' if not result.group('params') + else result.group('params'))) + + return None diff --git a/lib/apprise/plugins/NotifyNtfy.py b/lib/apprise/plugins/NotifyNtfy.py new file mode 100644 index 0000000..ceab5a2 --- /dev/null +++ b/lib/apprise/plugins/NotifyNtfy.py @@ -0,0 +1,845 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# Great sources +# - https://github.com/matrix-org/matrix-python-sdk +# - https://github.com/matrix-org/synapse/blob/master/docs/reverse_proxy.rst +# +# Examples: +# ntfys://my-topic +# ntfy://ntfy.local.domain/my-topic +# ntfys://ntfy.local.domain:8080/my-topic +# ntfy://ntfy.local.domain/?priority=max +import re +import requests +from json import loads +from json import dumps +from os.path import basename + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..common import NotifyImageSize +from ..AppriseLocale import gettext_lazy as _ +from ..utils import parse_list +from ..utils import parse_bool +from ..utils import is_hostname +from ..utils import is_ipaddr +from ..utils import validate_regex +from ..URLBase import PrivacyMode +from ..attachment.AttachBase import AttachBase + + +class NtfyMode: + """ + Define ntfy Notification Modes + """ + # App posts upstream to the developer API on ntfy's website + CLOUD = "cloud" + + # Running a dedicated private ntfy Server + PRIVATE = "private" + + +NTFY_MODES = ( + NtfyMode.CLOUD, + NtfyMode.PRIVATE, +) + +# A Simple regular expression used to auto detect Auth mode if it isn't +# otherwise specified: +NTFY_AUTH_DETECT_RE = re.compile('tk_[^ \t]+', re.IGNORECASE) + + +class NtfyAuth: + """ + Define ntfy Authentication Modes + """ + # Basic auth (user and password provided) + BASIC = "basic" + + # Auth Token based + TOKEN = "token" + + +NTFY_AUTH = ( + NtfyAuth.BASIC, + NtfyAuth.TOKEN, +) + + +class NtfyPriority: + """ + Ntfy Priority Definitions + """ + MAX = 'max' + HIGH = 'high' + NORMAL = 'default' + LOW = 'low' + MIN = 'min' + + +NTFY_PRIORITIES = ( + NtfyPriority.MAX, + NtfyPriority.HIGH, + NtfyPriority.NORMAL, + NtfyPriority.LOW, + NtfyPriority.MIN, +) + +NTFY_PRIORITY_MAP = { + # Maps against string 'low' but maps to Moderate to avoid + # conflicting with actual ntfy mappings + 'l': NtfyPriority.LOW, + # Maps against string 'moderate' + 'mo': NtfyPriority.LOW, + # Maps against string 'normal' + 'n': NtfyPriority.NORMAL, + # Maps against string 'high' + 'h': NtfyPriority.HIGH, + # Maps against string 'emergency' + 'e': NtfyPriority.MAX, + + # Entries to additionally support (so more like Ntfy's API) + # Maps against string 'min' + 'mi': NtfyPriority.MIN, + # Maps against string 'max' + 'ma': NtfyPriority.MAX, + # Maps against string 'default' + 'd': NtfyPriority.NORMAL, + + # support 1-5 values as well + '1': NtfyPriority.MIN, + # Maps against string 'moderate' + '2': NtfyPriority.LOW, + # Maps against string 'normal' + '3': NtfyPriority.NORMAL, + # Maps against string 'high' + '4': NtfyPriority.HIGH, + # Maps against string 'emergency' + '5': NtfyPriority.MAX, +} + + +class NotifyNtfy(NotifyBase): + """ + A wrapper for ntfy Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'ntfy' + + # The services URL + service_url = 'https://ntfy.sh/' + + # Insecure protocol (for those self hosted requests) + protocol = 'ntfy' + + # The default protocol + secure_protocol = 'ntfys' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_ntfy' + + # Default upstream/cloud host if none is defined + cloud_notify_url = 'https://ntfy.sh' + + # Support attachments + attachment_support = True + + # Allows the user to specify the NotifyImageSize object + image_size = NotifyImageSize.XY_256 + + # Message time to live (if remote client isn't around to receive it) + time_to_live = 2419200 + + # if our hostname matches the following we automatically enforce + # cloud mode + __auto_cloud_host = re.compile(r'ntfy\.sh', re.IGNORECASE) + + # Define object templates + templates = ( + '{schema}://{topic}', + '{schema}://{host}/{targets}', + '{schema}://{host}:{port}/{targets}', + '{schema}://{user}@{host}/{targets}', + '{schema}://{user}@{host}:{port}/{targets}', + '{schema}://{user}:{password}@{host}/{targets}', + '{schema}://{user}:{password}@{host}:{port}/{targets}', + '{schema}://{token}@{host}/{targets}', + '{schema}://{token}@{host}:{port}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'user': { + 'name': _('Username'), + 'type': 'string', + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + }, + 'token': { + 'name': _('Token'), + 'type': 'string', + 'private': True, + }, + 'topic': { + 'name': _('Topic'), + 'type': 'string', + 'map_to': 'targets', + 'regex': (r'^[a-z0-9_-]{1,64}$', 'i') + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'attach': { + 'name': _('Attach'), + 'type': 'string', + }, + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': True, + 'map_to': 'include_image', + }, + 'avatar_url': { + 'name': _('Avatar URL'), + 'type': 'string', + }, + 'filename': { + 'name': _('Attach Filename'), + 'type': 'string', + }, + 'click': { + 'name': _('Click'), + 'type': 'string', + }, + 'delay': { + 'name': _('Delay'), + 'type': 'string', + }, + 'email': { + 'name': _('Email'), + 'type': 'string', + }, + 'priority': { + 'name': _('Priority'), + 'type': 'choice:string', + 'values': NTFY_PRIORITIES, + 'default': NtfyPriority.NORMAL, + }, + 'tags': { + 'name': _('Tags'), + 'type': 'string', + }, + 'mode': { + 'name': _('Mode'), + 'type': 'choice:string', + 'values': NTFY_MODES, + 'default': NtfyMode.PRIVATE, + }, + 'token': { + 'alias_of': 'token', + }, + 'auth': { + 'name': _('Authentication Type'), + 'type': 'choice:string', + 'values': NTFY_AUTH, + 'default': NtfyAuth.BASIC, + }, + 'to': { + 'alias_of': 'targets', + }, + }) + + def __init__(self, targets=None, attach=None, filename=None, click=None, + delay=None, email=None, priority=None, tags=None, mode=None, + include_image=True, avatar_url=None, auth=None, token=None, + **kwargs): + """ + Initialize ntfy Object + """ + super().__init__(**kwargs) + + # Prepare our mode + self.mode = mode.strip().lower() \ + if isinstance(mode, str) \ + else self.template_args['mode']['default'] + + if self.mode not in NTFY_MODES: + msg = 'An invalid ntfy Mode ({}) was specified.'.format(mode) + self.logger.warning(msg) + raise TypeError(msg) + + # Show image associated with notification + self.include_image = include_image + + # Prepare our authentication type + self.auth = auth.strip().lower() \ + if isinstance(auth, str) \ + else self.template_args['auth']['default'] + + if self.auth not in NTFY_AUTH: + msg = 'An invalid ntfy Authentication type ({}) was specified.' \ + .format(auth) + self.logger.warning(msg) + raise TypeError(msg) + + # Attach a file (URL supported) + self.attach = attach + + # Our filename (if defined) + self.filename = filename + + # A clickthrough option for notifications + self.click = click + + # Time delay for notifications (various string formats) + self.delay = delay + + # An email to forward notifications to + self.email = email + + # Save our token + self.token = token + + # The Priority of the message + self.priority = NotifyNtfy.template_args['priority']['default'] \ + if not priority else \ + next(( + v for k, v in NTFY_PRIORITY_MAP.items() + if str(priority).lower().startswith(k)), + NotifyNtfy.template_args['priority']['default']) + + # Any optional tags to attach to the notification + self.__tags = parse_list(tags) + + # Avatar URL + # This allows a user to provide an over-ride to the otherwise + # dynamically generated avatar url images + self.avatar_url = avatar_url + + # Build list of topics + topics = parse_list(targets) + self.topics = [] + for _topic in topics: + topic = validate_regex( + _topic, *self.template_tokens['topic']['regex']) + if not topic: + self.logger.warning( + 'A specified ntfy topic ({}) is invalid and will be ' + 'ignored'.format(_topic)) + continue + self.topics.append(topic) + return + + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): + """ + Perform ntfy Notification + """ + + # error tracking (used for function return) + has_error = False + + if not len(self.topics): + # We have nothing to notify; we're done + self.logger.warning('There are no ntfy topics to notify') + return False + + # Acquire image_url + image_url = self.image_url(notify_type) + + if self.include_image and (image_url or self.avatar_url): + image_url = \ + self.avatar_url if self.avatar_url else image_url + else: + image_url = None + + # Create a copy of the topics + topics = list(self.topics) + while len(topics) > 0: + # Retrieve our topic + topic = topics.pop() + + if attach and self.attachment_support: + # We need to upload our payload first so that we can source it + # in remaining messages + for no, attachment in enumerate(attach): + + # First message only includes the text (if defined) + _body = body if not no and body else None + _title = title if not no and title else None + + # 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 + + self.logger.debug( + 'Preparing ntfy attachment {}'.format( + attachment.url(privacy=True))) + + okay, response = self._send( + topic, body=_body, title=_title, image_url=image_url, + attach=attachment) + if not okay: + # We can't post our attachment; abort immediately + return False + else: + # Send our Notification Message + okay, response = self._send( + topic, body=body, title=title, image_url=image_url) + if not okay: + # Mark our failure, but contiue to move on + has_error = True + + return not has_error + + def _send(self, topic, body=None, title=None, attach=None, image_url=None, + **kwargs): + """ + Wrapper to the requests (post) object + """ + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + } + + # See https://ntfy.sh/docs/publish/#publish-as-json + data = {} + + # Posting Parameters + params = {} + + auth = None + if self.mode == NtfyMode.CLOUD: + # Cloud Service + notify_url = self.cloud_notify_url + + else: # NotifyNtfy.PRVATE + # Allow more settings to be applied now + if self.auth == NtfyAuth.BASIC and self.user: + auth = (self.user, self.password) + + elif self.auth == NtfyAuth.TOKEN: + if not self.token: + self.logger.warning('No Ntfy Token was specified') + return False, None + + # Set Token + headers['Authorization'] = f'Bearer {self.token}' + + # Prepare our ntfy Template URL + schema = 'https' if self.secure else 'http' + + notify_url = '%s://%s' % (schema, self.host) + if isinstance(self.port, int): + notify_url += ':%d' % self.port + + if not attach: + headers['Content-Type'] = 'application/json' + + data['topic'] = topic + virt_payload = data + + if self.attach: + virt_payload['attach'] = self.attach + + if self.filename: + virt_payload['filename'] = self.filename + + else: + # Point our payload to our parameters + virt_payload = params + notify_url += '/{topic}'.format(topic=topic) + + # Prepare our Header + virt_payload['filename'] = attach.name + + with open(attach.path, 'rb') as fp: + data = fp.read() + + if image_url: + headers['X-Icon'] = image_url + + if title: + virt_payload['title'] = title + + if body: + virt_payload['message'] = body + + if self.priority != NtfyPriority.NORMAL: + headers['X-Priority'] = self.priority + + if self.delay is not None: + headers['X-Delay'] = self.delay + + if self.click is not None: + headers['X-Click'] = self.click + + if self.email is not None: + headers['X-Email'] = self.email + + if self.__tags: + headers['X-Tags'] = ",".join(self.__tags) + + self.logger.debug('ntfy POST URL: %s (cert_verify=%r)' % ( + notify_url, self.verify_certificate, + )) + self.logger.debug('ntfy Payload: %s' % str(virt_payload)) + self.logger.debug('ntfy Headers: %s' % str(headers)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + # Default response type + response = None + + if not attach: + data = dumps(data) + + try: + r = requests.post( + notify_url, + params=params if params else None, + data=data, + headers=headers, + auth=auth, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyBase.http_response_code_lookup(r.status_code) + + # set up our status code to use + status_code = r.status_code + + try: + # Update our status response if we can + response = loads(r.content) + status_str = response.get('error', status_str) + status_code = \ + int(response.get('code', status_code)) + + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + + # We could not parse JSON response. + # We will just use the status we already have. + pass + + self.logger.warning( + "Failed to send ntfy notification to topic '{}': " + '{}{}error={}.'.format( + topic, + status_str, + ', ' if status_str else '', + status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + return False, response + + # otherwise we were successful + self.logger.info( + "Sent ntfy notification to '{}'.".format(notify_url)) + + return True, response + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending ntfy:%s ' % ( + notify_url) + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while handling {}.'.format( + attach.name if isinstance(attach, AttachBase) + else virt_payload)) + self.logger.debug('I/O Exception: %s' % str(e)) + + return False, response + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + default_port = 443 if self.secure else 80 + + params = { + 'priority': self.priority, + 'mode': self.mode, + 'image': 'yes' if self.include_image else 'no', + 'auth': self.auth, + } + + if self.avatar_url: + params['avatar_url'] = self.avatar_url + + if self.attach is not None: + params['attach'] = self.attach + + if self.click is not None: + params['click'] = self.click + + if self.delay is not None: + params['delay'] = self.delay + + if self.email is not None: + params['email'] = self.email + + if self.__tags: + params['tags'] = ','.join(self.__tags) + + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Determine Authentication + auth = '' + if self.auth == NtfyAuth.BASIC: + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=NotifyNtfy.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, + safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=NotifyNtfy.quote(self.user, safe=''), + ) + + elif self.token: # NtfyAuth.TOKEN also + auth = '{token}@'.format( + token=self.pprint(self.token, privacy, safe=''), + ) + + if self.mode == NtfyMode.PRIVATE: + return '{schema}://{auth}{host}{port}/{targets}?{params}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + auth=auth, + host=self.host, + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + targets='/'.join( + [NotifyNtfy.quote(x, safe='') for x in self.topics]), + params=NotifyNtfy.urlencode(params) + ) + + else: # Cloud mode + return '{schema}://{targets}?{params}'.format( + schema=self.secure_protocol, + targets='/'.join( + [NotifyNtfy.quote(x, safe='') for x in self.topics]), + params=NotifyNtfy.urlencode(params) + ) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.topics) + + @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 + + # Set our priority + if 'priority' in results['qsd'] and len(results['qsd']['priority']): + results['priority'] = \ + NotifyNtfy.unquote(results['qsd']['priority']) + + if 'attach' in results['qsd'] and len(results['qsd']['attach']): + results['attach'] = NotifyNtfy.unquote(results['qsd']['attach']) + _results = NotifyBase.parse_url(results['attach']) + if _results: + results['filename'] = \ + None if _results['fullpath'] \ + else basename(_results['fullpath']) + + if 'filename' in results['qsd'] and \ + len(results['qsd']['filename']): + results['filename'] = \ + basename(NotifyNtfy.unquote(results['qsd']['filename'])) + + if 'click' in results['qsd'] and len(results['qsd']['click']): + results['click'] = NotifyNtfy.unquote(results['qsd']['click']) + + if 'delay' in results['qsd'] and len(results['qsd']['delay']): + results['delay'] = NotifyNtfy.unquote(results['qsd']['delay']) + + if 'email' in results['qsd'] and len(results['qsd']['email']): + results['email'] = NotifyNtfy.unquote(results['qsd']['email']) + + if 'tags' in results['qsd'] and len(results['qsd']['tags']): + results['tags'] = \ + parse_list(NotifyNtfy.unquote(results['qsd']['tags'])) + + # Boolean to include an image or not + results['include_image'] = parse_bool(results['qsd'].get( + 'image', NotifyNtfy.template_args['image']['default'])) + + # Extract avatar url if it was specified + if 'avatar_url' in results['qsd']: + results['avatar_url'] = \ + NotifyNtfy.unquote(results['qsd']['avatar_url']) + + # Acquire our targets/topics + results['targets'] = NotifyNtfy.split_path(results['fullpath']) + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyNtfy.parse_list(results['qsd']['to']) + + # Token Specified + if 'token' in results['qsd'] and len(results['qsd']['token']): + # Token presumed to be the one in use + results['auth'] = NtfyAuth.TOKEN + results['token'] = NotifyNtfy.unquote(results['qsd']['token']) + + # Auth override + if 'auth' in results['qsd'] and results['qsd']['auth']: + results['auth'] = NotifyNtfy.unquote( + results['qsd']['auth'].strip().lower()) + + if not results.get('auth') and results['user'] \ + and not results['password']: + # We can try to detect the authentication type on the formatting of + # the username. Look for tk_.* + # + # This isn't a surfire way to do things though; it's best to + # specify the auth= flag + results['auth'] = NtfyAuth.TOKEN \ + if NTFY_AUTH_DETECT_RE.match(results['user']) \ + else NtfyAuth.BASIC + + if results.get('auth') == NtfyAuth.TOKEN and not results.get('token'): + if results['user'] and not results['password']: + # Make sure we properly set our token + results['token'] = NotifyNtfy.unquote(results['user']) + + elif results['password']: + # Make sure we properly set our token + results['token'] = NotifyNtfy.unquote(results['password']) + + # Mode override + if 'mode' in results['qsd'] and results['qsd']['mode']: + results['mode'] = NotifyNtfy.unquote( + results['qsd']['mode'].strip().lower()) + + else: + # We can try to detect the mode based on the validity of the + # hostname. + # + # This isn't a surfire way to do things though; it's best to + # specify the mode= flag + results['mode'] = NtfyMode.PRIVATE \ + if ((is_hostname(results['host']) + or is_ipaddr(results['host'])) and results['targets']) \ + else NtfyMode.CLOUD + + if results['mode'] == NtfyMode.CLOUD: + # Store first entry as it can be a topic too in this case + # But only if we also rule it out not being the words + # ntfy.sh itself, something that starts wiht an non-alpha numeric + # character: + if not NotifyNtfy.__auto_cloud_host.search(results['host']): + # Add it to the front of the list for consistency + results['targets'].insert(0, results['host']) + + elif results['mode'] == NtfyMode.PRIVATE and \ + not (is_hostname(results['host'] or + is_ipaddr(results['host']))): + # Invalid Host for NtfyMode.PRIVATE + return None + + return results + + @staticmethod + def parse_native_url(url): + """ + Support https://ntfy.sh/topic + """ + + # Quick lookup for users who want to just paste + # the ntfy.sh url directly into Apprise + result = re.match( + r'^(http|ntfy)s?://ntfy\.sh' + r'(?P/[^?]+)?' + r'(?P\?.+)?$', url, re.I) + + if result: + mode = 'mode=%s' % NtfyMode.CLOUD + return NotifyNtfy.parse_url( + '{schema}://{topics}{params}'.format( + schema=NotifyNtfy.secure_protocol, + topics=result.group('topics') + if result.group('topics') else '', + params='?%s' % mode + if not result.group('params') + else result.group('params') + '&%s' % mode)) + + return None diff --git a/lib/apprise/plugins/NotifyOffice365.py b/lib/apprise/plugins/NotifyOffice365.py new file mode 100644 index 0000000..f445bc4 --- /dev/null +++ b/lib/apprise/plugins/NotifyOffice365.py @@ -0,0 +1,718 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# API Details: +# https://docs.microsoft.com/en-us/previous-versions/office/\ +# office-365-api/?redirectedfrom=MSDN + +# Information on sending an email: +# https://docs.microsoft.com/en-us/graph/api/user-sendmail\ +# ?view=graph-rest-1.0&tabs=http + +# Steps to get your Microsoft Client ID, Client Secret, and Tenant ID: +# 1. You should have valid Microsoft personal account. Go to Azure Portal +# 2. Go to -> Microsoft Active Directory --> App Registrations +# 3. Click new -> give any name (your choice) in Name field -> select +# personal Microsoft accounts only --> Register +# 4. Now you have your client_id & Tenant id. +# 5. To create client_secret , go to active directory -> +# Certificate & Tokens -> New client secret +# **This is auto-generated string which may have '@' and '?' +# characters in it. You should encode these to prevent +# from having any issues.** +# 6. Now need to set permission Active directory -> API permissions -> +# Add permission (search mail) , add relevant permission. +# 7. Set the redirect uri (Web) to: +# https://login.microsoftonline.com/common/oauth2/nativeclient +# +# ...and click register. +# +# This needs to be inserted into the "Redirect URI" text box as simply +# checking the check box next to this link seems to be insufficient. +# This is the default redirect uri used by this library, but you can use +# any other if you want. +# +# 8. Now you're good to go + +import requests +from datetime import datetime +from datetime import timedelta +from json import loads +from json import dumps +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyFormat +from ..common import NotifyType +from ..utils import is_email +from ..utils import parse_emails +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +class NotifyOffice365(NotifyBase): + """ + A wrapper for Office 365 Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Office 365' + + # The services URL + service_url = 'https://office.com/' + + # The default protocol + secure_protocol = 'o365' + + # Allow 300 requests per minute. + # 60/300 = 0.2 + request_rate_per_sec = 0.20 + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_office365' + + # URL to Microsoft Graph Server + graph_url = 'https://graph.microsoft.com' + + # Authentication URL + auth_url = 'https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token' + + # Use all the direct application permissions you have configured for your + # app. The endpoint should issue a token for the ones associated with the + # resource you want to use. + # see https://docs.microsoft.com/en-us/azure/active-directory/develop/\ + # v2-permissions-and-consent#the-default-scope + scope = '.default' + + # Default Notify Format + notify_format = NotifyFormat.HTML + + # Define object templates + templates = ( + '{schema}://{tenant}:{email}/{client_id}/{secret}', + '{schema}://{tenant}:{email}/{client_id}/{secret}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'tenant': { + 'name': _('Tenant Domain'), + 'type': 'string', + 'required': True, + 'private': True, + 'regex': (r'^[a-z0-9-]+$', 'i'), + }, + 'email': { + 'name': _('Account Email'), + 'type': 'string', + 'required': True, + }, + 'client_id': { + 'name': _('Client ID'), + 'type': 'string', + 'required': True, + 'private': True, + 'regex': (r'^[a-z0-9-]+$', 'i'), + }, + 'secret': { + 'name': _('Client Secret'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'target_email': { + 'name': _('Target Email'), + 'type': 'string', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'cc': { + 'name': _('Carbon Copy'), + 'type': 'list:string', + }, + 'bcc': { + 'name': _('Blind Carbon Copy'), + 'type': 'list:string', + }, + 'oauth_id': { + 'alias_of': 'client_id', + }, + 'oauth_secret': { + 'alias_of': 'secret', + }, + }) + + def __init__(self, tenant, email, client_id, secret, + targets=None, cc=None, bcc=None, **kwargs): + """ + Initialize Office 365 Object + """ + super().__init__(**kwargs) + + # Tenant identifier + self.tenant = validate_regex( + tenant, *self.template_tokens['tenant']['regex']) + if not self.tenant: + msg = 'An invalid Office 365 Tenant' \ + '({}) was specified.'.format(tenant) + self.logger.warning(msg) + raise TypeError(msg) + + result = is_email(email) + if not result: + msg = 'An invalid Office 365 Email Account ID' \ + '({}) was specified.'.format(email) + self.logger.warning(msg) + raise TypeError(msg) + + # Otherwise store our the email address + self.email = result['full_email'] + + # Client Key (associated with generated OAuth2 Login) + self.client_id = validate_regex( + client_id, *self.template_tokens['client_id']['regex']) + if not self.client_id: + msg = 'An invalid Office 365 Client OAuth2 ID ' \ + '({}) was specified.'.format(client_id) + self.logger.warning(msg) + raise TypeError(msg) + + # Client Secret (associated with generated OAuth2 Login) + self.secret = validate_regex(secret) + if not self.secret: + msg = 'An invalid Office 365 Client OAuth2 Secret ' \ + '({}) was specified.'.format(secret) + self.logger.warning(msg) + raise TypeError(msg) + + # For tracking our email -> name lookups + self.names = {} + + # Acquire Carbon Copies + self.cc = set() + + # Acquire Blind Carbon Copies + self.bcc = set() + + # Parse our targets + self.targets = list() + + if targets: + for recipient in parse_emails(targets): + # Validate recipients (to:) and drop bad ones: + result = is_email(recipient) + if result: + # Add our email to our target list + 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.email)) + + # Validate recipients (cc:) and drop bad ones: + for recipient in parse_emails(cc): + email = is_email(recipient) + if email: + self.cc.add(email['full_email']) + + # Index our name (if one exists) + self.names[email['full_email']] = \ + email['name'] if email['name'] else False + continue + + self.logger.warning( + 'Dropped invalid Carbon Copy email ' + '({}) specified.'.format(recipient), + ) + + # Validate recipients (bcc:) and drop bad ones: + for recipient in parse_emails(bcc): + email = is_email(recipient) + if email: + self.bcc.add(email['full_email']) + + # Index our name (if one exists) + self.names[email['full_email']] = \ + email['name'] if email['name'] else False + continue + + self.logger.warning( + 'Dropped invalid Blind Carbon Copy email ' + '({}) specified.'.format(recipient), + ) + + # Our token is acquired upon a successful login + self.token = None + + # Presume that our token has expired 'now' + self.token_expiry = datetime.now() + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Office 365 Notification + """ + + # error tracking (used for function return) + has_error = False + + if not self.targets: + # There is no one to email; we're done + self.logger.warning( + 'There are no Email recipients to notify') + return False + + # Setup our Content Type + content_type = \ + 'HTML' if self.notify_format == NotifyFormat.HTML else 'Text' + + # Prepare our payload + payload = { + 'Message': { + 'Subject': title, + 'Body': { + 'ContentType': content_type, + 'Content': body, + }, + }, + 'SaveToSentItems': 'false' + } + + # Create a copy of the email list + emails = list(self.targets) + + # Define our URL to post to + url = '{graph_url}/v1.0/users/{email}/sendmail'.format( + email=self.email, + graph_url=self.graph_url, + ) + + while len(emails): + # authenticate ourselves if we aren't already; but this function + # also tracks if our token we have is still valid and will + # re-authenticate ourselves if nessisary. + if not self.authenticate(): + # We could not authenticate ourselves; we're done + return False + + # Get our email to notify + to_name, to_addr = emails.pop(0) + + # Strip target out of cc list if in To or Bcc + cc = (self.cc - self.bcc - set([to_addr])) + + # Strip target out of bcc list if in To + bcc = (self.bcc - set([to_addr])) + + # Prepare our email + payload['Message']['ToRecipients'] = [{ + 'EmailAddress': { + 'Address': to_addr + } + }] + if to_name: + # Apply our To Name + payload['Message']['ToRecipients'][0]['EmailAddress']['Name'] \ + = to_name + + self.logger.debug('Email To: {}'.format(to_addr)) + + if cc: + # Prepare our CC list + payload['Message']['CcRecipients'] = [] + for addr in cc: + _payload = {'Address': addr} + if self.names.get(addr): + _payload['Name'] = self.names[addr] + + # Store our address in our payload + payload['Message']['CcRecipients']\ + .append({'EmailAddress': _payload}) + + self.logger.debug('Email Cc: {}'.format(', '.join( + ['{}{}'.format( + '' if self.names.get(e) + else '{}: '.format(self.names[e]), e) for e in cc]))) + + if bcc: + # Prepare our CC list + payload['Message']['BccRecipients'] = [] + for addr in bcc: + _payload = {'Address': addr} + if self.names.get(addr): + _payload['Name'] = self.names[addr] + + # Store our address in our payload + payload['Message']['BccRecipients']\ + .append({'EmailAddress': _payload}) + + self.logger.debug('Email Bcc: {}'.format(', '.join( + ['{}{}'.format( + '' if self.names.get(e) + else '{}: '.format(self.names[e]), e) for e in bcc]))) + + # Perform upstream fetch + postokay, response = self._fetch( + url=url, payload=dumps(payload), + content_type='application/json') + + # Test if we were okay + if not postokay: + has_error = True + + return not has_error + + def authenticate(self): + """ + Logs into and acquires us an authentication token to work with + """ + + if self.token and self.token_expiry > datetime.now(): + # If we're already authenticated and our token is still valid + self.logger.debug( + 'Already authenticate with token {}'.format(self.token)) + return True + + # If we reach here, we've either expired, or we need to authenticate + # for the first time. + + # Prepare our payload + payload = { + 'client_id': self.client_id, + 'client_secret': self.secret, + 'scope': '{graph_url}/{scope}'.format( + graph_url=self.graph_url, + scope=self.scope), + 'grant_type': 'client_credentials', + } + + # Prepare our URL + url = self.auth_url.format(tenant=self.tenant) + + # A response looks like the following: + # { + # "token_type": "Bearer", + # "expires_in": 3599, + # "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSzI1NiIsInNBXBP..." + # } + # + # Where expires_in defines the number of seconds the key is valid for + # before it must be renewed. + + # Alternatively, this could happen too... + # { + # "error": "invalid_scope", + # "error_description": "AADSTS70011: Blah... Blah Blah... Blah", + # "error_codes": [ + # 70011 + # ], + # "timestamp": "2020-01-09 02:02:12Z", + # "trace_id": "255d1aef-8c98-452f-ac51-23d051240864", + # "correlation_id": "fb3d2015-bc17-4bb9-bb85-30c5cf1aaaa7" + # } + + postokay, response = self._fetch(url=url, payload=payload) + if not postokay: + return False + + # Reset our token + self.token = None + + try: + # Extract our time from our response and subtrace 10 seconds from + # it to give us some wiggle/grace people to re-authenticate if we + # need to + self.token_expiry = datetime.now() + \ + timedelta(seconds=int(response.get('expires_in')) - 10) + + except (ValueError, AttributeError, TypeError): + # ValueError: expires_in wasn't an integer + # TypeError: expires_in was None + # AttributeError: we could not extract anything from our response + # object. + return False + + # Go ahead and store our token if it's available + self.token = response.get('access_token') + + # We're authenticated + return True if self.token else False + + def _fetch(self, url, payload, + content_type='application/x-www-form-urlencoded'): + """ + Wrapper to request object + + """ + + # Prepare our headers: + headers = { + 'User-Agent': self.app_id, + 'Content-Type': content_type, + } + + if self.token: + # Are we authenticated? + headers['Authorization'] = 'Bearer ' + self.token + + # Default content response object + content = {} + + # Some Debug Logging + self.logger.debug('Office 365 POST URL: {} (cert_verify={})'.format( + url, self.verify_certificate)) + self.logger.debug('Office 365 Payload: {}' .format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + # fetch function + try: + r = requests.post( + url, + data=payload, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + if r.status_code not in ( + requests.codes.ok, requests.codes.accepted): + + # We had a problem + status_str = \ + NotifyOffice365.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send Office 365 POST to {}: ' + '{}error={}.'.format( + url, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + return (False, content) + + try: + content = loads(r.content) + + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + content = {} + + except requests.RequestException as e: + self.logger.warning( + 'Exception received when sending Office 365 POST to {}: '. + format(url)) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + return (False, content) + + return (True, content) + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Our URL parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + if self.cc: + # Handle our Carbon Copy Addresses + params['cc'] = ','.join( + ['{}{}'.format( + '' if not self.names.get(e) + else '{}:'.format(self.names[e]), e) for e in self.cc]) + + if self.bcc: + # Handle our Blind Carbon Copy Addresses + params['bcc'] = ','.join( + ['{}{}'.format( + '' if not self.names.get(e) + else '{}:'.format(self.names[e]), e) for e in self.bcc]) + + return '{schema}://{tenant}:{email}/{client_id}/{secret}' \ + '/{targets}/?{params}'.format( + schema=self.secure_protocol, + tenant=self.pprint(self.tenant, privacy, safe=''), + # email does not need to be escaped because it should + # already be a valid host and username at this point + email=self.email, + client_id=self.pprint(self.client_id, privacy, safe=''), + secret=self.pprint( + self.secret, privacy, mode=PrivacyMode.Secret, + safe=''), + targets='/'.join( + [NotifyOffice365.quote('{}{}'.format( + '' if not e[0] else '{}:'.format(e[0]), e[1]), + safe='') for e in self.targets]), + params=NotifyOffice365.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return 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 + + # Now make a list of all our path entries + # We need to read each entry back one at a time in reverse order + # where each email found we mark as a target. Once we run out + # of targets, the presume the remainder of the entries are part + # of the secret key (since it can contain slashes in it) + entries = NotifyOffice365.split_path(results['fullpath']) + + try: + # Get our client_id is the first entry on the path + results['client_id'] = NotifyOffice365.unquote(entries.pop(0)) + + except IndexError: + # no problem, we may get the client_id another way through + # arguments... + pass + + # Prepare our target listing + results['targets'] = list() + while entries: + # Pop the last entry + entry = NotifyOffice365.unquote(entries.pop(-1)) + + if is_email(entry): + # Store our email and move on + results['targets'].append(entry) + continue + + # If we reach here, the entry we just popped is part of the secret + # key, so put it back + entries.append(NotifyOffice365.quote(entry, safe='')) + + # We're done + break + + # Initialize our tenant + results['tenant'] = None + + # Assemble our secret key which is a combination of the host followed + # by all entries in the full path that follow up until the first email + results['secret'] = '/'.join( + [NotifyOffice365.unquote(x) for x in entries]) + + # Assemble our client id from the user@hostname + if results['password']: + results['email'] = '{}@{}'.format( + NotifyOffice365.unquote(results['password']), + NotifyOffice365.unquote(results['host']), + ) + # Update our tenant + results['tenant'] = NotifyOffice365.unquote(results['user']) + + else: + # No tenant specified.. + results['email'] = '{}@{}'.format( + NotifyOffice365.unquote(results['user']), + NotifyOffice365.unquote(results['host']), + ) + + # OAuth2 ID + if 'oauth_id' in results['qsd'] and len(results['qsd']['oauth_id']): + # Extract the API Key from an argument + results['client_id'] = \ + NotifyOffice365.unquote(results['qsd']['oauth_id']) + + # OAuth2 Secret + if 'oauth_secret' in results['qsd'] and \ + len(results['qsd']['oauth_secret']): + # Extract the API Secret from an argument + results['secret'] = \ + NotifyOffice365.unquote(results['qsd']['oauth_secret']) + + # Tenant + if 'from' in results['qsd'] and \ + len(results['qsd']['from']): + # Extract the sending account's information + results['email'] = \ + NotifyOffice365.unquote(results['qsd']['from']) + + # Tenant + if 'tenant' in results['qsd'] and \ + len(results['qsd']['tenant']): + # Extract the Tenant from the argument + results['tenant'] = \ + NotifyOffice365.unquote(results['qsd']['tenant']) + + # Support the 'to' variable so that we can support targets this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyOffice365.parse_list(results['qsd']['to']) + + # Handle Carbon Copy Addresses + if 'cc' in results['qsd'] and len(results['qsd']['cc']): + results['cc'] = results['qsd']['cc'] + + # Handle Blind Carbon Copy Addresses + if 'bcc' in results['qsd'] and len(results['qsd']['bcc']): + results['bcc'] = results['qsd']['bcc'] + + return results diff --git a/lib/apprise/plugins/NotifyOneSignal.py b/lib/apprise/plugins/NotifyOneSignal.py new file mode 100644 index 0000000..39dd7f2 --- /dev/null +++ b/lib/apprise/plugins/NotifyOneSignal.py @@ -0,0 +1,519 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# One Signal requires that you've signed up with the service and +# generated yourself an API Key and APP ID. + +# Sources: +# - https://documentation.onesignal.com/docs/accounts-and-keys +# - https://documentation.onesignal.com/reference/create-notification + +import requests +from json import dumps +from itertools import chain + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..common import NotifyImageSize +from ..utils import validate_regex +from ..utils import parse_list +from ..utils import parse_bool +from ..utils import is_email +from ..AppriseLocale import gettext_lazy as _ + + +class OneSignalCategory: + """ + We define the different category types that we can notify via OneSignal + """ + PLAYER = 'include_player_ids' + EMAIL = 'include_email_tokens' + USER = 'include_external_user_ids' + SEGMENT = 'included_segments' + + +ONESIGNAL_CATEGORIES = ( + OneSignalCategory.PLAYER, + OneSignalCategory.EMAIL, + OneSignalCategory.USER, + OneSignalCategory.SEGMENT, +) + + +class NotifyOneSignal(NotifyBase): + """ + A wrapper for OneSignal Notifications + """ + # The default descriptive name associated with the Notification + service_name = 'OneSignal' + + # The services URL + service_url = 'https://onesignal.com' + + # The default protocol + secure_protocol = 'onesignal' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_onesignal' + + # Notification + notify_url = "https://onesignal.com/api/v1/notifications" + + # Allows the user to specify the NotifyImageSize object + image_size = NotifyImageSize.XY_72 + + # The maximum allowable batch sizes per message + default_batch_size = 2000 + + # Define object templates + templates = ( + '{schema}://{app}@{apikey}/{targets}', + '{schema}://{template}:{app}@{apikey}/{targets}', + ) + + # Define our template + template_tokens = dict(NotifyBase.template_tokens, **{ + # The App_ID is a UUID + # such as: 8250eaf6-1a58-489e-b136-7c74a864b434 + 'app': { + 'name': _('App ID'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'template': { + 'name': _('Template'), + 'type': 'string', + 'private': True, + }, + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'target_player': { + 'name': _('Target Player ID'), + 'type': 'string', + 'map_to': 'targets', + }, + 'target_email': { + 'name': _('Target Email'), + 'type': 'string', + 'map_to': 'targets', + }, + 'target_user': { + 'name': _('Target User'), + 'type': 'string', + 'prefix': '@', + 'map_to': 'targets', + }, + 'target_segment': { + 'name': _('Include Segment'), + 'type': 'string', + 'prefix': '#', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + 'required': True, + }, + }) + + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': True, + 'map_to': 'include_image', + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + 'template': { + 'alias_of': 'template', + }, + 'subtitle': { + 'name': _('Subtitle'), + 'type': 'string', + }, + 'language': { + 'name': _('Language'), + 'type': 'string', + 'default': 'en', + }, + }) + + def __init__(self, app, apikey, targets=None, include_image=True, + template=None, subtitle=None, language=None, batch=False, + **kwargs): + """ + Initialize OneSignal + + """ + super().__init__(**kwargs) + + # The apikey associated with the account + self.apikey = validate_regex(apikey) + if not self.apikey: + msg = 'An invalid OneSignal API key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # The App ID associated with the account + self.app = validate_regex(app) + if not self.app: + msg = 'An invalid OneSignal Application ID ' \ + '({}) was specified.'.format(app) + self.logger.warning(msg) + raise TypeError(msg) + + # Prepare Batch Mode Flag + self.batch_size = self.default_batch_size if batch else 1 + + # Place a thumbnail image inline with the message body + self.include_image = include_image + + # Our Assorted Types of Targets + self.targets = { + OneSignalCategory.PLAYER: [], + OneSignalCategory.EMAIL: [], + OneSignalCategory.USER: [], + OneSignalCategory.SEGMENT: [], + } + + # Assign our template (if defined) + self.template_id = template + + # Assign our subtitle (if defined) + self.subtitle = subtitle + + # Our Language + self.language = language.strip().lower()[0:2]\ + if language \ + else NotifyOneSignal.template_args['language']['default'] + + if not self.language or len(self.language) != 2: + msg = 'An invalid OneSignal Language ({}) was specified.'.format( + language) + self.logger.warning(msg) + raise TypeError(msg) + + # Sort our targets + for _target in parse_list(targets): + target = _target.strip() + if len(target) < 2: + self.logger.debug('Ignoring OneSignal Entry: %s' % target) + continue + + if target.startswith( + NotifyOneSignal.template_tokens + ['target_user']['prefix']): + + self.targets[OneSignalCategory.USER].append(target) + self.logger.debug( + 'Detected OneSignal UserID: %s' % + self.targets[OneSignalCategory.USER][-1]) + continue + + if target.startswith( + NotifyOneSignal.template_tokens + ['target_segment']['prefix']): + + self.targets[OneSignalCategory.SEGMENT].append(target) + self.logger.debug( + 'Detected OneSignal Include Segment: %s' % + self.targets[OneSignalCategory.SEGMENT][-1]) + continue + + result = is_email(target) + if result: + self.targets[OneSignalCategory.EMAIL]\ + .append(result['full_email']) + self.logger.debug( + 'Detected OneSignal Email: %s' % + self.targets[OneSignalCategory.EMAIL][-1]) + + else: + # Add element as Player ID + self.targets[OneSignalCategory.PLAYER].append(target) + self.logger.debug( + 'Detected OneSignal Player ID: %s' % + self.targets[OneSignalCategory.PLAYER][-1]) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform OneSignal Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json; charset=utf-8', + "Authorization": "Basic {}".format(self.apikey), + } + + has_error = False + sent_count = 0 + + payload = { + 'app_id': self.app, + + 'headings': { + self.language: title if title else self.app_desc, + }, + 'contents': { + self.language: body, + }, + + # Sending true wakes your app from background to run custom native + # code (Apple interprets this as content-available=1). + # Note: Not applicable if the app is in the "force-quit" state + # (i.e app was swiped away). Omit the contents field to + # prevent displaying a visible notification. + 'content_available': True, + } + + if self.subtitle: + payload.update({ + 'subtitle': { + self.language: self.subtitle, + }, + }) + + if self.template_id: + payload['template_id'] = self.template_id + + # Acquire our large_icon image URL (if set) + image_url = None if not self.include_image \ + else self.image_url(notify_type) + if image_url: + payload['large_icon'] = image_url + + # Acquire our small_icon image URL (if set) + image_url = None if not self.include_image \ + else self.image_url(notify_type, image_size=NotifyImageSize.XY_32) + if image_url: + payload['small_icon'] = image_url + + for category in ONESIGNAL_CATEGORIES: + # Create a pointer to our list of targets for specified category + targets = self.targets[category] + for index in range(0, len(targets), self.batch_size): + payload[category] = targets[index:index + self.batch_size] + + # Track our sent count + sent_count += len(payload[category]) + + self.logger.debug('OneSignal POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('OneSignal Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + self.notify_url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code not in ( + requests.codes.ok, requests.codes.no_content): + # We had a problem + status_str = \ + NotifyOneSignal.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send OneSignal notification: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n%s', r.content) + + has_error = True + + else: + self.logger.info('Sent OneSignal notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending OneSignal ' + 'notification.' + ) + self.logger.debug('Socket Exception: %s', str(e)) + + has_error = True + + if not sent_count: + # There is no one to notify; we need to capture this and not + # return a valid + self.logger.warning('There are no OneSignal targets to notify') + return False + + 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', + 'batch': 'yes' if self.batch_size > 1 else 'no', + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{tp_id}{app}@{apikey}/{targets}?{params}'.format( + schema=self.secure_protocol, + tp_id='{}:'.format( + self.pprint(self.template_id, privacy, safe='')) + if self.template_id else '', + app=self.pprint(self.app, privacy, safe=''), + apikey=self.pprint(self.apikey, privacy, safe=''), + targets='/'.join(chain( + [NotifyOneSignal.quote(x) + for x in self.targets[OneSignalCategory.PLAYER]], + [NotifyOneSignal.quote(x) + for x in self.targets[OneSignalCategory.EMAIL]], + [NotifyOneSignal.quote('{}{}'.format( + NotifyOneSignal.template_tokens + ['target_user']['prefix'], x), safe='') + for x in self.targets[OneSignalCategory.USER]], + [NotifyOneSignal.quote('{}{}'.format( + NotifyOneSignal.template_tokens + ['target_segment']['prefix'], x), safe='') + for x in self.targets[OneSignalCategory.SEGMENT]])), + params=NotifyOneSignal.urlencode(params), + ) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + if self.batch_size > 1: + # Batches can only be sent by group (you can't combine groups into + # a single batch) + total_targets = 0 + for k, m in self.targets.items(): + targets = len(m) + total_targets += int(targets / self.batch_size) + \ + (1 if targets % self.batch_size else 0) + return total_targets + + # Normal batch count; just count the targets + return sum([len(m) for _, m in self.targets.items()]) + + @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 + + if not results.get('password'): + # The APP ID identifier associated with the account + results['app'] = NotifyOneSignal.unquote(results['user']) + + else: + # The APP ID identifier associated with the account + results['app'] = NotifyOneSignal.unquote(results['password']) + # The Template ID + results['template'] = NotifyOneSignal.unquote(results['user']) + + # Get Image Boolean (if set) + results['include_image'] = \ + parse_bool( + results['qsd'].get( + 'image', + NotifyOneSignal.template_args['image']['default'])) + + # Get Batch Boolean (if set) + results['batch'] = \ + parse_bool( + results['qsd'].get( + 'batch', + NotifyOneSignal.template_args['batch']['default'])) + + # The API Key is stored in the hostname + results['apikey'] = NotifyOneSignal.unquote(results['host']) + + # Get our Targets + results['targets'] = NotifyOneSignal.split_path(results['fullpath']) + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyOneSignal.parse_list(results['qsd']['to']) + + if 'app' in results['qsd'] and len(results['qsd']['app']): + results['app'] = \ + NotifyOneSignal.unquote(results['qsd']['app']) + + if 'apikey' in results['qsd'] and len(results['qsd']['apikey']): + results['apikey'] = \ + NotifyOneSignal.unquote(results['qsd']['apikey']) + + if 'template' in results['qsd'] and len(results['qsd']['template']): + results['template'] = \ + NotifyOneSignal.unquote(results['qsd']['template']) + + if 'subtitle' in results['qsd'] and len(results['qsd']['subtitle']): + results['subtitle'] = \ + NotifyOneSignal.unquote(results['qsd']['subtitle']) + + if 'lang' in results['qsd'] and len(results['qsd']['lang']): + results['language'] = \ + NotifyOneSignal.unquote(results['qsd']['lang']) + + return results diff --git a/lib/apprise/plugins/NotifyOpsgenie.py b/lib/apprise/plugins/NotifyOpsgenie.py new file mode 100644 index 0000000..29cd0a2 --- /dev/null +++ b/lib/apprise/plugins/NotifyOpsgenie.py @@ -0,0 +1,611 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# Signup @ https://www.opsgenie.com +# +# Generate your Integration API Key +# https://app.opsgenie.com/settings/integration/add/API/ + +# Knowing this, you can build your Opsgenie URL as follows: +# opsgenie://{apikey}/ +# opsgenie://{apikey}/@{user} +# opsgenie://{apikey}/*{schedule} +# opsgenie://{apikey}/^{escalation} +# opsgenie://{apikey}/#{team} +# +# You can mix and match what you want to notify freely +# opsgenie://{apikey}/@{user}/#{team}/*{schedule}/^{escalation} +# +# If no target prefix is specified, then it is assumed to be a user. +# +# API Documentation: https://docs.opsgenie.com/docs/alert-api +# API Integration Docs: https://docs.opsgenie.com/docs/api-integration + +import requests +from json import dumps + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import validate_regex +from ..utils import is_uuid +from ..utils import parse_list +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + + +class OpsgenieCategory(NotifyBase): + """ + We define the different category types that we can notify + """ + USER = 'user' + SCHEDULE = 'schedule' + ESCALATION = 'escalation' + TEAM = 'team' + + +OPSGENIE_CATEGORIES = ( + OpsgenieCategory.USER, + OpsgenieCategory.SCHEDULE, + OpsgenieCategory.ESCALATION, + OpsgenieCategory.TEAM, +) + + +# Regions +class OpsgenieRegion: + US = 'us' + EU = 'eu' + + +# Opsgenie APIs +OPSGENIE_API_LOOKUP = { + OpsgenieRegion.US: 'https://api.opsgenie.com/v2/alerts', + OpsgenieRegion.EU: 'https://api.eu.opsgenie.com/v2/alerts', +} + +# A List of our regions we can use for verification +OPSGENIE_REGIONS = ( + OpsgenieRegion.US, + OpsgenieRegion.EU, +) + + +# Priorities +class OpsgeniePriority: + LOW = 1 + MODERATE = 2 + NORMAL = 3 + HIGH = 4 + EMERGENCY = 5 + + +OPSGENIE_PRIORITIES = { + # Note: This also acts as a reverse lookup mapping + OpsgeniePriority.LOW: 'low', + OpsgeniePriority.MODERATE: 'moderate', + OpsgeniePriority.NORMAL: 'normal', + OpsgeniePriority.HIGH: 'high', + OpsgeniePriority.EMERGENCY: 'emergency', +} + +OPSGENIE_PRIORITY_MAP = { + # Maps against string 'low' + 'l': OpsgeniePriority.LOW, + # Maps against string 'moderate' + 'm': OpsgeniePriority.MODERATE, + # Maps against string 'normal' + 'n': OpsgeniePriority.NORMAL, + # Maps against string 'high' + 'h': OpsgeniePriority.HIGH, + # Maps against string 'emergency' + 'e': OpsgeniePriority.EMERGENCY, + + # Entries to additionally support (so more like Opsgenie's API) + '1': OpsgeniePriority.LOW, + '2': OpsgeniePriority.MODERATE, + '3': OpsgeniePriority.NORMAL, + '4': OpsgeniePriority.HIGH, + '5': OpsgeniePriority.EMERGENCY, + # Support p-prefix + 'p1': OpsgeniePriority.LOW, + 'p2': OpsgeniePriority.MODERATE, + 'p3': OpsgeniePriority.NORMAL, + 'p4': OpsgeniePriority.HIGH, + 'p5': OpsgeniePriority.EMERGENCY, +} + + +class NotifyOpsgenie(NotifyBase): + """ + A wrapper for Opsgenie Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Opsgenie' + + # The services URL + service_url = 'https://opsgenie.com/' + + # All notification requests are secure + secure_protocol = 'opsgenie' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_opsgenie' + + # The maximum length of the body + body_maxlen = 15000 + + # If we don't have the specified min length, then we don't bother using + # the body directive + opsgenie_body_minlen = 130 + + # The default region to use if one isn't otherwise specified + opsgenie_default_region = OpsgenieRegion.US + + # The maximum allowable targets within a notification + default_batch_size = 50 + + # Define object templates + templates = ( + '{schema}://{apikey}', + '{schema}://{apikey}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'target_escalation': { + 'name': _('Target Escalation'), + 'prefix': '^', + 'type': 'string', + 'map_to': 'targets', + }, + 'target_schedule': { + 'name': _('Target Schedule'), + 'type': 'string', + 'prefix': '*', + 'map_to': 'targets', + }, + 'target_user': { + 'name': _('Target User'), + 'type': 'string', + 'prefix': '@', + 'map_to': 'targets', + }, + 'target_team': { + 'name': _('Target Team'), + 'type': 'string', + 'prefix': '#', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets '), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'region': { + 'name': _('Region Name'), + 'type': 'choice:string', + 'values': OPSGENIE_REGIONS, + 'default': OpsgenieRegion.US, + 'map_to': 'region_name', + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + 'priority': { + 'name': _('Priority'), + 'type': 'choice:int', + 'values': OPSGENIE_PRIORITIES, + 'default': OpsgeniePriority.NORMAL, + }, + 'entity': { + 'name': _('Entity'), + 'type': 'string', + }, + 'alias': { + 'name': _('Alias'), + 'type': 'string', + }, + 'tags': { + 'name': _('Tags'), + 'type': 'string', + }, + 'to': { + 'alias_of': 'targets', + }, + }) + + # Map of key-value pairs to use as custom properties of the alert. + template_kwargs = { + 'details': { + 'name': _('Details'), + 'prefix': '+', + }, + } + + def __init__(self, apikey, targets, region_name=None, details=None, + priority=None, alias=None, entity=None, batch=False, + tags=None, **kwargs): + """ + Initialize Opsgenie Object + """ + super().__init__(**kwargs) + + # API Key (associated with project) + self.apikey = validate_regex(apikey) + if not self.apikey: + msg = 'An invalid Opsgenie API Key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # The Priority of the message + self.priority = NotifyOpsgenie.template_args['priority']['default'] \ + if not priority else \ + next(( + v for k, v in OPSGENIE_PRIORITY_MAP.items() + if str(priority).lower().startswith(k)), + NotifyOpsgenie.template_args['priority']['default']) + + # Store our region + try: + self.region_name = self.opsgenie_default_region \ + if region_name is None else region_name.lower() + + if self.region_name not in OPSGENIE_REGIONS: + # allow the outer except to handle this common response + raise + except: + # Invalid region specified + msg = 'The Opsgenie region specified ({}) is invalid.' \ + .format(region_name) + self.logger.warning(msg) + raise TypeError(msg) + + self.details = {} + if details: + # Store our extra details + self.details.update(details) + + # Prepare Batch Mode Flag + self.batch_size = self.default_batch_size if batch else 1 + + # Assign our tags (if defined) + self.__tags = parse_list(tags) + + # Assign our entity (if defined) + self.entity = entity + + # Assign our alias (if defined) + self.alias = alias + + # Initialize our Targets + self.targets = [] + + # Sort our targets + for _target in parse_list(targets): + target = _target.strip() + if len(target) < 2: + self.logger.debug('Ignoring Opsgenie Entry: %s' % target) + continue + + if target.startswith(NotifyOpsgenie.template_tokens + ['target_team']['prefix']): + + self.targets.append( + {'type': OpsgenieCategory.TEAM, 'id': target[1:]} + if is_uuid(target[1:]) else + {'type': OpsgenieCategory.TEAM, 'name': target[1:]}) + + elif target.startswith(NotifyOpsgenie.template_tokens + ['target_schedule']['prefix']): + + self.targets.append( + {'type': OpsgenieCategory.SCHEDULE, 'id': target[1:]} + if is_uuid(target[1:]) else + {'type': OpsgenieCategory.SCHEDULE, 'name': target[1:]}) + + elif target.startswith(NotifyOpsgenie.template_tokens + ['target_escalation']['prefix']): + + self.targets.append( + {'type': OpsgenieCategory.ESCALATION, 'id': target[1:]} + if is_uuid(target[1:]) else + {'type': OpsgenieCategory.ESCALATION, 'name': target[1:]}) + + elif target.startswith(NotifyOpsgenie.template_tokens + ['target_user']['prefix']): + + self.targets.append( + {'type': OpsgenieCategory.USER, 'id': target[1:]} + if is_uuid(target[1:]) else + {'type': OpsgenieCategory.USER, 'username': target[1:]}) + + else: + # Ambiguious entry; treat it as a user but not before + # displaying a warning to the end user first: + self.logger.debug( + 'Treating ambigious Opsgenie target %s as a user', target) + self.targets.append( + {'type': OpsgenieCategory.USER, 'id': target} + if is_uuid(target) else + {'type': OpsgenieCategory.USER, 'username': target}) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Opsgenie Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + 'Authorization': 'GenieKey {}'.format(self.apikey), + } + + # Prepare our URL as it's based on our hostname + notify_url = OPSGENIE_API_LOOKUP[self.region_name] + + # Initialize our has_error flag + has_error = False + + # Use body if title not set + title_body = body if not title else title + + # Create a copy ouf our details object + details = self.details.copy() + if 'type' not in details: + details['type'] = notify_type + + # Prepare our payload + payload = { + 'source': self.app_desc, + 'message': title_body, + 'description': body, + 'details': details, + 'priority': 'P{}'.format(self.priority), + } + + # Use our body directive if we exceed the minimum message + # limitation + if len(payload['message']) > self.opsgenie_body_minlen: + payload['message'] = '{}...'.format( + title_body[:self.opsgenie_body_minlen - 3]) + + if self.__tags: + payload['tags'] = self.__tags + + if self.entity: + payload['entity'] = self.entity + + if self.alias: + payload['alias'] = self.alias + + length = len(self.targets) if self.targets else 1 + for index in range(0, length, self.batch_size): + if self.targets: + # If there were no targets identified, then we simply + # just iterate once without the responders set + payload['responders'] = \ + self.targets[index:index + self.batch_size] + + # Some Debug Logging + self.logger.debug( + 'Opsgenie POST URL: {} (cert_verify={})'.format( + notify_url, self.verify_certificate)) + self.logger.debug('Opsgenie Payload: {}' .format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + notify_url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + if r.status_code not in ( + requests.codes.accepted, requests.codes.ok): + status_str = \ + NotifyBase.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send Opsgenie notification:' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + # If we reach here; the message was sent + self.logger.info('Sent Opsgenie notification') + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Opsgenie ' + 'notification.') + self.logger.debug('Socket Exception: %s' % str(e)) + # Mark our failure + has_error = True + + 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 = { + 'region': self.region_name, + 'priority': + OPSGENIE_PRIORITIES[self.template_args['priority']['default']] + if self.priority not in OPSGENIE_PRIORITIES + else OPSGENIE_PRIORITIES[self.priority], + 'batch': 'yes' if self.batch_size > 1 else 'no', + } + + # Assign our entity value (if defined) + if self.entity: + params['entity'] = self.entity + + # Assign our alias value (if defined) + if self.alias: + params['alias'] = self.alias + + # Assign our tags (if specifed) + if self.__tags: + params['tags'] = ','.join(self.__tags) + + # Append our details into our parameters + params.update({'+{}'.format(k): v for k, v in self.details.items()}) + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # A map allows us to map our target types so they can be correctly + # placed back into your URL below. Hence map the 'user' -> '@' + __map = { + OpsgenieCategory.USER: + NotifyOpsgenie.template_tokens['target_user']['prefix'], + OpsgenieCategory.SCHEDULE: + NotifyOpsgenie.template_tokens['target_schedule']['prefix'], + OpsgenieCategory.ESCALATION: + NotifyOpsgenie.template_tokens['target_escalation']['prefix'], + OpsgenieCategory.TEAM: + NotifyOpsgenie.template_tokens['target_team']['prefix'], + } + + return '{schema}://{apikey}/{targets}/?{params}'.format( + schema=self.secure_protocol, + apikey=self.pprint(self.apikey, privacy, safe=''), + targets='/'.join( + [NotifyOpsgenie.quote('{}{}'.format( + __map[x['type']], + x.get('id', x.get('name', x.get('username'))))) + for x in self.targets]), + params=NotifyOpsgenie.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + targets = len(self.targets) + if self.batch_size > 1: + targets = int(targets / self.batch_size) + \ + (1 if targets % self.batch_size else 0) + + return targets if targets > 0 else 1 + + @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 + + # The API Key is stored in the hostname + results['apikey'] = NotifyOpsgenie.unquote(results['host']) + + # Get our Targets + results['targets'] = NotifyOpsgenie.split_path(results['fullpath']) + + # Add our Meta Detail keys + results['details'] = {NotifyBase.unquote(x): NotifyBase.unquote(y) + for x, y in results['qsd+'].items()} + + # Set our priority + if 'priority' in results['qsd'] and len(results['qsd']['priority']): + results['priority'] = \ + NotifyOpsgenie.unquote(results['qsd']['priority']) + + # Get Batch Boolean (if set) + results['batch'] = \ + parse_bool( + results['qsd'].get( + 'batch', + NotifyOpsgenie.template_args['batch']['default'])) + + if 'apikey' in results['qsd'] and len(results['qsd']['apikey']): + results['apikey'] = \ + NotifyOpsgenie.unquote(results['qsd']['apikey']) + + if 'tags' in results['qsd'] and len(results['qsd']['tags']): + # Extract our tags + results['tags'] = \ + parse_list(NotifyOpsgenie.unquote(results['qsd']['tags'])) + + if 'region' in results['qsd'] and len(results['qsd']['region']): + # Extract our region + results['region_name'] = \ + NotifyOpsgenie.unquote(results['qsd']['region']) + + if 'entity' in results['qsd'] and len(results['qsd']['entity']): + # Extract optional entity field + results['entity'] = \ + NotifyOpsgenie.unquote(results['qsd']['entity']) + + if 'alias' in results['qsd'] and len(results['qsd']['alias']): + # Extract optional alias field + results['alias'] = \ + NotifyOpsgenie.unquote(results['qsd']['alias']) + + # Handle 'to' email address + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'].append(results['qsd']['to']) + + return results diff --git a/lib/apprise/plugins/NotifyPagerDuty.py b/lib/apprise/plugins/NotifyPagerDuty.py new file mode 100644 index 0000000..1592f93 --- /dev/null +++ b/lib/apprise/plugins/NotifyPagerDuty.py @@ -0,0 +1,538 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# API Refererence: +# - https://developer.pagerduty.com/api-reference/\ +# 368ae3d938c9e-send-an-event-to-pager-duty +# + +import requests +from json import dumps + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..common import NotifyImageSize +from ..utils import validate_regex +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + + +class PagerDutySeverity: + """ + Defines the Pager Duty Severity Levels + """ + INFO = 'info' + + WARNING = 'warning' + + ERROR = 'error' + + CRITICAL = 'critical' + + +# Map all support Apprise Categories with the Pager Duty ones +PAGERDUTY_SEVERITY_MAP = { + NotifyType.INFO: PagerDutySeverity.INFO, + NotifyType.SUCCESS: PagerDutySeverity.INFO, + NotifyType.WARNING: PagerDutySeverity.WARNING, + NotifyType.FAILURE: PagerDutySeverity.CRITICAL, +} + +PAGERDUTY_SEVERITIES = ( + PagerDutySeverity.INFO, + PagerDutySeverity.WARNING, + PagerDutySeverity.CRITICAL, + PagerDutySeverity.ERROR, +) + + +# Priorities +class PagerDutyRegion: + US = 'us' + EU = 'eu' + + +# SparkPost APIs +PAGERDUTY_API_LOOKUP = { + PagerDutyRegion.US: 'https://events.pagerduty.com/v2/enqueue', + PagerDutyRegion.EU: 'https://events.eu.pagerduty.com/v2/enqueue', +} + +# A List of our regions we can use for verification +PAGERDUTY_REGIONS = ( + PagerDutyRegion.US, + PagerDutyRegion.EU, +) + + +class NotifyPagerDuty(NotifyBase): + """ + A wrapper for Pager Duty Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Pager Duty' + + # The services URL + service_url = 'https://pagerduty.com/' + + # Secure Protocol + secure_protocol = 'pagerduty' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pagerduty' + + # We don't support titles for Pager Duty notifications + title_maxlen = 0 + + # Allows the user to specify the NotifyImageSize object; this is supported + # through the webhook + image_size = NotifyImageSize.XY_128 + + # Our event action type + event_action = 'trigger' + + # The default region to use if one isn't otherwise specified + default_region = PagerDutyRegion.US + + # Define object templates + templates = ( + '{schema}://{integrationkey}@{apikey}', + '{schema}://{integrationkey}@{apikey}/{source}', + '{schema}://{integrationkey}@{apikey}/{source}/{component}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'private': True, + 'required': True + }, + # Optional but triggers V2 API + 'integrationkey': { + 'name': _('Integration Key'), + 'type': 'string', + 'private': True, + 'required': True + }, + 'source': { + # Optional Source Identifier (preferably a FQDN) + 'name': _('Source'), + 'type': 'string', + 'default': 'Apprise', + }, + 'component': { + # Optional Component Identifier + 'name': _('Component'), + 'type': 'string', + 'default': 'Notification', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'group': { + 'name': _('Group'), + 'type': 'string', + }, + 'class': { + 'name': _('Class'), + 'type': 'string', + 'map_to': 'class_id', + }, + 'click': { + 'name': _('Click'), + 'type': 'string', + }, + 'region': { + 'name': _('Region Name'), + 'type': 'choice:string', + 'values': PAGERDUTY_REGIONS, + 'default': PagerDutyRegion.US, + 'map_to': 'region_name', + }, + # The severity is automatically determined, however you can optionally + # over-ride its value and force it to be what you want + 'severity': { + 'name': _('Severity'), + 'type': 'choice:string', + 'values': PAGERDUTY_SEVERITIES, + 'map_to': 'severity', + }, + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': True, + 'map_to': 'include_image', + }, + }) + + # Define any kwargs we're using + template_kwargs = { + 'details': { + 'name': _('Custom Details'), + 'prefix': '+', + }, + } + + def __init__(self, apikey, integrationkey=None, source=None, + component=None, group=None, class_id=None, + include_image=True, click=None, details=None, + region_name=None, severity=None, **kwargs): + """ + Initialize Pager Duty Object + """ + super().__init__(**kwargs) + + # Long-Lived Access token (generated from User Profile) + self.apikey = validate_regex(apikey) + if not self.apikey: + msg = 'An invalid Pager Duty API Key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + self.integration_key = validate_regex(integrationkey) + if not self.integration_key: + msg = 'An invalid Pager Duty Routing Key ' \ + '({}) was specified.'.format(integrationkey) + self.logger.warning(msg) + raise TypeError(msg) + + # An Optional Source + self.source = self.template_tokens['source']['default'] + if source: + self.source = validate_regex(source) + if not self.source: + msg = 'An invalid Pager Duty Notification Source ' \ + '({}) was specified.'.format(source) + self.logger.warning(msg) + raise TypeError(msg) + else: + self.component = self.template_tokens['source']['default'] + + # An Optional Component + self.component = self.template_tokens['component']['default'] + if component: + self.component = validate_regex(component) + if not self.component: + msg = 'An invalid Pager Duty Notification Component ' \ + '({}) was specified.'.format(component) + self.logger.warning(msg) + raise TypeError(msg) + else: + self.component = self.template_tokens['component']['default'] + + # Store our region + try: + self.region_name = self.default_region \ + if region_name is None else region_name.lower() + + if self.region_name not in PAGERDUTY_REGIONS: + # allow the outer except to handle this common response + raise + except: + # Invalid region specified + msg = 'The PagerDuty region specified ({}) is invalid.' \ + .format(region_name) + self.logger.warning(msg) + raise TypeError(msg) + + # The severity (if specified) + self.severity = \ + None if severity is None else next(( + s for s in PAGERDUTY_SEVERITIES + if str(s).lower().startswith(severity)), False) + + if self.severity is False: + # Invalid severity specified + msg = 'The PagerDuty severity specified ({}) is invalid.' \ + .format(severity) + self.logger.warning(msg) + raise TypeError(msg) + + # A clickthrough option for notifications + self.click = click + + # Store Class ID if specified + self.class_id = class_id + + # Store Group if specified + self.group = group + + self.details = {} + if details: + # Store our extra details + self.details.update(details) + + # Display our Apprise Image + self.include_image = include_image + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Send our PagerDuty Notification + """ + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + 'Authorization': 'Token token={}'.format(self.apikey), + } + + # Prepare our persistent_notification.create payload + payload = { + # Define our integration key + 'routing_key': self.integration_key, + + # Prepare our payload + 'payload': { + 'summary': body, + + # Set our severity + 'severity': PAGERDUTY_SEVERITY_MAP[notify_type] + if not self.severity else self.severity, + + # Our Alerting Source/Component + 'source': self.source, + 'component': self.component, + }, + 'client': self.app_id, + # Our Event Action + 'event_action': self.event_action, + } + + if self.group: + payload['payload']['group'] = self.group + + if self.class_id: + payload['payload']['class'] = self.class_id + + if self.click: + payload['links'] = [{ + "href": self.click, + }] + + # 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: + payload['images'] = [{ + 'src': image_url, + 'alt': notify_type, + }] + + if self.details: + payload['payload']['custom_details'] = {} + # Apply any provided custom details + for k, v in self.details.items(): + payload['payload']['custom_details'][k] = v + + # Prepare our URL based on region + notify_url = PAGERDUTY_API_LOOKUP[self.region_name] + + self.logger.debug('Pager Duty POST URL: %s (cert_verify=%r)' % ( + notify_url, self.verify_certificate, + )) + self.logger.debug('Pager Duty Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + notify_url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code not in ( + requests.codes.ok, requests.codes.created, + requests.codes.accepted): + # We had a problem + status_str = \ + NotifyPagerDuty.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send Pager Duty notification: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info('Sent Pager Duty notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Pager Duty ' + 'notification to %s.' % self.host) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'region': self.region_name, + 'image': 'yes' if self.include_image else 'no', + } + if self.class_id: + params['class'] = self.class_id + + if self.group: + params['group'] = self.group + + if self.click is not None: + params['click'] = self.click + + if self.severity: + params['severity'] = self.severity + + # Append our custom entries our parameters + params.update({'+{}'.format(k): v for k, v in self.details.items()}) + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + url = '{schema}://{integration_key}@{apikey}/' \ + '{source}/{component}?{params}' + + return url.format( + schema=self.secure_protocol, + # never encode hostname since we're expecting it to be a valid one + integration_key=self.pprint( + self.integration_key, privacy, mode=PrivacyMode.Secret, + safe=''), + apikey=self.pprint( + self.apikey, privacy, mode=PrivacyMode.Secret, safe=''), + source=self.pprint( + self.source, privacy, safe=''), + component=self.pprint( + self.component, privacy, safe=''), + params=NotifyPagerDuty.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, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + # The 'apikey' makes it easier to use yaml configuration + if 'apikey' in results['qsd'] and len(results['qsd']['apikey']): + results['apikey'] = \ + NotifyPagerDuty.unquote(results['qsd']['apikey']) + else: + results['apikey'] = NotifyPagerDuty.unquote(results['host']) + + # The 'integrationkey' makes it easier to use yaml configuration + if 'integrationkey' in results['qsd'] and \ + len(results['qsd']['integrationkey']): + results['integrationkey'] = \ + NotifyPagerDuty.unquote(results['qsd']['integrationkey']) + else: + results['integrationkey'] = \ + NotifyPagerDuty.unquote(results['user']) + + if 'click' in results['qsd'] and len(results['qsd']['click']): + results['click'] = NotifyPagerDuty.unquote(results['qsd']['click']) + + if 'group' in results['qsd'] and len(results['qsd']['group']): + results['group'] = \ + NotifyPagerDuty.unquote(results['qsd']['group']) + + if 'class' in results['qsd'] and len(results['qsd']['class']): + results['class_id'] = \ + NotifyPagerDuty.unquote(results['qsd']['class']) + + if 'severity' in results['qsd'] and len(results['qsd']['severity']): + results['severity'] = \ + NotifyPagerDuty.unquote(results['qsd']['severity']) + + # Acquire our full path + fullpath = NotifyPagerDuty.split_path(results['fullpath']) + + # Get our source + if 'source' in results['qsd'] and len(results['qsd']['source']): + results['source'] = \ + NotifyPagerDuty.unquote(results['qsd']['source']) + else: + results['source'] = fullpath.pop(0) if fullpath else None + + # Get our component + if 'component' in results['qsd'] and len(results['qsd']['component']): + results['component'] = \ + NotifyPagerDuty.unquote(results['qsd']['component']) + else: + results['component'] = fullpath.pop(0) if fullpath else None + + # Add our custom details key/value pairs that the user can potentially + # over-ride if they wish to to our returned result set and tidy + # entries by unquoting them + results['details'] = { + NotifyPagerDuty.unquote(x): NotifyPagerDuty.unquote(y) + for x, y in results['qsd+'].items()} + + if 'region' in results['qsd'] and len(results['qsd']['region']): + # Extract from name to associate with from address + results['region_name'] = \ + NotifyPagerDuty.unquote(results['qsd']['region']) + + # Include images with our message + results['include_image'] = \ + parse_bool(results['qsd'].get('image', True)) + + return results diff --git a/lib/apprise/plugins/NotifyPagerTree.py b/lib/apprise/plugins/NotifyPagerTree.py new file mode 100644 index 0000000..a1579c3 --- /dev/null +++ b/lib/apprise/plugins/NotifyPagerTree.py @@ -0,0 +1,420 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 json import dumps + +from uuid import uuid4 + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import parse_list +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +# Actions +class PagerTreeAction: + CREATE = 'create' + ACKNOWLEDGE = 'acknowledge' + RESOLVE = 'resolve' + + +# Urgencies +class PagerTreeUrgency: + SILENT = "silent" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +PAGERTREE_ACTIONS = { + PagerTreeAction.CREATE: 'create', + PagerTreeAction.ACKNOWLEDGE: 'acknowledge', + PagerTreeAction.RESOLVE: 'resolve', +} + +PAGERTREE_URGENCIES = { + # Note: This also acts as a reverse lookup mapping + PagerTreeUrgency.SILENT: 'silent', + PagerTreeUrgency.LOW: 'low', + PagerTreeUrgency.MEDIUM: 'medium', + PagerTreeUrgency.HIGH: 'high', + PagerTreeUrgency.CRITICAL: 'critical', +} +# Extend HTTP Error Messages +PAGERTREE_HTTP_ERROR_MAP = { + 402: 'Payment Required - Please subscribe or upgrade', + 403: 'Forbidden - Blocked', + 404: 'Not Found - Invalid Integration ID', + 405: 'Method Not Allowed - Integration Disabled', + 429: 'Too Many Requests - Rate Limit Exceeded', +} + + +class NotifyPagerTree(NotifyBase): + """ + A wrapper for PagerTree Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'PagerTree' + + # The services URL + service_url = 'https://pagertree.com/' + + # All PagerTree requests are secure + secure_protocol = 'pagertree' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pagertree' + + # PagerTree uses the http protocol with JSON requests + notify_url = 'https://api.pagertree.com/integration/{}' + + # Define object templates + templates = ( + '{schema}://{integration}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'integration': { + 'name': _('Integration ID'), + 'type': 'string', + 'private': True, + 'required': True, + } + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'action': { + 'name': _('Action'), + 'type': 'choice:string', + 'values': PAGERTREE_ACTIONS, + 'default': PagerTreeAction.CREATE, + }, + 'thirdparty': { + 'name': _('Third Party ID'), + 'type': 'string', + }, + 'urgency': { + 'name': _('Urgency'), + 'type': 'choice:string', + 'values': PAGERTREE_URGENCIES, + }, + 'tags': { + 'name': _('Tags'), + 'type': 'string', + }, + }) + + # Define any kwargs we're using + template_kwargs = { + 'headers': { + 'name': _('HTTP Header'), + 'prefix': '+', + }, + 'payload_extras': { + 'name': _('Payload Extras'), + 'prefix': ':', + }, + 'meta_extras': { + 'name': _('Meta Extras'), + 'prefix': '-', + }, + } + + def __init__(self, integration, action=None, thirdparty=None, + urgency=None, tags=None, headers=None, + payload_extras=None, meta_extras=None, **kwargs): + """ + Initialize PagerTree Object + """ + super().__init__(**kwargs) + + # Integration ID (associated with account) + self.integration = \ + validate_regex(integration, r'^int_[a-zA-Z0-9\-_]{7,14}$') + if not self.integration: + msg = 'An invalid PagerTree Integration ID ' \ + '({}) was specified.'.format(integration) + self.logger.warning(msg) + raise TypeError(msg) + + # thirdparty (optional, in case they want to pass the + # acknowledge or resolve action) + self.thirdparty = None + if thirdparty: + # An id was specified, we want to validate it + self.thirdparty = validate_regex(thirdparty) + if not self.thirdparty: + msg = 'An invalid PagerTree third party ID ' \ + '({}) was specified.'.format(thirdparty) + self.logger.warning(msg) + raise TypeError(msg) + + self.headers = {} + if headers: + # Store our extra headers + self.headers.update(headers) + + self.payload_extras = {} + if payload_extras: + # Store our extra payload entries + self.payload_extras.update(payload_extras) + + self.meta_extras = {} + if meta_extras: + # Store our extra payload entries + self.meta_extras.update(meta_extras) + + # Setup our action + self.action = NotifyPagerTree.template_args['action']['default'] \ + if action not in PAGERTREE_ACTIONS else \ + PAGERTREE_ACTIONS[action] + + # Setup our urgency + self.urgency = \ + None if urgency not in PAGERTREE_URGENCIES else \ + PAGERTREE_URGENCIES[urgency] + + # Any optional tags to attach to the notification + self.__tags = parse_list(tags) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform PagerTree Notification + """ + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + } + + # Apply any/all header over-rides defined + # For things like PagerTree Token + headers.update(self.headers) + + # prepare JSON Object + payload = { + # Generate an ID (unless one was explicitly forced to be used) + 'id': self.thirdparty if self.thirdparty else str(uuid4()), + 'event_type': self.action, + } + + if self.action == PagerTreeAction.CREATE: + payload['title'] = title if title else self.app_desc + payload['description'] = body + + payload['meta'] = self.meta_extras + payload['tags'] = self.__tags + + if self.urgency is not None: + payload['urgency'] = self.urgency + + # Apply any/all payload over-rides defined + payload.update(self.payload_extras) + + # Prepare our URL based on integration + notify_url = self.notify_url.format(self.integration) + + self.logger.debug('PagerTree POST URL: %s (cert_verify=%r)' % ( + notify_url, self.verify_certificate, + )) + self.logger.debug('PagerTree Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + notify_url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code not in ( + requests.codes.ok, requests.codes.created, + requests.codes.accepted): + # We had a problem + status_str = \ + NotifyPagerTree.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send PagerTree notification: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info('Sent PagerTree notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending PagerTree ' + 'notification to %s.' % self.host) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'action': self.action, + } + + if self.thirdparty: + params['tid'] = self.thirdparty + + if self.urgency: + params['urgency'] = self.urgency + + if self.__tags: + params['tags'] = ','.join([x for x in self.__tags]) + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Headers prefixed with a '+' sign + # Append our headers into our parameters + params.update({'+{}'.format(k): v for k, v in self.headers.items()}) + + # Meta: {} prefixed with a '-' sign + # Append our meta extras into our parameters + params.update( + {'-{}'.format(k): v for k, v in self.meta_extras.items()}) + + # Payload body extras prefixed with a ':' sign + # Append our payload extras into our parameters + params.update( + {':{}'.format(k): v for k, v in self.payload_extras.items()}) + + return '{schema}://{integration}?{params}'.format( + schema=self.secure_protocol, + # never encode hostname since we're expecting it to be a valid one + integration=self.pprint(self.integration, privacy, safe=''), + params=NotifyPagerTree.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, verify_host=False) + + if not results: + # We're done early as we couldn't load the results + return results + + # Add our headers that the user can potentially over-ride if they wish + # to to our returned result set and tidy entries by unquoting them + results['headers'] = { + NotifyPagerTree.unquote(x): NotifyPagerTree.unquote(y) + for x, y in results['qsd+'].items() + } + + # store any additional payload extra's defined + results['payload_extras'] = { + NotifyPagerTree.unquote(x): NotifyPagerTree.unquote(y) + for x, y in results['qsd:'].items() + } + + # store any additional meta extra's defined + results['meta_extras'] = { + NotifyPagerTree.unquote(x): NotifyPagerTree.unquote(y) + for x, y in results['qsd-'].items() + } + + # Integration ID + if 'id' in results['qsd'] and len(results['qsd']['id']): + # Shortened version of integration id + results['integration'] = \ + NotifyPagerTree.unquote(results['qsd']['id']) + + elif 'integration' in results['qsd'] and \ + len(results['qsd']['integration']): + results['integration'] = \ + NotifyPagerTree.unquote(results['qsd']['integration']) + + else: + results['integration'] = \ + NotifyPagerTree.unquote(results['host']) + + # Set our thirdparty + + if 'tid' in results['qsd'] and len(results['qsd']['tid']): + # Shortened version of thirdparty + results['thirdparty'] = \ + NotifyPagerTree.unquote(results['qsd']['tid']) + + elif 'thirdparty' in results['qsd'] and \ + len(results['qsd']['thirdparty']): + results['thirdparty'] = \ + NotifyPagerTree.unquote(results['qsd']['thirdparty']) + + # Set our urgency + if 'action' in results['qsd'] and \ + len(results['qsd']['action']): + results['action'] = \ + NotifyPagerTree.unquote(results['qsd']['action']) + + # Set our urgency + if 'urgency' in results['qsd'] and len(results['qsd']['urgency']): + results['urgency'] = \ + NotifyPagerTree.unquote(results['qsd']['urgency']) + + # Set our tags + if 'tags' in results['qsd'] and len(results['qsd']['tags']): + results['tags'] = \ + parse_list(NotifyPagerTree.unquote(results['qsd']['tags'])) + + return results diff --git a/lib/apprise/plugins/NotifyParsePlatform.py b/lib/apprise/plugins/NotifyParsePlatform.py new file mode 100644 index 0000000..f3d7d63 --- /dev/null +++ b/lib/apprise/plugins/NotifyParsePlatform.py @@ -0,0 +1,320 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 re +import requests +from json import dumps + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + +# Used to break path apart into list of targets +TARGET_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') + + +# Priorities +class ParsePlatformDevice: + # All Devices + ALL = 'all' + + # Apple IOS (APNS) + IOS = 'ios' + + # Android/Firebase (FCM) + ANDROID = 'android' + + +PARSE_PLATFORM_DEVICES = ( + ParsePlatformDevice.ALL, + ParsePlatformDevice.IOS, + ParsePlatformDevice.ANDROID, +) + + +class NotifyParsePlatform(NotifyBase): + """ + A wrapper for Parse Platform Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Parse Platform' + + # The services URL + service_url = ' https://parseplatform.org/' + + # insecure notifications (using http) + protocol = 'parsep' + + # Secure notifications (using https) + secure_protocol = 'parseps' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_parseplatform' + + # Define object templates + templates = ( + '{schema}://{app_id}:{master_key}@{host}', + '{schema}://{app_id}:{master_key}@{host}:{port}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'app_id': { + 'name': _('App ID'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'master_key': { + 'name': _('Master Key'), + 'type': 'string', + 'private': True, + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'device': { + 'name': _('Device'), + 'type': 'choice:string', + 'values': PARSE_PLATFORM_DEVICES, + 'default': ParsePlatformDevice.ALL, + }, + 'app_id': { + 'alias_of': 'app_id', + }, + 'master_key': { + 'alias_of': 'master_key', + }, + }) + + def __init__(self, app_id, master_key, device=None, **kwargs): + """ + Initialize Parse Platform Object + """ + super().__init__(**kwargs) + + self.fullpath = kwargs.get('fullpath') + if not isinstance(self.fullpath, str): + self.fullpath = '/' + + # Application ID + self.application_id = validate_regex(app_id) + if not self.application_id: + msg = 'An invalid Parse Platform Application ID ' \ + '({}) was specified.'.format(app_id) + self.logger.warning(msg) + raise TypeError(msg) + + # Master Key + self.master_key = validate_regex(master_key) + if not self.master_key: + msg = 'An invalid Parse Platform Master Key ' \ + '({}) was specified.'.format(master_key) + self.logger.warning(msg) + raise TypeError(msg) + + # Initialize Devices Array + self.devices = [] + + if device: + self.device = device.lower() + if device not in PARSE_PLATFORM_DEVICES: + msg = 'An invalid Parse Platform device ' \ + '({}) was specified.'.format(device) + self.logger.warning(msg) + raise TypeError(msg) + else: + self.device = self.template_args['device']['default'] + + if self.device == ParsePlatformDevice.ALL: + self.devices = [d for d in PARSE_PLATFORM_DEVICES + if d != ParsePlatformDevice.ALL] + else: + # Store our device + self.devices.append(device) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Parse Platform Notification + """ + + # Prepare our headers: + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': self.application_id, + 'X-Parse-Master-Key': self.master_key, + } + + # prepare our payload + payload = { + 'where': { + 'deviceType': { + '$in': self.devices, + } + }, + 'data': { + 'title': title, + 'alert': body, + } + } + + # Set our schema + schema = 'https' if self.secure else 'http' + + # Our Notification URL + url = '%s://%s' % (schema, self.host) + if isinstance(self.port, int): + url += ':%d' % self.port + + url += self.fullpath.rstrip('/') + '/parse/push/' + + self.logger.debug('Parse Platform POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('Parse Platform 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, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = NotifyParsePlatform.\ + http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send Parse Platform notification: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info('Sent Parse Platform notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending Parse Platform ' + 'notification to %s.' % self.host) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + params = { + 'device': self.device, + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + default_port = 443 if self.secure else 80 + + return \ + '{schema}://{app_id}:{master_key}@' \ + '{hostname}{port}{fullpath}/?{params}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + app_id=self.pprint(self.application_id, privacy, safe=''), + master_key=self.pprint(self.master_key, privacy, safe=''), + hostname=NotifyParsePlatform.quote(self.host, safe=''), + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + fullpath=NotifyParsePlatform.quote(self.fullpath, safe='/'), + params=NotifyParsePlatform.urlencode(params)) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to substantiate this object. + + """ + results = NotifyBase.parse_url(url) + + if not results: + # We're done early as we couldn't load the results + return results + + # App ID is retrieved from the user + results['app_id'] = NotifyParsePlatform.unquote(results['user']) + + # Master Key is retrieved from the password + results['master_key'] = \ + NotifyParsePlatform.unquote(results['password']) + + # Device support override + if 'device' in results['qsd'] and len(results['qsd']['device']): + results['device'] = results['qsd']['device'] + + # Allow app_id attribute over-ride + if 'app_id' in results['qsd'] and len(results['qsd']['app_id']): + results['app_id'] = results['qsd']['app_id'] + + # Allow master_key attribute over-ride + if 'master_key' in results['qsd'] \ + and len(results['qsd']['master_key']): + results['master_key'] = results['qsd']['master_key'] + + return results diff --git a/lib/apprise/plugins/NotifyPopcornNotify.py b/lib/apprise/plugins/NotifyPopcornNotify.py new file mode 100644 index 0000000..47a2961 --- /dev/null +++ b/lib/apprise/plugins/NotifyPopcornNotify.py @@ -0,0 +1,311 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import is_email +from ..utils import is_phone_no +from ..utils import parse_list +from ..utils import parse_bool +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +class NotifyPopcornNotify(NotifyBase): + """ + A wrapper for PopcornNotify Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'PopcornNotify' + + # The services URL + service_url = 'https://popcornnotify.com/' + + # The default protocol + secure_protocol = 'popcorn' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_popcornnotify' + + # PopcornNotify uses the http protocol + notify_url = 'https://popcornnotify.com/notify' + + # The maximum targets to include when doing batch transfers + default_batch_size = 10 + + # Define object templates + templates = ( + '{schema}://{apikey}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'regex': (r'^[a-z0-9]+$', 'i'), + 'required': True, + }, + 'target_phone': { + 'name': _('Target Phone No'), + 'type': 'string', + 'prefix': '+', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + 'map_to': 'targets', + }, + 'target_email': { + 'name': _('Target Email'), + 'type': 'string', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + 'required': True, + } + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + }) + + def __init__(self, apikey, targets=None, batch=False, **kwargs): + """ + Initialize PopcornNotify Object + """ + super().__init__(**kwargs) + + # Access Token (associated with project) + self.apikey = validate_regex( + apikey, *self.template_tokens['apikey']['regex']) + if not self.apikey: + msg = 'An invalid PopcornNotify API Key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # Prepare Batch Mode Flag + self.batch = batch + + # Parse our targets + self.targets = list() + + for target in parse_list(targets): + # Validate targets and drop bad ones: + result = is_phone_no(target) + if result: + # store valid phone number + self.targets.append(result['full']) + continue + + result = is_email(target) + if result: + # store valid email + self.targets.append(result['full_email']) + continue + + self.logger.warning( + 'Dropped invalid target ' + '({}) specified.'.format(target), + ) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform PopcornNotify Notification + """ + + if len(self.targets) == 0: + # There were no services to notify + self.logger.warning( + 'There were no PopcornNotify targets to notify.') + return False + + # error tracking (used for function return) + has_error = False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/x-www-form-urlencoded', + } + + # Prepare our payload + payload = { + 'message': body, + 'subject': title, + } + + auth = (self.apikey, None) + + # Send in batches if identified to do so + batch_size = 1 if not self.batch else self.default_batch_size + + for index in range(0, len(self.targets), batch_size): + # Prepare our recipients + payload['recipients'] = \ + ','.join(self.targets[index:index + batch_size]) + + self.logger.debug('PopcornNotify POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('PopcornNotify Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + self.notify_url, + auth=auth, + data=payload, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyPopcornNotify.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send {} PopcornNotify notification{}: ' + '{}{}error={}.'.format( + len(self.targets[index:index + batch_size]), + ' to {}'.format(self.targets[index]) + if batch_size == 1 else '(s)', + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent {} PopcornNotify notification{}.' + .format( + len(self.targets[index:index + batch_size]), + ' to {}'.format(self.targets[index]) + if batch_size == 1 else '(s)', + )) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending {} PopcornNotify ' + 'notification(s).'.format( + len(self.targets[index:index + batch_size]))) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + 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 = { + 'batch': 'yes' if self.batch else 'no', + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{apikey}/{targets}/?{params}'.format( + schema=self.secure_protocol, + apikey=self.pprint(self.apikey, privacy, safe=''), + targets='/'.join( + [NotifyPopcornNotify.quote(x, safe='') for x in self.targets]), + params=NotifyPopcornNotify.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + batch_size = 1 if not self.batch else self.default_batch_size + targets = len(self.targets) + if batch_size > 1: + targets = int(targets / batch_size) + \ + (1 if targets % batch_size else 0) + + return 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 + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'] = \ + NotifyPopcornNotify.split_path(results['fullpath']) + + # The hostname is our authentication key + results['apikey'] = NotifyPopcornNotify.unquote(results['host']) + + # Support the 'to' variable so that we can support targets this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyPopcornNotify.parse_list(results['qsd']['to']) + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get('batch', False)) + + return results diff --git a/lib/apprise/plugins/NotifyProwl.py b/lib/apprise/plugins/NotifyProwl.py new file mode 100644 index 0000000..80f0aca --- /dev/null +++ b/lib/apprise/plugins/NotifyProwl.py @@ -0,0 +1,304 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +# Priorities +class ProwlPriority: + LOW = -2 + MODERATE = -1 + NORMAL = 0 + HIGH = 1 + EMERGENCY = 2 + + +PROWL_PRIORITIES = { + # Note: This also acts as a reverse lookup mapping + ProwlPriority.LOW: 'low', + ProwlPriority.MODERATE: 'moderate', + ProwlPriority.NORMAL: 'normal', + ProwlPriority.HIGH: 'high', + ProwlPriority.EMERGENCY: 'emergency', +} + +PROWL_PRIORITY_MAP = { + # Maps against string 'low' + 'l': ProwlPriority.LOW, + # Maps against string 'moderate' + 'm': ProwlPriority.MODERATE, + # Maps against string 'normal' + 'n': ProwlPriority.NORMAL, + # Maps against string 'high' + 'h': ProwlPriority.HIGH, + # Maps against string 'emergency' + 'e': ProwlPriority.EMERGENCY, + + # Entries to additionally support (so more like Prowl's API) + '-2': ProwlPriority.LOW, + '-1': ProwlPriority.MODERATE, + '0': ProwlPriority.NORMAL, + '1': ProwlPriority.HIGH, + '2': ProwlPriority.EMERGENCY, +} + +# Provide some known codes Prowl uses and what they translate to: +PROWL_HTTP_ERROR_MAP = { + 406: 'IP address has exceeded API limit', + 409: 'Request not aproved.', +} + + +class NotifyProwl(NotifyBase): + """ + A wrapper for Prowl Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Prowl' + + # The services URL + service_url = 'https://www.prowlapp.com/' + + # The default secure protocol + secure_protocol = 'prowl' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_prowl' + + # Prowl uses the http protocol with JSON requests + notify_url = 'https://api.prowlapp.com/publicapi/add' + + # Disable throttle rate for Prowl requests since they are normally + # local anyway + request_rate_per_sec = 0 + + # The maximum allowable characters allowed in the body per message + body_maxlen = 10000 + + # Defines the maximum allowable characters in the title + title_maxlen = 1024 + + # Define object templates + templates = ( + '{schema}://{apikey}', + '{schema}://{apikey}/{providerkey}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[A-Za-z0-9]{40}$', 'i'), + }, + 'providerkey': { + 'name': _('Provider Key'), + 'type': 'string', + 'private': True, + 'regex': (r'^[A-Za-z0-9]{40}$', 'i'), + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'priority': { + 'name': _('Priority'), + 'type': 'choice:int', + 'values': PROWL_PRIORITIES, + 'default': ProwlPriority.NORMAL, + }, + }) + + def __init__(self, apikey, providerkey=None, priority=None, **kwargs): + """ + Initialize Prowl Object + """ + super().__init__(**kwargs) + + # The Priority of the message + self.priority = NotifyProwl.template_args['priority']['default'] \ + if not priority else \ + next(( + v for k, v in PROWL_PRIORITY_MAP.items() + if str(priority).lower().startswith(k)), + NotifyProwl.template_args['priority']['default']) + + # API Key (associated with project) + self.apikey = validate_regex( + apikey, *self.template_tokens['apikey']['regex']) + if not self.apikey: + msg = 'An invalid Prowl API Key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # Store the provider key (if specified) + if providerkey: + self.providerkey = validate_regex( + providerkey, *self.template_tokens['providerkey']['regex']) + if not self.providerkey: + msg = 'An invalid Prowl Provider Key ' \ + '({}) was specified.'.format(providerkey) + self.logger.warning(msg) + raise TypeError(msg) + + else: + # No provider key was set + self.providerkey = None + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Prowl Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-type': "application/x-www-form-urlencoded", + } + + # prepare JSON Object + payload = { + 'apikey': self.apikey, + 'application': self.app_id, + 'event': title, + 'description': body, + 'priority': self.priority, + } + + if self.providerkey: + payload['providerkey'] = self.providerkey + + self.logger.debug('Prowl POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('Prowl Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + self.notify_url, + data=payload, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyBase.http_response_code_lookup( + r.status_code, PROWL_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send Prowl notification:' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info('Sent Prowl notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Prowl notification.') + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'priority': + PROWL_PRIORITIES[self.template_args['priority']['default']] + if self.priority not in PROWL_PRIORITIES + else PROWL_PRIORITIES[self.priority], + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{apikey}/{providerkey}/?{params}'.format( + schema=self.secure_protocol, + apikey=self.pprint(self.apikey, privacy, safe=''), + providerkey=self.pprint(self.providerkey, privacy, safe=''), + params=NotifyProwl.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, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + # Set the API Key + results['apikey'] = NotifyProwl.unquote(results['host']) + + # Optionally try to find the provider key + try: + results['providerkey'] = \ + NotifyProwl.split_path(results['fullpath'])[0] + + except IndexError: + pass + + # Set our priority + if 'priority' in results['qsd'] and len(results['qsd']['priority']): + results['priority'] = \ + NotifyProwl.unquote(results['qsd']['priority']) + + return results diff --git a/lib/apprise/plugins/NotifyPushBullet.py b/lib/apprise/plugins/NotifyPushBullet.py new file mode 100644 index 0000000..61e8db2 --- /dev/null +++ b/lib/apprise/plugins/NotifyPushBullet.py @@ -0,0 +1,440 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 json import dumps +from json import loads + +from .NotifyBase import NotifyBase +from ..utils import is_email +from ..common import NotifyType +from ..utils import parse_list +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ +from ..attachment.AttachBase import AttachBase + +# Flag used as a placeholder to sending to all devices +PUSHBULLET_SEND_TO_ALL = 'ALL_DEVICES' + +# Provide some known codes Pushbullet uses and what they translate to: +PUSHBULLET_HTTP_ERROR_MAP = { + 401: 'Unauthorized - Invalid Token.', +} + + +class NotifyPushBullet(NotifyBase): + """ + A wrapper for PushBullet Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Pushbullet' + + # The services URL + service_url = 'https://www.pushbullet.com/' + + # The default secure protocol + secure_protocol = 'pbul' + + # Allow 50 requests per minute (Tier 2). + # 60/50 = 0.2 + request_rate_per_sec = 1.2 + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pushbullet' + + # PushBullet uses the http protocol with JSON requests + notify_url = 'https://api.pushbullet.com/v2/{}' + + # Support attachments + attachment_support = True + + # Define object templates + templates = ( + '{schema}://{accesstoken}', + '{schema}://{accesstoken}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'accesstoken': { + 'name': _('Access Token'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'target_device': { + 'name': _('Target Device'), + 'type': 'string', + 'map_to': 'targets', + }, + 'target_channel': { + 'name': _('Target Channel'), + 'type': 'string', + 'prefix': '#', + 'map_to': 'targets', + }, + 'target_email': { + 'name': _('Target Email'), + 'type': 'string', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + }) + + def __init__(self, accesstoken, targets=None, **kwargs): + """ + Initialize PushBullet Object + """ + super().__init__(**kwargs) + + # Access Token (associated with project) + self.accesstoken = validate_regex(accesstoken) + if not self.accesstoken: + msg = 'An invalid PushBullet Access Token ' \ + '({}) was specified.'.format(accesstoken) + self.logger.warning(msg) + raise TypeError(msg) + + self.targets = parse_list(targets) + if len(self.targets) == 0: + self.targets = (PUSHBULLET_SEND_TO_ALL, ) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): + """ + Perform PushBullet Notification + """ + + # error tracking (used for function return) + has_error = False + + # Build a list of our attachments + attachments = [] + + if attach and self.attachment_support: + # We need to upload our payload first so that we can source it + # in remaining messages + 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 + + self.logger.debug( + 'Preparing PushBullet attachment {}'.format( + attachment.url(privacy=True))) + + # prepare payload + payload = { + 'file_name': attachment.name, + 'file_type': attachment.mimetype, + } + # First thing we need to do is make a request so that we can + # get a URL to post our request to. + # see: https://docs.pushbullet.com/#upload-request + okay, response = self._send( + self.notify_url.format('upload-request'), payload) + if not okay: + # We can't post our attachment + return False + + # If we get here, our output will look something like this: + # { + # "file_name": "cat.jpg", + # "file_type": "image/jpeg", + # "file_url": "https://dl.pushb.com/abc/cat.jpg", + # "upload_url": "https://upload.pushbullet.com/abcd123" + # } + + # - The file_url is where the file will be available after it + # is uploaded. + # - The upload_url is where to POST the file to. The file must + # be posted using multipart/form-data encoding. + + # Prepare our attachment payload; we'll use this if we + # successfully upload the content below for later on. + try: + # By placing this in a try/except block we can validate + # our response at the same time as preparing our payload + payload = { + # PushBullet v2/pushes file type: + 'type': 'file', + 'file_name': response['file_name'], + 'file_type': response['file_type'], + 'file_url': response['file_url'], + } + + if response['file_type'].startswith('image/'): + # Allow image to be displayed inline (if image type) + payload['image_url'] = response['file_url'] + + upload_url = response['upload_url'] + + except (KeyError, TypeError): + # A method of verifying our content exists + return False + + okay, response = self._send(upload_url, attachment) + if not okay: + # We can't post our attachment + return False + + # Save our pre-prepared payload for attachment posting + attachments.append(payload) + + # Create a copy of the targets list + targets = list(self.targets) + while len(targets): + recipient = targets.pop(0) + + # prepare payload + payload = { + 'type': 'note', + 'title': title, + 'body': body, + } + + # Check if an email was defined + match = is_email(recipient) + if match: + payload['email'] = match['full_email'] + self.logger.debug( + "PushBullet recipient {} parsed as an email address" + .format(recipient)) + + elif recipient is PUSHBULLET_SEND_TO_ALL: + # Send to all + pass + + elif recipient[0] == '#': + payload['channel_tag'] = recipient[1:] + self.logger.debug( + "PushBullet recipient {} parsed as a channel" + .format(recipient)) + + else: + payload['device_iden'] = recipient + self.logger.debug( + "PushBullet recipient {} parsed as a device" + .format(recipient)) + + if body: + okay, response = self._send( + self.notify_url.format('pushes'), payload) + if not okay: + has_error = True + continue + + self.logger.info( + 'Sent PushBullet notification to "%s".' % (recipient)) + + for attach_payload in attachments: + # Send our attachments to our same user (already prepared as + # our payload object) + okay, response = self._send( + self.notify_url.format('pushes'), attach_payload) + if not okay: + has_error = True + continue + + self.logger.info( + 'Sent PushBullet attachment ({}) to "{}".'.format( + attach_payload['file_name'], recipient)) + + return not has_error + + def _send(self, url, payload, **kwargs): + """ + Wrapper to the requests (post) object + """ + + headers = { + 'User-Agent': self.app_id, + } + + # Some default values for our request object to which we'll update + # depending on what our payload is + files = None + data = None + + if not isinstance(payload, AttachBase): + # Send our payload as a JSON object + headers['Content-Type'] = 'application/json' + data = dumps(payload) if payload else None + + auth = (self.accesstoken, '') + + self.logger.debug('PushBullet POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('PushBullet Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + # Default response type + response = None + + try: + # Open our attachment path if required: + if isinstance(payload, AttachBase): + files = {'file': (payload.name, open(payload.path, 'rb'))} + + r = requests.post( + url, + data=data, + headers=headers, + files=files, + auth=auth, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + try: + response = loads(r.content) + + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + + # Fall back to the existing unparsed value + response = r.content + + if r.status_code not in ( + requests.codes.ok, requests.codes.no_content): + # We had a problem + status_str = \ + NotifyPushBullet.http_response_code_lookup( + r.status_code, PUSHBULLET_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to deliver payload to PushBullet:' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + return False, response + + # otherwise we were successful + return True, response + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred communicating with PushBullet.') + self.logger.debug('Socket Exception: %s' % str(e)) + + return False, response + + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while handling {}.'.format( + payload.name if isinstance(payload, AttachBase) + else payload)) + self.logger.debug('I/O Exception: %s' % str(e)) + return False, response + + finally: + # Close our file (if it's open) stored in the second element + # of our files tuple (index 1) + if files: + files['file'][1].close() + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Our URL parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + targets = '/'.join([NotifyPushBullet.quote(x) for x in self.targets]) + if targets == PUSHBULLET_SEND_TO_ALL: + # keyword is reserved for internal usage only; it's safe to remove + # it from the recipients list + targets = '' + + return '{schema}://{accesstoken}/{targets}/?{params}'.format( + schema=self.secure_protocol, + accesstoken=self.pprint(self.accesstoken, privacy, safe=''), + targets=targets, + params=NotifyPushBullet.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return 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 + + # Fetch our targets + results['targets'] = \ + NotifyPushBullet.split_path(results['fullpath']) + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyPushBullet.parse_list(results['qsd']['to']) + + # Setup the token; we store it in Access Token for global + # plugin consistency with naming conventions + results['accesstoken'] = NotifyPushBullet.unquote(results['host']) + + return results diff --git a/lib/apprise/plugins/NotifyPushDeer.py b/lib/apprise/plugins/NotifyPushDeer.py new file mode 100644 index 0000000..76805c3 --- /dev/null +++ b/lib/apprise/plugins/NotifyPushDeer.py @@ -0,0 +1,218 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 ..common import NotifyType +from .NotifyBase import NotifyBase +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + +# Syntax: +# schan://{key}/ + + +class NotifyPushDeer(NotifyBase): + """ + A wrapper for PushDeer Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'PushDeer' + + # The services URL + service_url = 'https://www.pushdeer.com/' + + # Insecure Protocol Access + protocol = 'pushdeer' + + # Secure Protocol + secure_protocol = 'pushdeers' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_PushDeer' + + # Default hostname + default_hostname = 'api2.pushdeer.com' + + # PushDeer API + notify_url = '{schema}://{host}:{port}/message/push?pushkey={pushKey}' + + # Define object templates + templates = ( + '{schema}://{pushkey}', + '{schema}://{host}/{pushkey}', + '{schema}://{host}:{port}/{pushkey}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'pushkey': { + 'name': _('Pushkey'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[a-z0-9]+$', 'i'), + }, + }) + + def __init__(self, pushkey, **kwargs): + """ + Initialize PushDeer Object + """ + super().__init__(**kwargs) + + # PushKey (associated with project) + self.push_key = validate_regex( + pushkey, *self.template_tokens['pushkey']['regex']) + if not self.push_key: + msg = 'An invalid PushDeer API Pushkey ' \ + '({}) was specified.'.format(pushkey) + self.logger.warning(msg) + raise TypeError(msg) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform PushDeer Notification + """ + + # Prepare our persistent_notification.create payload + payload = { + 'text': title if title else body, + 'type': 'text', + 'desp': body if title else '', + } + + # Set our schema + schema = 'https' if self.secure else 'http' + + # Set host + host = self.default_hostname + if self.host: + host = self.host + + # Set port + port = 443 if self.secure else 80 + if self.port: + port = self.port + + # Our Notification URL + notify_url = self.notify_url.format( + schema=schema, host=host, port=port, pushKey=self.push_key) + + # Some Debug Logging + self.logger.debug('PushDeer URL: {} (cert_verify={})'.format( + notify_url, self.verify_certificate)) + self.logger.debug('PushDeer Payload: {}'.format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + notify_url, + data=payload, + timeout=self.request_timeout, + ) + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyPushDeer.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send PushDeer notification: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + return False + + else: + self.logger.info('Sent PushDeer notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending PushDeer ' + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + return True + + def url(self, privacy=False): + """ + Returns the URL built dynamically based on specified arguments. + """ + + if self.host: + url = '{schema}://{host}{port}/{pushkey}' + else: + url = '{schema}://{pushkey}' + + return url.format( + schema=self.secure_protocol if self.secure else self.protocol, + host=self.host, + port='' if not self.port else ':{}'.format(self.port), + pushkey=self.pprint(self.push_key, privacy, safe='')) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to substantiate this object. + """ + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early as we couldn't parse the URL + return results + + fullpaths = NotifyPushDeer.split_path(results['fullpath']) + + if len(fullpaths) == 0: + results['pushkey'] = results['host'] + results['host'] = None + else: + results['pushkey'] = fullpaths.pop() + + return results diff --git a/lib/apprise/plugins/NotifyPushMe.py b/lib/apprise/plugins/NotifyPushMe.py new file mode 100644 index 0000000..8ef3c79 --- /dev/null +++ b/lib/apprise/plugins/NotifyPushMe.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 .NotifyBase import NotifyBase +from ..common import NotifyType +from ..common import NotifyFormat +from ..utils import validate_regex +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + + +class NotifyPushMe(NotifyBase): + """ + A wrapper for PushMe Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'PushMe' + + # The services URL + service_url = 'https://push.i-i.me/' + + # Insecure protocol (for those self hosted requests) + protocol = 'pushme' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pushme' + + # PushMe URL + notify_url = 'https://push.i-i.me/' + + # Define object templates + templates = ( + '{schema}://{token}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'token': { + 'name': _('Token'), + 'type': 'string', + 'private': True, + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'token': { + 'alias_of': 'token', + }, + 'push_key': { + 'alias_of': 'token', + }, + 'status': { + 'name': _('Show Status'), + 'type': 'bool', + 'default': True, + }, + }) + + def __init__(self, token, status=None, **kwargs): + """ + Initialize PushMe Object + """ + super().__init__(**kwargs) + + # Token (associated with project) + self.token = validate_regex(token) + if not self.token: + msg = 'An invalid PushMe Token ' \ + '({}) was specified.'.format(token) + self.logger.warning(msg) + raise TypeError(msg) + + # Set Status type + self.status = status + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform PushMe Notification + """ + + headers = { + 'User-Agent': self.app_id, + } + + # Prepare our payload + params = { + 'push_key': self.token, + 'title': title if not self.status + else '{} {}'.format(self.asset.ascii(notify_type), title), + 'content': body, + 'type': 'markdown' + if self.notify_format == NotifyFormat.MARKDOWN else 'text' + } + + self.logger.debug('PushMe POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('PushMe Payload: %s' % str(params)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + self.notify_url, + params=params, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyPushMe.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send PushMe notification:' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info('Sent PushMe notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending PushMe notification.', + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'status': 'yes' if self.status else 'no', + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Official URLs are easy to assemble + return '{schema}://{token}/?{params}'.format( + schema=self.protocol, + token=self.pprint(self.token, privacy, safe=''), + params=NotifyPushMe.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, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + # Store our token using the host + results['token'] = NotifyPushMe.unquote(results['host']) + + # The 'token' makes it easier to use yaml configuration + if 'token' in results['qsd'] and len(results['qsd']['token']): + results['token'] = NotifyPushMe.unquote(results['qsd']['token']) + + elif 'push_key' in results['qsd'] and len(results['qsd']['push_key']): + # Support 'push_key' if specified + results['token'] = NotifyPushMe.unquote(results['qsd']['push_key']) + + # Get status switch + results['status'] = \ + parse_bool(results['qsd'].get('status', True)) + + return results diff --git a/lib/apprise/plugins/NotifyPushSafer.py b/lib/apprise/plugins/NotifyPushSafer.py new file mode 100644 index 0000000..9873bd8 --- /dev/null +++ b/lib/apprise/plugins/NotifyPushSafer.py @@ -0,0 +1,839 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 base64 +import requests +from json import loads + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import parse_list +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +class PushSaferSound: + """ + Defines all of the supported PushSafe sounds + """ + # Silent + SILENT = 0 + # Ahem (IM) + AHEM = 1 + # Applause (Mail) + APPLAUSE = 2 + # Arrow (Reminder) + ARROW = 3 + # Baby (SMS) + BABY = 4 + # Bell (Alarm) + BELL = 5 + # Bicycle (Alarm2) + BICYCLE = 6 + # Boing (Alarm3) + BOING = 7 + # Buzzer (Alarm4) + BUZZER = 8 + # Camera (Alarm5) + CAMERA = 9 + # Car Horn (Alarm6) + CAR_HORN = 10 + # Cash Register (Alarm7) + CASH_REGISTER = 11 + # Chime (Alarm8) + CHIME = 12 + # Creaky Door (Alarm9) + CREAKY_DOOR = 13 + # Cuckoo Clock (Alarm10) + CUCKOO_CLOCK = 14 + # Disconnect (Call) + DISCONNECT = 15 + # Dog (Call2) + DOG = 16 + # Doorbell (Call3) + DOORBELL = 17 + # Fanfare (Call4) + FANFARE = 18 + # Gun Shot (Call5) + GUN_SHOT = 19 + # Honk (Call6) + HONK = 20 + # Jaw Harp (Call7) + JAW_HARP = 21 + # Morse (Call8) + MORSE = 22 + # Electricity (Call9) + ELECTRICITY = 23 + # Radio Tuner (Call10) + RADIO_TURNER = 24 + # Sirens + SIRENS = 25 + # Military Trumpets + MILITARY_TRUMPETS = 26 + # Ufo + UFO = 27 + # Whah Whah Whah + LONG_WHAH = 28 + # Man Saying Goodbye + GOODBYE = 29 + # Man Saying Hello + HELLO = 30 + # Man Saying No + NO = 31 + # Man Saying Ok + OKAY = 32 + # Man Saying Ooohhhweee + OOOHHHWEEE = 33 + # Man Saying Warning + WARNING = 34 + # Man Saying Welcome + WELCOME = 35 + # Man Saying Yeah + YEAH = 36 + # Man Saying Yes + YES = 37 + # Beep short + BEEP1 = 38 + # Weeeee short + WEEE = 39 + # Cut in and out short + CUTINOUT = 40 + # Finger flicking glas short + FLICK_GLASS = 41 + # Wa Wa Waaaa short + SHORT_WHAH = 42 + # Laser short + LASER = 43 + # Wind Chime short + WIND_CHIME = 44 + # Echo short + ECHO = 45 + # Zipper short + ZIPPER = 46 + # HiHat short + HIHAT = 47 + # Beep 2 short + BEEP2 = 48 + # Beep 3 short + BEEP3 = 49 + # Beep 4 short + BEEP4 = 50 + # The Alarm is armed + ALARM_ARMED = 51 + # The Alarm is disarmed + ALARM_DISARMED = 52 + # The Backup is ready + BACKUP_READY = 53 + # The Door is closed + DOOR_CLOSED = 54 + # The Door is opend + DOOR_OPENED = 55 + # The Window is closed + WINDOW_CLOSED = 56 + # The Window is open + WINDOW_OPEN = 57 + # The Light is off + LIGHT_ON = 58 + # The Light is on + LIGHT_OFF = 59 + # The Doorbell rings + DOORBELL_RANG = 60 + + +PUSHSAFER_SOUND_MAP = { + # Device Default, + 'silent': PushSaferSound.SILENT, + 'ahem': PushSaferSound.AHEM, + 'applause': PushSaferSound.APPLAUSE, + 'arrow': PushSaferSound.ARROW, + 'baby': PushSaferSound.BABY, + 'bell': PushSaferSound.BELL, + 'bicycle': PushSaferSound.BICYCLE, + 'bike': PushSaferSound.BICYCLE, + 'boing': PushSaferSound.BOING, + 'buzzer': PushSaferSound.BUZZER, + 'camera': PushSaferSound.CAMERA, + 'carhorn': PushSaferSound.CAR_HORN, + 'horn': PushSaferSound.CAR_HORN, + 'cashregister': PushSaferSound.CASH_REGISTER, + 'chime': PushSaferSound.CHIME, + 'creakydoor': PushSaferSound.CREAKY_DOOR, + 'cuckooclock': PushSaferSound.CUCKOO_CLOCK, + 'cuckoo': PushSaferSound.CUCKOO_CLOCK, + 'disconnect': PushSaferSound.DISCONNECT, + 'dog': PushSaferSound.DOG, + 'doorbell': PushSaferSound.DOORBELL, + 'fanfare': PushSaferSound.FANFARE, + 'gunshot': PushSaferSound.GUN_SHOT, + 'honk': PushSaferSound.HONK, + 'jawharp': PushSaferSound.JAW_HARP, + 'morse': PushSaferSound.MORSE, + 'electric': PushSaferSound.ELECTRICITY, + 'radiotuner': PushSaferSound.RADIO_TURNER, + 'sirens': PushSaferSound.SIRENS, + 'militarytrumpets': PushSaferSound.MILITARY_TRUMPETS, + 'military': PushSaferSound.MILITARY_TRUMPETS, + 'trumpets': PushSaferSound.MILITARY_TRUMPETS, + 'ufo': PushSaferSound.UFO, + 'whahwhah': PushSaferSound.LONG_WHAH, + 'whah': PushSaferSound.SHORT_WHAH, + 'goodye': PushSaferSound.GOODBYE, + 'hello': PushSaferSound.HELLO, + 'no': PushSaferSound.NO, + 'okay': PushSaferSound.OKAY, + 'ok': PushSaferSound.OKAY, + 'ooohhhweee': PushSaferSound.OOOHHHWEEE, + 'warn': PushSaferSound.WARNING, + 'warning': PushSaferSound.WARNING, + 'welcome': PushSaferSound.WELCOME, + 'yeah': PushSaferSound.YEAH, + 'yes': PushSaferSound.YES, + 'beep': PushSaferSound.BEEP1, + 'beep1': PushSaferSound.BEEP1, + 'weee': PushSaferSound.WEEE, + 'wee': PushSaferSound.WEEE, + 'cutinout': PushSaferSound.CUTINOUT, + 'flickglass': PushSaferSound.FLICK_GLASS, + 'laser': PushSaferSound.LASER, + 'windchime': PushSaferSound.WIND_CHIME, + 'echo': PushSaferSound.ECHO, + 'zipper': PushSaferSound.ZIPPER, + 'hihat': PushSaferSound.HIHAT, + 'beep2': PushSaferSound.BEEP2, + 'beep3': PushSaferSound.BEEP3, + 'beep4': PushSaferSound.BEEP4, + 'alarmarmed': PushSaferSound.ALARM_ARMED, + 'armed': PushSaferSound.ALARM_ARMED, + 'alarmdisarmed': PushSaferSound.ALARM_DISARMED, + 'disarmed': PushSaferSound.ALARM_DISARMED, + 'backupready': PushSaferSound.BACKUP_READY, + 'dooropen': PushSaferSound.DOOR_OPENED, + 'dopen': PushSaferSound.DOOR_OPENED, + 'doorclosed': PushSaferSound.DOOR_CLOSED, + 'dclosed': PushSaferSound.DOOR_CLOSED, + 'windowopen': PushSaferSound.WINDOW_OPEN, + 'wopen': PushSaferSound.WINDOW_OPEN, + 'windowclosed': PushSaferSound.WINDOW_CLOSED, + 'wclosed': PushSaferSound.WINDOW_CLOSED, + 'lighton': PushSaferSound.LIGHT_ON, + 'lon': PushSaferSound.LIGHT_ON, + 'lightoff': PushSaferSound.LIGHT_OFF, + 'loff': PushSaferSound.LIGHT_OFF, + 'doorbellrang': PushSaferSound.DOORBELL_RANG, +} + + +# Priorities +class PushSaferPriority: + LOW = -2 + MODERATE = -1 + NORMAL = 0 + HIGH = 1 + EMERGENCY = 2 + + +PUSHSAFER_PRIORITIES = ( + PushSaferPriority.LOW, + PushSaferPriority.MODERATE, + PushSaferPriority.NORMAL, + PushSaferPriority.HIGH, + PushSaferPriority.EMERGENCY, +) + +PUSHSAFER_PRIORITY_MAP = { + # short for 'low' + 'low': PushSaferPriority.LOW, + # short for 'medium' + 'medium': PushSaferPriority.MODERATE, + # short for 'normal' + 'normal': PushSaferPriority.NORMAL, + # short for 'high' + 'high': PushSaferPriority.HIGH, + # short for 'emergency' + 'emergency': PushSaferPriority.EMERGENCY, +} + +# Identify the priority ou want to designate as the fall back +DEFAULT_PRIORITY = "normal" + + +# Vibrations +class PushSaferVibration: + """ + Defines the acceptable vibration settings for notification + """ + # x1 + LOW = 1 + # x2 + NORMAL = 2 + # x3 + HIGH = 3 + + +# Identify all of the vibrations in one place +PUSHSAFER_VIBRATIONS = ( + PushSaferVibration.LOW, + PushSaferVibration.NORMAL, + PushSaferVibration.HIGH, +) + +# At this time, the following pictures can be attached to each notification +# at one time. When more are supported, just add their argument below +PICTURE_PARAMETER = ( + 'p', + 'p2', + 'p3', +) + + +# Flag used as a placeholder to sending to all devices +PUSHSAFER_SEND_TO_ALL = 'a' + + +class NotifyPushSafer(NotifyBase): + """ + A wrapper for PushSafer Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Pushsafer' + + # The services URL + service_url = 'https://www.pushsafer.com/' + + # The default insecure protocol + protocol = 'psafer' + + # The default secure protocol + secure_protocol = 'psafers' + + # Support attachments + attachment_support = True + + # Number of requests to a allow per second + request_rate_per_sec = 1.2 + + # The icon ID of 25 looks like a megaphone + default_pushsafer_icon = 25 + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pushsafer' + + # Defines the hostname to post content to; since this service supports + # both insecure and secure methods, we set the {schema} just before we + # post the message upstream. + notify_url = '{schema}://www.pushsafer.com/api' + + # Define object templates + templates = ( + '{schema}://{privatekey}', + '{schema}://{privatekey}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'privatekey': { + 'name': _('Private Key'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'target_device': { + 'name': _('Target Device'), + 'type': 'string', + 'map_to': 'targets', + }, + 'target_email': { + 'name': _('Target Email'), + 'type': 'string', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'priority': { + 'name': _('Priority'), + 'type': 'choice:int', + 'values': PUSHSAFER_PRIORITIES, + }, + 'sound': { + 'name': _('Sound'), + 'type': 'choice:string', + 'values': PUSHSAFER_SOUND_MAP, + }, + 'vibration': { + 'name': _('Vibration'), + 'type': 'choice:int', + 'values': PUSHSAFER_VIBRATIONS, + }, + 'to': { + 'alias_of': 'targets', + }, + }) + + def __init__(self, privatekey, targets=None, priority=None, sound=None, + vibration=None, **kwargs): + """ + Initialize PushSafer Object + """ + super().__init__(**kwargs) + + # + # Priority + # + try: + # Acquire our priority if we can: + # - We accept both the integer form as well as a string + # representation + self.priority = int(priority) + + except TypeError: + # NoneType means use Default; this is an okay exception + self.priority = None + + except ValueError: + # Input is a string; attempt to get the lookup from our + # priority mapping + priority = priority.lower().strip() + + # This little bit of black magic allows us to match against + # low, lo, l (for low); + # normal, norma, norm, nor, no, n (for normal) + # ... etc + match = next((key for key in PUSHSAFER_PRIORITY_MAP.keys() + if key.startswith(priority)), None) \ + if priority else None + + # Now test to see if we got a match + if not match: + msg = 'An invalid PushSafer priority ' \ + '({}) was specified.'.format(priority) + self.logger.warning(msg) + raise TypeError(msg) + + # store our successfully looked up priority + self.priority = PUSHSAFER_PRIORITY_MAP[match] + + if self.priority is not None and \ + self.priority not in PUSHSAFER_PRIORITY_MAP.values(): + msg = 'An invalid PushSafer priority ' \ + '({}) was specified.'.format(priority) + self.logger.warning(msg) + raise TypeError(msg) + + # + # Sound + # + try: + # Acquire our sound if we can: + # - We accept both the integer form as well as a string + # representation + self.sound = int(sound) + + except TypeError: + # NoneType means use Default; this is an okay exception + self.sound = None + + except ValueError: + # Input is a string; attempt to get the lookup from our + # sound mapping + sound = sound.lower().strip() + + # This little bit of black magic allows us to match against + # against multiple versions of the same string + # ... etc + match = next((key for key in PUSHSAFER_SOUND_MAP.keys() + if key.startswith(sound)), None) \ + if sound else None + + # Now test to see if we got a match + if not match: + msg = 'An invalid PushSafer sound ' \ + '({}) was specified.'.format(sound) + self.logger.warning(msg) + raise TypeError(msg) + + # store our successfully looked up sound + self.sound = PUSHSAFER_SOUND_MAP[match] + + if self.sound is not None and \ + self.sound not in PUSHSAFER_SOUND_MAP.values(): + msg = 'An invalid PushSafer sound ' \ + '({}) was specified.'.format(sound) + self.logger.warning(msg) + raise TypeError(msg) + + # + # Vibration + # + try: + # Use defined integer as is if defined, no further error checking + # is performed + self.vibration = int(vibration) + + except TypeError: + # NoneType means use Default; this is an okay exception + self.vibration = None + + except ValueError: + msg = 'An invalid PushSafer vibration ' \ + '({}) was specified.'.format(vibration) + self.logger.warning(msg) + raise TypeError(msg) + + if self.vibration and self.vibration not in PUSHSAFER_VIBRATIONS: + msg = 'An invalid PushSafer vibration ' \ + '({}) was specified.'.format(vibration) + self.logger.warning(msg) + raise TypeError(msg) + + # + # Private Key (associated with project) + # + self.privatekey = validate_regex(privatekey) + if not self.privatekey: + msg = 'An invalid PushSafer Private Key ' \ + '({}) was specified.'.format(privatekey) + self.logger.warning(msg) + raise TypeError(msg) + + self.targets = parse_list(targets) + if len(self.targets) == 0: + self.targets = (PUSHSAFER_SEND_TO_ALL, ) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): + """ + Perform PushSafer Notification + """ + + # error tracking (used for function return) + has_error = False + + # Initialize our list of attachments + attachments = [] + + if attach and self.attachment_support: + # We need to upload our payload first so that we can source it + # in remaining messages + for attachment in attach: + # prepare payload + if not attachment: + # We could not access the attachment + self.logger.error( + 'Could not access attachment {}.'.format( + attachment.url(privacy=True))) + return False + + if not attachment.mimetype.startswith('image/'): + # Attachment not supported; continue peacefully + self.logger.debug( + 'Ignoring unsupported PushSafer attachment {}.'.format( + attachment.url(privacy=True))) + continue + + self.logger.debug( + 'Posting PushSafer attachment {}'.format( + attachment.url(privacy=True))) + + try: + with open(attachment.path, 'rb') as f: + # Output must be in a DataURL format (that's what + # PushSafer calls it): + attachment = ( + attachment.name, + 'data:{};base64,{}'.format( + attachment.mimetype, + base64.b64encode(f.read()))) + + 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 + + # Save our pre-prepared payload for attachment posting + attachments.append(attachment) + + # Create a copy of the targets list + targets = list(self.targets) + while len(targets): + recipient = targets.pop(0) + + # prepare payload + payload = { + 't': title, + 'm': body, + # Our default icon to use + 'i': self.default_pushsafer_icon, + # Notification Color + 'c': self.color(notify_type), + # Target Recipient + 'd': recipient, + } + + if self.sound is not None: + # Only apply sound setting if it was specified + payload['s'] = str(self.sound) + + if self.vibration is not None: + # Only apply vibration setting + payload['v'] = str(self.vibration) + + if not attachments: + okay, response = self._send(payload) + if not okay: + has_error = True + continue + + self.logger.info( + 'Sent PushSafer notification to "%s".' % (recipient)) + + else: + # Create a copy of our payload object + _payload = payload.copy() + + for idx in range( + 0, len(attachments), len(PICTURE_PARAMETER)): + # Send our attachments to our same user (already prepared + # as our payload object) + for c, attachment in enumerate( + attachments[idx:idx + len(PICTURE_PARAMETER)]): + + # Get our attachment information + filename, dataurl = attachment + _payload.update({PICTURE_PARAMETER[c]: dataurl}) + + self.logger.debug( + 'Added attachment (%s) to "%s".' % ( + filename, recipient)) + + okay, response = self._send(_payload) + if not okay: + has_error = True + continue + + self.logger.info( + 'Sent PushSafer attachment (%s) to "%s".' % ( + filename, recipient)) + + # More then the maximum messages shouldn't cause all of + # the text to loop on future iterations + _payload = payload.copy() + _payload['t'] = '' + _payload['m'] = '...' + + return not has_error + + def _send(self, payload, **kwargs): + """ + Wrapper to the requests (post) object + """ + + headers = { + 'User-Agent': self.app_id, + } + + # Prepare the notification URL to post to + notify_url = self.notify_url.format( + schema='https' if self.secure else 'http' + ) + + # Store the payload key + payload['k'] = self.privatekey + + self.logger.debug('PushSafer POST URL: %s (cert_verify=%r)' % ( + notify_url, self.verify_certificate, + )) + self.logger.debug('PushSafer Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + # Default response type + response = None + + # Initialize our Pushsafer expected responses + _code = None + _str = 'Unknown' + + try: + # Open our attachment path if required: + r = requests.post( + notify_url, + data=payload, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + try: + response = loads(r.content) + _code = response.get('status') + _str = response.get('success', _str) \ + if _code == 1 else response.get('error', _str) + + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + + # Fall back to the existing unparsed value + response = r.content + + if r.status_code not in ( + requests.codes.ok, requests.codes.no_content): + # We had a problem + status_str = \ + NotifyPushSafer.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to deliver payload to PushSafer:' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + return False, response + + elif _code != 1: + # It's a bit backwards, but: + # 1 is returned if we succeed + # 0 is returned if we fail + self.logger.warning( + 'Failed to deliver payload to PushSafer;' + ' error={}.'.format(_str)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + return False, response + + # otherwise we were successful + return True, response + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred communicating with PushSafer.') + self.logger.debug('Socket Exception: %s' % str(e)) + + return False, response + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Our URL parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + if self.priority is not None: + # Store our priority; but only if it was specified + params['priority'] = \ + next((key for key, value in PUSHSAFER_PRIORITY_MAP.items() + if value == self.priority), + DEFAULT_PRIORITY) # pragma: no cover + + if self.sound is not None: + # Store our sound; but only if it was specified + params['sound'] = \ + next((key for key, value in PUSHSAFER_SOUND_MAP.items() + if value == self.sound), '') # pragma: no cover + + if self.vibration is not None: + # Store our vibration; but only if it was specified + params['vibration'] = str(self.vibration) + + targets = '/'.join([NotifyPushSafer.quote(x) for x in self.targets]) + if targets == PUSHSAFER_SEND_TO_ALL: + # keyword is reserved for internal usage only; it's safe to remove + # it from the recipients list + targets = '' + + return '{schema}://{privatekey}/{targets}?{params}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + privatekey=self.pprint(self.privatekey, privacy, safe=''), + targets=targets, + params=NotifyPushSafer.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return 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 + + # Fetch our targets + results['targets'] = \ + NotifyPushSafer.split_path(results['fullpath']) + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyPushSafer.parse_list(results['qsd']['to']) + + # Setup the token; we store it in Private Key for global + # plugin consistency with naming conventions + results['privatekey'] = NotifyPushSafer.unquote(results['host']) + + if 'priority' in results['qsd'] and len(results['qsd']['priority']): + results['priority'] = \ + NotifyPushSafer.unquote(results['qsd']['priority']) + + if 'sound' in results['qsd'] and len(results['qsd']['sound']): + results['sound'] = \ + NotifyPushSafer.unquote(results['qsd']['sound']) + + if 'vibration' in results['qsd'] and len(results['qsd']['vibration']): + results['vibration'] = \ + NotifyPushSafer.unquote(results['qsd']['vibration']) + + return results diff --git a/lib/apprise/plugins/NotifyPushed.py b/lib/apprise/plugins/NotifyPushed.py new file mode 100644 index 0000000..96e2e89 --- /dev/null +++ b/lib/apprise/plugins/NotifyPushed.py @@ -0,0 +1,372 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 re +import requests +from json import dumps +from itertools import chain + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..utils import parse_list +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + +# Used to detect and parse channels +IS_CHANNEL = re.compile(r'^#?(?P[A-Za-z0-9]+)$') + +# Used to detect and parse a users push id +IS_USER_PUSHED_ID = re.compile(r'^@(?P[A-Za-z0-9]+)$') + + +class NotifyPushed(NotifyBase): + """ + A wrapper to Pushed Notifications + + """ + + # The default descriptive name associated with the Notification + service_name = 'Pushed' + + # The services URL + service_url = 'https://pushed.co/' + + # The default secure protocol + secure_protocol = 'pushed' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pushed' + + # Pushed uses the http protocol with JSON requests + notify_url = 'https://api.pushed.co/1/push' + + # A title can not be used for Pushed Messages. Setting this to zero will + # cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + + # The maximum allowable characters allowed in the body per message + body_maxlen = 160 + + # Define object templates + templates = ( + '{schema}://{app_key}/{app_secret}', + '{schema}://{app_key}/{app_secret}@{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'app_key': { + 'name': _('Application Key'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'app_secret': { + 'name': _('Application Secret'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'target_user': { + 'name': _('Target User'), + 'prefix': '@', + 'type': 'string', + 'map_to': 'targets', + }, + 'target_channel': { + 'name': _('Target Channel'), + 'type': 'string', + 'prefix': '#', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + }) + + def __init__(self, app_key, app_secret, targets=None, **kwargs): + """ + Initialize Pushed Object + + """ + super().__init__(**kwargs) + + # Application Key (associated with project) + self.app_key = validate_regex(app_key) + if not self.app_key: + msg = 'An invalid Pushed Application Key ' \ + '({}) was specified.'.format(app_key) + self.logger.warning(msg) + raise TypeError(msg) + + # Access Secret (associated with project) + self.app_secret = validate_regex(app_secret) + if not self.app_secret: + msg = 'An invalid Pushed Application Secret ' \ + '({}) was specified.'.format(app_secret) + self.logger.warning(msg) + raise TypeError(msg) + + # Initialize channel list + self.channels = list() + + # Initialize user list + self.users = list() + + # Get our targets + targets = parse_list(targets) + if targets: + # Validate recipients and drop bad ones: + for target in targets: + result = IS_CHANNEL.match(target) + if result: + # store valid device + self.channels.append(result.group('name')) + continue + + result = IS_USER_PUSHED_ID.match(target) + if result: + # store valid room + self.users.append(result.group('name')) + continue + + self.logger.warning( + 'Dropped invalid channel/userid ' + '(%s) specified.' % target, + ) + + if len(self.channels) + len(self.users) == 0: + # We have no valid channels or users to notify after + # explicitly identifying at least one. + msg = 'No Pushed targets to notify.' + self.logger.warning(msg) + raise TypeError(msg) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Pushed Notification + """ + + # Initiaize our error tracking + has_error = False + + # prepare JSON Object + payload = { + 'app_key': self.app_key, + 'app_secret': self.app_secret, + 'target_type': 'app', + 'content': body, + } + + # So the logic is as follows: + # - if no user/channel was specified, then we just simply notify the + # app. + # - if there are user/channels specified, then we only alert them + # while respecting throttle limits (in the event there are a lot of + # entries. + + if len(self.channels) + len(self.users) == 0: + # Just notify the app + return self._send( + payload=payload, notify_type=notify_type, **kwargs) + + # If our code reaches here, we want to target channels and users (by + # their Pushed_ID instead... + + # Generate a copy of our original list + channels = list(self.channels) + users = list(self.users) + + # Copy our payload + _payload = dict(payload) + _payload['target_type'] = 'channel' + + while len(channels) > 0: + # Get Channel + _payload['target_alias'] = channels.pop(0) + + if not self._send( + payload=_payload, notify_type=notify_type, **kwargs): + + # toggle flag + has_error = True + + # Copy our payload + _payload = dict(payload) + _payload['target_type'] = 'pushed_id' + + # Send all our defined User Pushed ID's + while len(users): + # Get User's Pushed ID + _payload['pushed_id'] = users.pop(0) + + if not self._send( + payload=_payload, notify_type=notify_type, **kwargs): + + # toggle flag + has_error = True + + return not has_error + + def _send(self, payload, notify_type, **kwargs): + """ + A lower level call that directly pushes a payload to the Pushed + Notification servers. This should never be called directly; it is + referenced automatically through the send() function. + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json' + } + + self.logger.debug('Pushed POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('Pushed Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + self.notify_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 = \ + NotifyPushed.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send Pushed notification:' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info('Sent Pushed notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Pushed notification.') + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Our URL parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + return '{schema}://{app_key}/{app_secret}/{targets}/?{params}'.format( + schema=self.secure_protocol, + app_key=self.pprint(self.app_key, privacy, safe=''), + app_secret=self.pprint( + self.app_secret, privacy, mode=PrivacyMode.Secret, safe=''), + targets='/'.join( + [NotifyPushed.quote(x) for x in chain( + # Channels are prefixed with a pound/hashtag symbol + ['#{}'.format(x) for x in self.channels], + # Users are prefixed with an @ symbol + ['@{}'.format(x) for x in self.users], + )]), + params=NotifyPushed.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.channels) + len(self.users) + return targets if targets > 0 else 1 + + @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 + + # The first token is stored in the hostname + app_key = NotifyPushed.unquote(results['host']) + + entries = NotifyPushed.split_path(results['fullpath']) + # Now fetch the remaining tokens + try: + app_secret = entries.pop(0) + + except IndexError: + # Force some bad values that will get caught + # in parsing later + app_secret = None + app_key = None + + # Get our recipients (based on remaining entries) + results['targets'] = entries + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyPushed.parse_list(results['qsd']['to']) + + results['app_key'] = app_key + results['app_secret'] = app_secret + + return results diff --git a/lib/apprise/plugins/NotifyPushjet.py b/lib/apprise/plugins/NotifyPushjet.py new file mode 100644 index 0000000..50ee16e --- /dev/null +++ b/lib/apprise/plugins/NotifyPushjet.py @@ -0,0 +1,268 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 json import dumps + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +class NotifyPushjet(NotifyBase): + """ + A wrapper for Pushjet Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Pushjet' + + # The default protocol + protocol = 'pjet' + + # The default secure protocol + secure_protocol = 'pjets' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pushjet' + + # Disable throttle rate for Pushjet requests since they are normally + # local anyway (the remote/online service is no more) + request_rate_per_sec = 0 + + # Define object templates + templates = ( + '{schema}://{host}:{port}/{secret_key}', + '{schema}://{host}/{secret_key}', + '{schema}://{user}:{password}@{host}:{port}/{secret_key}', + '{schema}://{user}:{password}@{host}/{secret_key}', + ) + + # Define our tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'secret_key': { + 'name': _('Secret Key'), + 'type': 'string', + 'required': True, + 'private': True, + }, + 'user': { + 'name': _('Username'), + 'type': 'string', + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + }, + }) + + template_args = dict(NotifyBase.template_args, **{ + 'secret': { + 'alias_of': 'secret_key', + }, + }) + + def __init__(self, secret_key, **kwargs): + """ + Initialize Pushjet Object + """ + super().__init__(**kwargs) + + # Secret Key (associated with project) + self.secret_key = validate_regex(secret_key) + if not self.secret_key: + msg = 'An invalid Pushjet Secret Key ' \ + '({}) was specified.'.format(secret_key) + self.logger.warning(msg) + raise TypeError(msg) + + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Our URL parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + default_port = 443 if self.secure else 80 + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=NotifyPushjet.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + + return '{schema}://{auth}{hostname}{port}/{secret}/?{params}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + auth=auth, + # never encode hostname since we're expecting it to be a valid one + hostname=self.host, + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + secret=self.pprint( + self.secret_key, privacy, mode=PrivacyMode.Secret, safe=''), + params=NotifyPushjet.urlencode(params), + ) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Pushjet Notification + """ + + params = { + 'secret': self.secret_key, + } + + # prepare Pushjet Object + payload = { + 'message': body, + 'title': title, + 'link': None, + 'level': None, + } + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', + } + + auth = None + if self.user: + auth = (self.user, self.password) + + notify_url = '{schema}://{host}{port}/message/'.format( + schema="https" if self.secure else "http", + host=self.host, + port=':{}'.format(self.port) if self.port else '') + + self.logger.debug('Pushjet POST URL: %s (cert_verify=%r)' % ( + notify_url, self.verify_certificate, + )) + self.logger.debug('Pushjet Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + notify_url, + params=params, + data=dumps(payload), + headers=headers, + auth=auth, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyPushjet.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send Pushjet notification: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info('Sent Pushjet notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Pushjet ' + 'notification to %s.' % self.host) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + Syntax: + pjet://hostname/secret_key + pjet://hostname:port/secret_key + pjet://user:pass@hostname/secret_key + pjet://user:pass@hostname:port/secret_key + pjets://hostname/secret_key + pjets://hostname:port/secret_key + pjets://user:pass@hostname/secret_key + pjets://user:pass@hostname:port/secret_key + """ + results = NotifyBase.parse_url(url) + if not results: + # We're done early as we couldn't load the results + return results + + try: + # Retrieve our secret_key from the first entry in the url path + results['secret_key'] = \ + NotifyPushjet.split_path(results['fullpath'])[0] + + except IndexError: + # no secret key specified + results['secret_key'] = None + + # Allow over-riding the secret by specifying it as an argument + # this allows people who have http-auth infront to login + # through it in addition to supporting the secret key + if 'secret' in results['qsd'] and len(results['qsd']['secret']): + results['secret_key'] = \ + NotifyPushjet.unquote(results['qsd']['secret']) + + return results diff --git a/lib/apprise/plugins/NotifyPushover.py b/lib/apprise/plugins/NotifyPushover.py new file mode 100644 index 0000000..4a76e7d --- /dev/null +++ b/lib/apprise/plugins/NotifyPushover.py @@ -0,0 +1,640 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 re +import requests +from itertools import chain + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..common import NotifyFormat +from ..conversion import convert_between +from ..utils import parse_list +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ +from ..attachment.AttachBase import AttachBase + +# Flag used as a placeholder to sending to all devices +PUSHOVER_SEND_TO_ALL = 'ALL_DEVICES' + +# Used to detect a Device +VALIDATE_DEVICE = re.compile(r'^\s*(?P[a-z0-9_-]{1,25})\s*$', re.I) + + +# Priorities +class PushoverPriority: + LOW = -2 + MODERATE = -1 + NORMAL = 0 + HIGH = 1 + EMERGENCY = 2 + + +# Sounds +class PushoverSound: + PUSHOVER = 'pushover' + BIKE = 'bike' + BUGLE = 'bugle' + CASHREGISTER = 'cashregister' + CLASSICAL = 'classical' + COSMIC = 'cosmic' + FALLING = 'falling' + GAMELAN = 'gamelan' + INCOMING = 'incoming' + INTERMISSION = 'intermission' + MAGIC = 'magic' + MECHANICAL = 'mechanical' + PIANOBAR = 'pianobar' + SIREN = 'siren' + SPACEALARM = 'spacealarm' + TUGBOAT = 'tugboat' + ALIEN = 'alien' + CLIMB = 'climb' + PERSISTENT = 'persistent' + ECHO = 'echo' + UPDOWN = 'updown' + NONE = 'none' + + +PUSHOVER_SOUNDS = ( + PushoverSound.PUSHOVER, + PushoverSound.BIKE, + PushoverSound.BUGLE, + PushoverSound.CASHREGISTER, + PushoverSound.CLASSICAL, + PushoverSound.COSMIC, + PushoverSound.FALLING, + PushoverSound.GAMELAN, + PushoverSound.INCOMING, + PushoverSound.INTERMISSION, + PushoverSound.MAGIC, + PushoverSound.MECHANICAL, + PushoverSound.PIANOBAR, + PushoverSound.SIREN, + PushoverSound.SPACEALARM, + PushoverSound.TUGBOAT, + PushoverSound.ALIEN, + PushoverSound.CLIMB, + PushoverSound.PERSISTENT, + PushoverSound.ECHO, + PushoverSound.UPDOWN, + PushoverSound.NONE, +) + +PUSHOVER_PRIORITIES = { + # Note: This also acts as a reverse lookup mapping + PushoverPriority.LOW: 'low', + PushoverPriority.MODERATE: 'moderate', + PushoverPriority.NORMAL: 'normal', + PushoverPriority.HIGH: 'high', + PushoverPriority.EMERGENCY: 'emergency', +} + +PUSHOVER_PRIORITY_MAP = { + # Maps against string 'low' + 'l': PushoverPriority.LOW, + # Maps against string 'moderate' + 'm': PushoverPriority.MODERATE, + # Maps against string 'normal' + 'n': PushoverPriority.NORMAL, + # Maps against string 'high' + 'h': PushoverPriority.HIGH, + # Maps against string 'emergency' + 'e': PushoverPriority.EMERGENCY, + + # Entries to additionally support (so more like Pushover's API) + '-2': PushoverPriority.LOW, + '-1': PushoverPriority.MODERATE, + '0': PushoverPriority.NORMAL, + '1': PushoverPriority.HIGH, + '2': PushoverPriority.EMERGENCY, +} + +# Extend HTTP Error Messages +PUSHOVER_HTTP_ERROR_MAP = { + 401: 'Unauthorized - Invalid Token.', +} + + +class NotifyPushover(NotifyBase): + """ + A wrapper for Pushover Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Pushover' + + # The services URL + service_url = 'https://pushover.net/' + + # All pushover requests are secure + secure_protocol = 'pover' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pushover' + + # Pushover uses the http protocol with JSON requests + notify_url = 'https://api.pushover.net/1/messages.json' + + # Support attachments + attachment_support = True + + # The maximum allowable characters allowed in the body per message + body_maxlen = 1024 + + # Default Pushover sound + default_pushover_sound = PushoverSound.PUSHOVER + + # 2.5MB is the maximum supported image filesize as per documentation + # here: https://pushover.net/api#attachments (Dec 26th, 2019) + attach_max_size_bytes = 2621440 + + # The regular expression of the current attachment supported mime types + # At this time it is only images + attach_supported_mime_type = r'^image/.*' + + # Define object templates + templates = ( + '{schema}://{user_key}@{token}', + '{schema}://{user_key}@{token}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'user_key': { + 'name': _('User Key'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'token': { + 'name': _('Access Token'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'target_device': { + 'name': _('Target Device'), + 'type': 'string', + 'regex': (r'^[a-z0-9_-]{1,25}$', 'i'), + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'priority': { + 'name': _('Priority'), + 'type': 'choice:int', + 'values': PUSHOVER_PRIORITIES, + 'default': PushoverPriority.NORMAL, + }, + 'sound': { + 'name': _('Sound'), + 'type': 'string', + 'regex': (r'^[a-z]{1,12}$', 'i'), + 'default': PushoverSound.PUSHOVER, + }, + 'url': { + 'name': _('URL'), + 'map_to': 'supplemental_url', + 'type': 'string', + }, + 'url_title': { + 'name': _('URL Title'), + 'map_to': 'supplemental_url_title', + 'type': 'string' + }, + 'retry': { + 'name': _('Retry'), + 'type': 'int', + 'min': 30, + 'default': 900, # 15 minutes + }, + 'expire': { + 'name': _('Expire'), + 'type': 'int', + 'min': 0, + 'max': 10800, + 'default': 3600, # 1 hour + }, + 'to': { + 'alias_of': 'targets', + }, + }) + + def __init__(self, user_key, token, targets=None, priority=None, + sound=None, retry=None, expire=None, supplemental_url=None, + supplemental_url_title=None, **kwargs): + """ + Initialize Pushover Object + """ + super().__init__(**kwargs) + + # Access Token (associated with project) + self.token = validate_regex(token) + if not self.token: + msg = 'An invalid Pushover Access Token ' \ + '({}) was specified.'.format(token) + self.logger.warning(msg) + raise TypeError(msg) + + # User Key (associated with project) + self.user_key = validate_regex(user_key) + if not self.user_key: + msg = 'An invalid Pushover User Key ' \ + '({}) was specified.'.format(user_key) + self.logger.warning(msg) + raise TypeError(msg) + + # Track our valid devices + targets = parse_list(targets) + + # Track any invalid entries + self.invalid_targets = list() + + if len(targets) == 0: + self.targets = (PUSHOVER_SEND_TO_ALL, ) + + else: + self.targets = [] + for target in targets: + result = VALIDATE_DEVICE.match(target) + if result: + # Store device information + self.targets.append(result.group('device')) + continue + + self.logger.warning( + 'Dropped invalid Pushover device ' + '({}) specified.'.format(target), + ) + self.invalid_targets.append(target) + + # Setup supplemental url + self.supplemental_url = supplemental_url + self.supplemental_url_title = supplemental_url_title + + # Setup our sound + self.sound = NotifyPushover.default_pushover_sound \ + if not isinstance(sound, str) else sound.lower() + if self.sound and self.sound not in PUSHOVER_SOUNDS: + msg = 'Using custom sound specified ({}). '.format(sound) + self.logger.debug(msg) + + # The Priority of the message + self.priority = int( + NotifyPushover.template_args['priority']['default'] + if priority is None else + next(( + v for k, v in PUSHOVER_PRIORITY_MAP.items() + if str(priority).lower().startswith(k)), + NotifyPushover.template_args['priority']['default'])) + + # The following are for emergency alerts + if self.priority == PushoverPriority.EMERGENCY: + + # How often to resend notification, in seconds + self.retry = self.template_args['retry']['default'] + try: + self.retry = int(retry) + except (ValueError, TypeError): + # Do nothing + pass + + # How often to resend notification, in seconds + self.expire = self.template_args['expire']['default'] + try: + self.expire = int(expire) + except (ValueError, TypeError): + # Do nothing + pass + + if self.retry < 30: + msg = 'Pushover retry must be at least 30 seconds.' + self.logger.warning(msg) + raise TypeError(msg) + + if self.expire < 0 or self.expire > 10800: + msg = 'Pushover expire must reside in the range of ' \ + '0 to 10800 seconds.' + self.logger.warning(msg) + raise TypeError(msg) + return + + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): + """ + Perform Pushover Notification + """ + + if not self.targets: + # There were no services to notify + self.logger.warning( + 'There were no Pushover targets to notify.') + return False + + # prepare JSON Object + payload = { + 'token': self.token, + 'user': self.user_key, + 'priority': str(self.priority), + 'title': title if title else self.app_desc, + 'message': body, + 'device': ','.join(self.targets), + 'sound': self.sound, + } + + if self.supplemental_url: + payload['url'] = self.supplemental_url + + if self.supplemental_url_title: + payload['url_title'] = self.supplemental_url_title + + if self.notify_format == NotifyFormat.HTML: + # https://pushover.net/api#html + payload['html'] = 1 + + elif self.notify_format == NotifyFormat.MARKDOWN: + payload['message'] = convert_between( + NotifyFormat.MARKDOWN, NotifyFormat.HTML, body) + payload['html'] = 1 + + if self.priority == PushoverPriority.EMERGENCY: + payload.update({'retry': self.retry, 'expire': self.expire}) + + if attach and self.attachment_support: + # Create a copy of our payload + _payload = payload.copy() + + # Send with attachments + for no, attachment in enumerate(attach): + if no or not body: + # To handle multiple attachments, clean up our message + _payload['message'] = attachment.name + + if not self._send(_payload, attachment): + # Mark our failure + return False + + # Clear our title if previously set + _payload['title'] = '' + + # No need to alarm for each consecutive attachment uploaded + # afterwards + _payload['sound'] = PushoverSound.NONE + + else: + # Simple send + return self._send(payload) + + return True + + def _send(self, payload, attach=None): + """ + Wrapper to the requests (post) object + """ + + if isinstance(attach, AttachBase): + # Perform some simple error checking + if not attach: + # We could not access the attachment + self.logger.error( + 'Could not access attachment {}.'.format( + attach.url(privacy=True))) + return False + + # Perform some basic checks as we want to gracefully skip + # over unsupported mime types. + if not re.match( + self.attach_supported_mime_type, + attach.mimetype, + re.I): + # No problem; we just don't support this attachment + # type; gracefully move along + self.logger.debug( + 'Ignored unsupported Pushover attachment ({}): {}' + .format( + attach.mimetype, + attach.url(privacy=True))) + + attach = None + + else: + # If we get here, we're dealing with a supported image. + # Verify that the filesize is okay though. + file_size = len(attach) + if not (file_size > 0 + and file_size <= self.attach_max_size_bytes): + + # File size is no good + self.logger.warning( + 'Pushover attachment size ({}B) exceeds limit: {}' + .format(file_size, attach.url(privacy=True))) + + return False + + self.logger.debug( + 'Posting Pushover attachment {}'.format( + attach.url(privacy=True))) + + # Default Header + headers = { + 'User-Agent': self.app_id, + } + + # Authentication + auth = (self.token, '') + + # Some default values for our request object to which we'll update + # depending on what our payload is + files = None + + self.logger.debug('Pushover POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('Pushover Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + # Open our attachment path if required: + if attach: + files = {'attachment': (attach.name, open(attach.path, 'rb'))} + + r = requests.post( + self.notify_url, + data=payload, + headers=headers, + files=files, + auth=auth, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyPushover.http_response_code_lookup( + r.status_code, PUSHOVER_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send Pushover notification to {}: ' + '{}{}error={}.'.format( + payload['device'], + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + return False + + else: + self.logger.info( + 'Sent Pushover notification to %s.' % payload['device']) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Pushover:%s ' % ( + payload['device']) + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + return False + + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while reading {}.'.format( + attach.name if attach else 'attachment')) + self.logger.debug('I/O Exception: %s' % str(e)) + return False + + finally: + # Close our file (if it's open) stored in the second element + # of our files tuple (index 1) + if files: + files['attachment'][1].close() + + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'priority': + PUSHOVER_PRIORITIES[self.template_args['priority']['default']] + if self.priority not in PUSHOVER_PRIORITIES + else PUSHOVER_PRIORITIES[self.priority], + } + + # Only add expire and retry for emergency messages, + # pushover ignores for all other priorities + if self.priority == PushoverPriority.EMERGENCY: + params.update({'expire': self.expire, 'retry': self.retry}) + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Escape our devices + devices = '/'.join( + [NotifyPushover.quote(x, safe='') + for x in chain(self.targets, self.invalid_targets)]) + + if devices == PUSHOVER_SEND_TO_ALL: + # keyword is reserved for internal usage only; it's safe to remove + # it from the devices list + devices = '' + + return '{schema}://{user_key}@{token}/{devices}/?{params}'.format( + schema=self.secure_protocol, + user_key=self.pprint(self.user_key, privacy, safe=''), + token=self.pprint(self.token, privacy, safe=''), + devices=devices, + params=NotifyPushover.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, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + # Set our priority + if 'priority' in results['qsd'] and len(results['qsd']['priority']): + results['priority'] = \ + NotifyPushover.unquote(results['qsd']['priority']) + + # Retrieve all of our targets + results['targets'] = NotifyPushover.split_path(results['fullpath']) + + # User Key is retrieved from the user + results['user_key'] = NotifyPushover.unquote(results['user']) + + # Get the sound + if 'sound' in results['qsd'] and len(results['qsd']['sound']): + results['sound'] = \ + NotifyPushover.unquote(results['qsd']['sound']) + + # Get the supplementary url + if 'url' in results['qsd'] and len(results['qsd']['url']): + results['supplemental_url'] = NotifyPushover.unquote( + results['qsd']['url'] + ) + if 'url_title' in results['qsd'] and len(results['qsd']['url_title']): + results['supplemental_url_title'] = results['qsd']['url_title'] + + # Get expire and retry + if 'expire' in results['qsd'] and len(results['qsd']['expire']): + results['expire'] = results['qsd']['expire'] + if 'retry' in results['qsd'] and len(results['qsd']['retry']): + results['retry'] = results['qsd']['retry'] + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyPushover.parse_list(results['qsd']['to']) + + # Token + results['token'] = NotifyPushover.unquote(results['host']) + + return results diff --git a/lib/apprise/plugins/NotifyPushy.py b/lib/apprise/plugins/NotifyPushy.py new file mode 100644 index 0000000..2a8a456 --- /dev/null +++ b/lib/apprise/plugins/NotifyPushy.py @@ -0,0 +1,384 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# API reference: https://pushy.me/docs/api/send-notifications +import re +import requests +from itertools import chain + +from json import dumps, loads +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import parse_list +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + +# Used to detect a Device and Topic +VALIDATE_DEVICE = re.compile(r'^@(?P[a-z0-9]+)$', re.I) +VALIDATE_TOPIC = re.compile(r'^[#]?(?P[a-z0-9]+)$', re.I) + +# Extend HTTP Error Messages +PUSHY_HTTP_ERROR_MAP = { + 401: 'Unauthorized - Invalid Token.', +} + + +class NotifyPushy(NotifyBase): + """ + A wrapper for Pushy Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Pushy' + + # The services URL + service_url = 'https://pushy.me/' + + # All Pushy requests are secure + secure_protocol = 'pushy' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pushy' + + # Pushy uses the http protocol with JSON requests + notify_url = 'https://api.pushy.me/push?api_key={apikey}' + + # The maximum allowable characters allowed in the body per message + body_maxlen = 4096 + + # Define object templates + templates = ( + '{schema}://{apikey}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('Secret API Key'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'target_device': { + 'name': _('Target Device'), + 'type': 'string', + 'prefix': '@', + 'map_to': 'targets', + }, + 'target_topic': { + 'name': _('Target Topic'), + 'type': 'string', + 'prefix': '#', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'sound': { + # Specify something like ping.aiff + 'name': _('Sound'), + 'type': 'string', + }, + 'badge': { + 'name': _('Badge'), + 'type': 'int', + 'min': 0, + }, + 'to': { + 'alias_of': 'targets', + }, + 'key': { + 'alias_of': 'apikey', + }, + }) + + def __init__(self, apikey, targets=None, sound=None, badge=None, **kwargs): + """ + Initialize Pushy Object + """ + super().__init__(**kwargs) + + # Access Token (associated with project) + self.apikey = validate_regex(apikey) + if not self.apikey: + msg = 'An invalid Pushy Secret API Key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # Get our targets + self.devices = [] + self.topics = [] + + for target in parse_list(targets): + result = VALIDATE_TOPIC.match(target) + if result: + self.topics.append(result.group('topic')) + continue + + result = VALIDATE_DEVICE.match(target) + if result: + self.devices.append(result.group('device')) + continue + + self.logger.warning( + 'Dropped invalid topic/device ' + '({}) specified.'.format(target), + ) + + # Setup our sound + self.sound = sound + + # Badge + try: + # Acquire our badge count if we can: + # - We accept both the integer form as well as a string + # representation + self.badge = int(badge) + if self.badge < 0: + raise ValueError() + + except TypeError: + # NoneType means use Default; this is an okay exception + self.badge = None + + except ValueError: + self.badge = None + self.logger.warning( + 'The specified Pushy badge ({}) is not valid ', badge) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Pushy Notification + """ + + if len(self.topics) + len(self.devices) == 0: + # There were no services to notify + self.logger.warning('There were no Pushy targets to notify.') + return False + + # error tracking (used for function return) + has_error = False + + # Default Header + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + 'Accepts': 'application/json', + } + + # Our URL + notify_url = self.notify_url.format(apikey=self.apikey) + + # Default content response object + content = {} + + # Create a copy of targets (topics and devices) + targets = list(self.topics) + list(self.devices) + while len(targets): + target = targets.pop(0) + + # prepare JSON Object + payload = { + # Mandatory fields + 'to': target, + "data": { + "message": body, + }, + "notification": { + 'body': body, + } + } + + # Optional payload items + if title: + payload['notification']['title'] = title + + if self.sound: + payload['notification']['sound'] = self.sound + + if self.badge is not None: + payload['notification']['badge'] = self.badge + + self.logger.debug('Pushy POST URL: %s (cert_verify=%r)' % ( + notify_url, self.verify_certificate, + )) + self.logger.debug('Pushy Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + notify_url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + # Sample response + # See: https://pushy.me/docs/api/send-notifications + # { + # "success": true, + # "id": "5ea9b214b47cad768a35f13a", + # "info": { + # "devices": 1 + # "failed": ['abc'] + # } + # } + try: + content = loads(r.content) + + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + content = { + "success": False, + "id": '', + "info": {}, + } + + if r.status_code != requests.codes.ok \ + or not content.get('success'): + + # We had a problem + status_str = \ + NotifyPushy.http_response_code_lookup( + r.status_code, PUSHY_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send Pushy notification to {}: ' + '{}{}error={}.'.format( + target, + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + has_error = True + continue + + else: + self.logger.info( + 'Sent Pushy notification to %s.' % target) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Pushy:%s ' + 'notification', target) + self.logger.debug('Socket Exception: %s' % str(e)) + + has_error = True + continue + + 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 = {} + if self.sound: + params['sound'] = self.sound + + if self.badge is not None: + params['badge'] = str(self.badge) + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{apikey}/{targets}/?{params}'.format( + schema=self.secure_protocol, + apikey=self.pprint(self.apikey, privacy, safe=''), + targets='/'.join( + [NotifyPushy.quote(x, safe='@#') for x in chain( + # Topics are prefixed with a pound/hashtag symbol + ['#{}'.format(x) for x in self.topics], + # Devices + ['@{}'.format(x) for x in self.devices], + )]), + params=NotifyPushy.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.topics) + len(self.devices) + + @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 + + # Token + results['apikey'] = NotifyPushy.unquote(results['host']) + + # Retrieve all of our targets + results['targets'] = NotifyPushy.split_path(results['fullpath']) + + # Get the sound + if 'sound' in results['qsd'] and len(results['qsd']['sound']): + results['sound'] = \ + NotifyPushy.unquote(results['qsd']['sound']) + + # Badge + if 'badge' in results['qsd'] and results['qsd']['badge']: + results['badge'] = NotifyPushy.unquote( + results['qsd']['badge'].strip()) + + # Support key variable to store Secret API Key + if 'key' in results['qsd'] and len(results['qsd']['key']): + results['apikey'] = results['qsd']['key'] + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyPushy.parse_list(results['qsd']['to']) + + return results diff --git a/lib/apprise/plugins/NotifyRSyslog.py b/lib/apprise/plugins/NotifyRSyslog.py new file mode 100644 index 0000000..473e4c5 --- /dev/null +++ b/lib/apprise/plugins/NotifyRSyslog.py @@ -0,0 +1,376 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 os +import socket + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + + +class syslog: + """ + Extrapoloated information from the syslog library so that this plugin + would not be dependent on it. + """ + # Notification Categories + LOG_KERN = 0 + LOG_USER = 8 + LOG_MAIL = 16 + LOG_DAEMON = 24 + LOG_AUTH = 32 + LOG_SYSLOG = 40 + LOG_LPR = 48 + LOG_NEWS = 56 + LOG_UUCP = 64 + LOG_CRON = 72 + LOG_LOCAL0 = 128 + LOG_LOCAL1 = 136 + LOG_LOCAL2 = 144 + LOG_LOCAL3 = 152 + LOG_LOCAL4 = 160 + LOG_LOCAL5 = 168 + LOG_LOCAL6 = 176 + LOG_LOCAL7 = 184 + + # Notification Types + LOG_INFO = 6 + LOG_NOTICE = 5 + LOG_WARNING = 4 + LOG_CRIT = 2 + + +class SyslogFacility: + """ + All of the supported facilities + """ + KERN = 'kern' + USER = 'user' + MAIL = 'mail' + DAEMON = 'daemon' + AUTH = 'auth' + SYSLOG = 'syslog' + LPR = 'lpr' + NEWS = 'news' + UUCP = 'uucp' + CRON = 'cron' + LOCAL0 = 'local0' + LOCAL1 = 'local1' + LOCAL2 = 'local2' + LOCAL3 = 'local3' + LOCAL4 = 'local4' + LOCAL5 = 'local5' + LOCAL6 = 'local6' + LOCAL7 = 'local7' + + +SYSLOG_FACILITY_MAP = { + SyslogFacility.KERN: syslog.LOG_KERN, + SyslogFacility.USER: syslog.LOG_USER, + SyslogFacility.MAIL: syslog.LOG_MAIL, + SyslogFacility.DAEMON: syslog.LOG_DAEMON, + SyslogFacility.AUTH: syslog.LOG_AUTH, + SyslogFacility.SYSLOG: syslog.LOG_SYSLOG, + SyslogFacility.LPR: syslog.LOG_LPR, + SyslogFacility.NEWS: syslog.LOG_NEWS, + SyslogFacility.UUCP: syslog.LOG_UUCP, + SyslogFacility.CRON: syslog.LOG_CRON, + SyslogFacility.LOCAL0: syslog.LOG_LOCAL0, + SyslogFacility.LOCAL1: syslog.LOG_LOCAL1, + SyslogFacility.LOCAL2: syslog.LOG_LOCAL2, + SyslogFacility.LOCAL3: syslog.LOG_LOCAL3, + SyslogFacility.LOCAL4: syslog.LOG_LOCAL4, + SyslogFacility.LOCAL5: syslog.LOG_LOCAL5, + SyslogFacility.LOCAL6: syslog.LOG_LOCAL6, + SyslogFacility.LOCAL7: syslog.LOG_LOCAL7, +} + +SYSLOG_FACILITY_RMAP = { + syslog.LOG_KERN: SyslogFacility.KERN, + syslog.LOG_USER: SyslogFacility.USER, + syslog.LOG_MAIL: SyslogFacility.MAIL, + syslog.LOG_DAEMON: SyslogFacility.DAEMON, + syslog.LOG_AUTH: SyslogFacility.AUTH, + syslog.LOG_SYSLOG: SyslogFacility.SYSLOG, + syslog.LOG_LPR: SyslogFacility.LPR, + syslog.LOG_NEWS: SyslogFacility.NEWS, + syslog.LOG_UUCP: SyslogFacility.UUCP, + syslog.LOG_CRON: SyslogFacility.CRON, + syslog.LOG_LOCAL0: SyslogFacility.LOCAL0, + syslog.LOG_LOCAL1: SyslogFacility.LOCAL1, + syslog.LOG_LOCAL2: SyslogFacility.LOCAL2, + syslog.LOG_LOCAL3: SyslogFacility.LOCAL3, + syslog.LOG_LOCAL4: SyslogFacility.LOCAL4, + syslog.LOG_LOCAL5: SyslogFacility.LOCAL5, + syslog.LOG_LOCAL6: SyslogFacility.LOCAL6, + syslog.LOG_LOCAL7: SyslogFacility.LOCAL7, +} + +# Used as a lookup when handling the Apprise -> Syslog Mapping +SYSLOG_PUBLISH_MAP = { + NotifyType.INFO: syslog.LOG_INFO, + NotifyType.SUCCESS: syslog.LOG_NOTICE, + NotifyType.FAILURE: syslog.LOG_CRIT, + NotifyType.WARNING: syslog.LOG_WARNING, +} + + +class NotifyRSyslog(NotifyBase): + """ + A wrapper for Remote Syslog Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Remote Syslog' + + # The services URL + service_url = 'https://tools.ietf.org/html/rfc5424' + + # The default protocol + protocol = 'rsyslog' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_rsyslog' + + # Disable throttle rate for RSyslog requests + request_rate_per_sec = 0 + + # Define object templates + templates = ( + '{schema}://{host}', + '{schema}://{host}:{port}', + '{schema}://{host}/{facility}', + '{schema}://{host}:{port}/{facility}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'facility': { + 'name': _('Facility'), + 'type': 'choice:string', + 'values': [k for k in SYSLOG_FACILITY_MAP.keys()], + 'default': SyslogFacility.USER, + 'required': True, + }, + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + 'default': 514, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'facility': { + # We map back to the same element defined in template_tokens + 'alias_of': 'facility', + }, + 'logpid': { + 'name': _('Log PID'), + 'type': 'bool', + 'default': True, + 'map_to': 'log_pid', + }, + }) + + def __init__(self, facility=None, log_pid=True, **kwargs): + """ + Initialize RSyslog Object + """ + super().__init__(**kwargs) + + if facility: + try: + self.facility = SYSLOG_FACILITY_MAP[facility] + + except KeyError: + msg = 'An invalid syslog facility ' \ + '({}) was specified.'.format(facility) + self.logger.warning(msg) + raise TypeError(msg) + + else: + self.facility = \ + SYSLOG_FACILITY_MAP[ + self.template_tokens['facility']['default']] + + # Include PID with each message. + self.log_pid = log_pid + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform RSyslog Notification + """ + + if title: + # Format title + body = '{}: {}'.format(title, body) + + # Always call throttle before any remote server i/o is made + self.throttle() + host = self.host + port = self.port if self.port \ + else self.template_tokens['port']['default'] + + if self.log_pid: + payload = '<%d>- %d - %s' % ( + SYSLOG_PUBLISH_MAP[notify_type] + self.facility * 8, + os.getpid(), body) + + else: + payload = '<%d>- %s' % ( + SYSLOG_PUBLISH_MAP[notify_type] + self.facility * 8, body) + + # send UDP packet to upstream server + self.logger.debug( + 'RSyslog Host: %s:%d/%s', + host, port, SYSLOG_FACILITY_RMAP[self.facility]) + self.logger.debug('RSyslog Payload: %s' % str(payload)) + + # our sent bytes + sent = 0 + + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(self.socket_connect_timeout) + sent = sock.sendto(payload.encode('utf-8'), (host, port)) + sock.close() + + except socket.gaierror as e: + self.logger.warning( + 'A connection error occurred sending RSyslog ' + 'notification to %s:%d/%s', host, port, + SYSLOG_FACILITY_RMAP[self.facility] + ) + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + except socket.timeout as e: + self.logger.warning( + 'A connection timeout occurred sending RSyslog ' + 'notification to %s:%d/%s', host, port, + SYSLOG_FACILITY_RMAP[self.facility] + ) + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + if sent < len(payload): + self.logger.warning( + 'RSyslog sent %d byte(s) but intended to send %d byte(s)', + sent, len(payload)) + return False + + self.logger.info('Sent RSyslog notification.') + + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'logpid': 'yes' if self.log_pid else 'no', + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{hostname}{port}/{facility}/?{params}'.format( + schema=self.protocol, + hostname=NotifyRSyslog.quote(self.host, safe=''), + port='' if self.port is None + or self.port == self.template_tokens['port']['default'] + else ':{}'.format(self.port), + facility=self.template_tokens['facility']['default'] + if self.facility not in SYSLOG_FACILITY_RMAP + else SYSLOG_FACILITY_RMAP[self.facility], + params=NotifyRSyslog.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, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + tokens = [] + + # Get our path values + tokens.extend(NotifyRSyslog.split_path(results['fullpath'])) + + # Initialization + facility = None + + if tokens: + # Store the last entry as the facility + facility = tokens[-1].lower() + + # However if specified on the URL, that will over-ride what was + # identified + if 'facility' in results['qsd'] and len(results['qsd']['facility']): + facility = results['qsd']['facility'].lower() + + if facility and facility not in SYSLOG_FACILITY_MAP: + # Find first match; if no match is found we set the result + # to the matching key. This allows us to throw a TypeError + # during the __init__() call. The benifit of doing this + # check here is if we do have a valid match, we can support + # short form matches like 'u' which will match against user + facility = next((f for f in SYSLOG_FACILITY_MAP.keys() + if f.startswith(facility)), facility) + + # Save facility if set + if facility: + results['facility'] = facility + + # Include PID as part of the message logged + results['log_pid'] = parse_bool( + results['qsd'].get( + 'logpid', + NotifyRSyslog.template_args['logpid']['default'])) + + return results diff --git a/lib/apprise/plugins/NotifyReddit.py b/lib/apprise/plugins/NotifyReddit.py new file mode 100644 index 0000000..b25e76d --- /dev/null +++ b/lib/apprise/plugins/NotifyReddit.py @@ -0,0 +1,762 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# 1. Visit https://www.reddit.com/prefs/apps and scroll to the bottom +# 2. Click on the button that reads 'are you a developer? create an app...' +# 3. Set the mode to `script`, +# 4. Provide a `name`, `description`, `redirect uri` and save it. +# 5. Once the bot is saved, you'll be given a ID (next to the the bot name) +# and a Secret. + +# The App ID will look something like this: YWARPXajkk645m +# The App Secret will look something like this: YZGKc5YNjq3BsC-bf7oBKalBMeb1xA +# The App will also have a location where you can identify the users +# who have access (identified as Developers) to the app itself. You will +# additionally need these credentials authenticate with. + +# With this information you'll be able to form the URL: +# reddit://{user}:{password}@{app_id}/{app_secret} + +# All of the documentation needed to work with the Reddit API can be found +# here: +# - https://www.reddit.com/dev/api/ +# - https://www.reddit.com/dev/api/#POST_api_submit +# - https://github.com/reddit-archive/reddit/wiki/API +import requests +from json import loads +from datetime import timedelta +from datetime import datetime +from datetime import timezone + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyFormat +from ..common import NotifyType +from ..utils import parse_list +from ..utils import parse_bool +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ +from .. import __title__, __version__ + +# Extend HTTP Error Messages +REDDIT_HTTP_ERROR_MAP = { + 401: 'Unauthorized - Invalid Token', +} + + +class RedditMessageKind: + """ + Define the kinds of messages supported + """ + # Attempt to auto-detect the type prior to passing along the message to + # Reddit + AUTO = 'auto' + + # A common message + SELF = 'self' + + # A Hyperlink + LINK = 'link' + + +REDDIT_MESSAGE_KINDS = ( + RedditMessageKind.AUTO, + RedditMessageKind.SELF, + RedditMessageKind.LINK, +) + + +class NotifyReddit(NotifyBase): + """ + A wrapper for Notify Reddit Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Reddit' + + # The services URL + service_url = 'https://reddit.com' + + # The default secure protocol + secure_protocol = 'reddit' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_reddit' + + # The maximum size of the message + body_maxlen = 6000 + + # Maximum title length as defined by the Reddit API + title_maxlen = 300 + + # Default to markdown + notify_format = NotifyFormat.MARKDOWN + + # The default Notification URL to use + auth_url = 'https://www.reddit.com/api/v1/access_token' + submit_url = 'https://oauth.reddit.com/api/submit' + + # Reddit 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 = 0 + + # Taken right from google.auth.helpers: + clock_skew = timedelta(seconds=10) + + # 1 hour in seconds (the lifetime of our token) + access_token_lifetime_sec = timedelta(seconds=3600) + + # Define object templates + templates = ( + '{schema}://{user}:{password}@{app_id}/{app_secret}/{targets}', + ) + + # Define our template arguments + template_tokens = dict(NotifyBase.template_tokens, **{ + 'user': { + 'name': _('User Name'), + 'type': 'string', + 'required': True, + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'app_id': { + 'name': _('Application ID'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[a-z0-9_-]+$', 'i'), + }, + 'app_secret': { + 'name': _('Application Secret'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[a-z0-9_-]+$', 'i'), + }, + 'target_subreddit': { + 'name': _('Target Subreddit'), + 'type': 'string', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'kind': { + 'name': _('Kind'), + 'type': 'choice:string', + 'values': REDDIT_MESSAGE_KINDS, + 'default': RedditMessageKind.AUTO, + }, + 'flair_id': { + 'name': _('Flair ID'), + 'type': 'string', + 'map_to': 'flair_id', + }, + 'flair_text': { + 'name': _('Flair Text'), + 'type': 'string', + 'map_to': 'flair_text', + }, + 'nsfw': { + 'name': _('NSFW'), + 'type': 'bool', + 'default': False, + 'map_to': 'nsfw', + }, + 'ad': { + 'name': _('Is Ad?'), + 'type': 'bool', + 'default': False, + 'map_to': 'advertisement', + }, + 'replies': { + 'name': _('Send Replies'), + 'type': 'bool', + 'default': True, + 'map_to': 'sendreplies', + }, + 'spoiler': { + 'name': _('Is Spoiler'), + 'type': 'bool', + 'default': False, + 'map_to': 'spoiler', + }, + 'resubmit': { + 'name': _('Resubmit Flag'), + 'type': 'bool', + 'default': False, + 'map_to': 'resubmit', + }, + }) + + def __init__(self, app_id=None, app_secret=None, targets=None, + kind=None, nsfw=False, sendreplies=True, resubmit=False, + spoiler=False, advertisement=False, + flair_id=None, flair_text=None, **kwargs): + """ + Initialize Notify Reddit Object + """ + super().__init__(**kwargs) + + # Initialize subreddit list + self.subreddits = set() + + # Not Safe For Work Flag + self.nsfw = nsfw + + # Send Replies Flag + self.sendreplies = sendreplies + + # Is Spoiler Flag + self.spoiler = spoiler + + # Resubmit Flag + self.resubmit = resubmit + + # Is Ad? + self.advertisement = advertisement + + # Flair details + self.flair_id = flair_id + self.flair_text = flair_text + + # Our keys we build using the provided content + self.__refresh_token = None + self.__access_token = None + self.__access_token_expiry = datetime.now(timezone.utc) + + self.kind = kind.strip().lower() \ + if isinstance(kind, str) \ + else self.template_args['kind']['default'] + + if self.kind not in REDDIT_MESSAGE_KINDS: + msg = 'An invalid Reddit message kind ({}) was specified'.format( + kind) + self.logger.warning(msg) + raise TypeError(msg) + + self.user = validate_regex(self.user) + if not self.user: + msg = 'An invalid Reddit User ID ' \ + '({}) was specified'.format(self.user) + self.logger.warning(msg) + raise TypeError(msg) + + self.password = validate_regex(self.password) + if not self.password: + msg = 'An invalid Reddit Password ' \ + '({}) was specified'.format(self.password) + self.logger.warning(msg) + raise TypeError(msg) + + self.client_id = validate_regex( + app_id, *self.template_tokens['app_id']['regex']) + if not self.client_id: + msg = 'An invalid Reddit App ID ' \ + '({}) was specified'.format(app_id) + self.logger.warning(msg) + raise TypeError(msg) + + self.client_secret = validate_regex( + app_secret, *self.template_tokens['app_secret']['regex']) + if not self.client_secret: + msg = 'An invalid Reddit App Secret ' \ + '({}) was specified'.format(app_secret) + self.logger.warning(msg) + raise TypeError(msg) + + # Build list of subreddits + self.subreddits = [ + sr.lstrip('#') for sr in parse_list(targets) if sr.lstrip('#')] + + if not self.subreddits: + self.logger.warning( + 'No subreddits were identified to be notified') + + # For Rate Limit Tracking Purposes + self.ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None) + + # Default to 1.0 + self.ratelimit_remaining = 1.0 + + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'kind': self.kind, + 'ad': 'yes' if self.advertisement else 'no', + 'nsfw': 'yes' if self.nsfw else 'no', + 'resubmit': 'yes' if self.resubmit else 'no', + 'replies': 'yes' if self.sendreplies else 'no', + 'spoiler': 'yes' if self.spoiler else 'no', + } + + # Flair support + if self.flair_id: + params['flair_id'] = self.flair_id + + if self.flair_text: + params['flair_text'] = self.flair_text + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{user}:{password}@{app_id}/{app_secret}' \ + '/{targets}/?{params}'.format( + schema=self.secure_protocol, + user=NotifyReddit.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + app_id=self.pprint( + self.client_id, privacy, mode=PrivacyMode.Secret, safe=''), + app_secret=self.pprint( + self.client_secret, privacy, mode=PrivacyMode.Secret, + safe=''), + targets='/'.join( + [NotifyReddit.quote(x, safe='') for x in self.subreddits]), + params=NotifyReddit.urlencode(params), + ) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.subreddits) + + def login(self): + """ + A simple wrapper to authenticate with the Reddit Server + """ + + # Prepare our payload + payload = { + 'grant_type': 'password', + 'username': self.user, + 'password': self.password, + } + + # Enforce a False flag setting before calling _fetch() + self.__access_token = False + + # Send Login Information + postokay, response = self._fetch( + self.auth_url, + payload=payload, + ) + + if not postokay or not response: + # Setting this variable to False as a way of letting us know + # we failed to authenticate on our last attempt + self.__access_token = False + return False + + # Our response object looks like this (content has been altered for + # presentation purposes): + # { + # "access_token": Your access token, + # "token_type": "bearer", + # "expires_in": Unix Epoch Seconds, + # "scope": A scope string, + # "refresh_token": Your refresh token + # } + + # Acquire our token + self.__access_token = response.get('access_token') + + # Handle other optional arguments we can use + if 'expires_in' in response: + delta = timedelta(seconds=int(response['expires_in'])) + self.__access_token_expiry = \ + delta + datetime.now(timezone.utc) - self.clock_skew + else: + self.__access_token_expiry = self.access_token_lifetime_sec + \ + datetime.now(timezone.utc) - self.clock_skew + + # The Refresh Token + self.__refresh_token = response.get( + 'refresh_token', self.__refresh_token) + + if self.__access_token: + self.logger.info('Authenticated to Reddit as {}'.format(self.user)) + return True + + self.logger.warning( + 'Failed to authenticate to Reddit as {}'.format(self.user)) + + # Mark our failure + return False + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Reddit Notification + """ + + # error tracking (used for function return) + has_error = False + + if not self.__access_token and not self.login(): + # We failed to authenticate - we're done + return False + + if not len(self.subreddits): + # We have nothing to notify; we're done + self.logger.warning('There are no Reddit targets to notify') + return False + + # Prepare our Message Type/Kind + if self.kind == RedditMessageKind.AUTO: + parsed = NotifyBase.parse_url(body) + # Detect a link + if parsed and parsed.get('schema', '').startswith('http') \ + and parsed.get('host'): + kind = RedditMessageKind.LINK + + else: + kind = RedditMessageKind.SELF + else: + kind = self.kind + + # Create a copy of the subreddits list + subreddits = list(self.subreddits) + while len(subreddits) > 0: + # Retrieve our subreddit + subreddit = subreddits.pop() + + # Prepare our payload + payload = { + 'ad': True if self.advertisement else False, + 'api_type': 'json', + 'extension': 'json', + 'sr': subreddit, + 'title': title if title else self.app_desc, + 'kind': kind, + 'nsfw': True if self.nsfw else False, + 'resubmit': True if self.resubmit else False, + 'sendreplies': True if self.sendreplies else False, + 'spoiler': True if self.spoiler else False, + } + + if self.flair_id: + payload['flair_id'] = self.flair_id + + if self.flair_text: + payload['flair_text'] = self.flair_text + + if kind == RedditMessageKind.LINK: + payload.update({ + 'url': body, + }) + else: + payload.update({ + 'text': body, + }) + + postokay, response = self._fetch(self.submit_url, payload=payload) + # only toggle has_error flag if we had an error + if not postokay: + # Mark our failure + has_error = True + continue + + # If we reach here, we were successful + self.logger.info( + 'Sent Reddit notification to {}'.format( + subreddit)) + + return not has_error + + def _fetch(self, url, payload=None): + """ + Wrapper to Reddit API requests object + """ + + # use what was specified, otherwise build headers dynamically + headers = { + 'User-Agent': '{} v{}'.format(__title__, __version__) + } + + if self.__access_token: + # Set our token + headers['Authorization'] = 'Bearer {}'.format(self.__access_token) + + # Prepare our url + url = self.submit_url if self.__access_token else self.auth_url + + # Some Debug Logging + self.logger.debug('Reddit POST URL: {} (cert_verify={})'.format( + url, self.verify_certificate)) + self.logger.debug('Reddit Payload: %s' % str(payload)) + + # By default set wait to None + wait = 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 + # Reddit server. One would hope we're on NTP and our clocks are + # the same allowing this to role smoothly: + + now = datetime.now(timezone.utc).replace(tzinfo=None) + 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()) + + # Always call throttle before any remote server i/o is made; + self.throttle(wait=wait) + + # Initialize a default value for our content value + content = {} + + # acquire our request mode + try: + r = requests.post( + url, + data=payload, + auth=None if self.__access_token + else (self.client_id, self.client_secret), + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + # We attempt to login again and retry the original request + # if we aren't in the process of handling a login already + if r.status_code != requests.codes.ok \ + and self.__access_token and url != self.auth_url: + + # We had a problem + status_str = \ + NotifyReddit.http_response_code_lookup( + r.status_code, REDDIT_HTTP_ERROR_MAP) + + self.logger.debug( + 'Taking countermeasures after failed to send to Reddit ' + '{}: {}error={}'.format( + url, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # We failed to authenticate with our token; login one more + # time and retry this original request + if not self.login(): + return (False, {}) + + # Try again + r = requests.post( + url, + data=payload, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout + ) + + # Get our JSON content if it's possible + try: + content = loads(r.content) + + except (TypeError, ValueError, AttributeError): + # TypeError = r.content is not a String + # ValueError = r.content is Unparsable + # AttributeError = r.content is None + + # We had a problem + status_str = \ + NotifyReddit.http_response_code_lookup( + r.status_code, REDDIT_HTTP_ERROR_MAP) + + # Reddit always returns a JSON response + self.logger.warning( + 'Failed to send to Reddit after countermeasures {}: ' + '{}error={}'.format( + url, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + return (False, {}) + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyReddit.http_response_code_lookup( + r.status_code, REDDIT_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send to Reddit {}: ' + '{}error={}'.format( + url, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + return (False, content) + + errors = [] if not content else \ + content.get('json', {}).get('errors', []) + if errors: + self.logger.warning( + 'Failed to send to Reddit {}: ' + '{}'.format( + url, + str(errors))) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + return (False, content) + + try: + # Store our rate limiting (if provided) + self.ratelimit_remaining = \ + float(r.headers.get( + 'X-RateLimit-Remaining')) + self.ratelimit_reset = datetime.fromtimestamp( + int(r.headers.get('X-RateLimit-Reset')), timezone.utc + ).replace(tzinfo=None) + + except (TypeError, ValueError): + # This is returned if we could not retrieve this information + # gracefully accept this state and move on + pass + + except requests.RequestException as e: + self.logger.warning( + 'Exception received when sending Reddit to {}'. + format(url)) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + return (False, content) + + return (True, content) + + @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 + + # Acquire our targets + results['targets'] = NotifyReddit.split_path(results['fullpath']) + + # Kind override + if 'kind' in results['qsd'] and results['qsd']['kind']: + results['kind'] = NotifyReddit.unquote( + results['qsd']['kind'].strip().lower()) + else: + results['kind'] = RedditMessageKind.AUTO + + # Is an Ad? + results['ad'] = \ + parse_bool(results['qsd'].get('ad', False)) + + # Get Not Safe For Work (NSFW) Flag + results['nsfw'] = \ + parse_bool(results['qsd'].get('nsfw', False)) + + # Send Replies Flag + results['replies'] = \ + parse_bool(results['qsd'].get('replies', True)) + + # Resubmit Flag + results['resubmit'] = \ + parse_bool(results['qsd'].get('resubmit', False)) + + # Is Spoiler Flag + results['spoiler'] = \ + parse_bool(results['qsd'].get('spoiler', False)) + + if 'flair_text' in results['qsd']: + results['flair_text'] = \ + NotifyReddit.unquote(results['qsd']['flair_text']) + + if 'flair_id' in results['qsd']: + results['flair_id'] = \ + NotifyReddit.unquote(results['qsd']['flair_id']) + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyReddit.parse_list(results['qsd']['to']) + + if 'app_id' in results['qsd']: + results['app_id'] = \ + NotifyReddit.unquote(results['qsd']['app_id']) + else: + # The App/Bot ID is the hostname + results['app_id'] = NotifyReddit.unquote(results['host']) + + if 'app_secret' in results['qsd']: + results['app_secret'] = \ + NotifyReddit.unquote(results['qsd']['app_secret']) + else: + # The first target identified is the App secret + results['app_secret'] = \ + None if not results['targets'] else results['targets'].pop(0) + + return results diff --git a/lib/apprise/plugins/NotifyRocketChat.py b/lib/apprise/plugins/NotifyRocketChat.py new file mode 100644 index 0000000..6384386 --- /dev/null +++ b/lib/apprise/plugins/NotifyRocketChat.py @@ -0,0 +1,732 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 re +import requests +from json import loads +from json import dumps +from itertools import chain + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyImageSize +from ..common import NotifyFormat +from ..common import NotifyType +from ..utils import parse_list +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + +IS_CHANNEL = re.compile(r'^#(?P[A-Za-z0-9_-]+)$') +IS_USER = re.compile(r'^@(?P[A-Za-z0-9._-]+)$') +IS_ROOM_ID = re.compile(r'^(?P[A-Za-z0-9]+)$') + +# Extend HTTP Error Messages +RC_HTTP_ERROR_MAP = { + 400: 'Channel/RoomId is wrong format, or missing from server.', + 401: 'Authentication tokens provided is invalid or missing.', +} + + +class RocketChatAuthMode: + """ + The Chat Authentication mode is detected + """ + # providing a webhook + WEBHOOK = "webhook" + + # Providing a username and password (default) + BASIC = "basic" + + +# Define our authentication modes +ROCKETCHAT_AUTH_MODES = ( + RocketChatAuthMode.WEBHOOK, + RocketChatAuthMode.BASIC, +) + + +class NotifyRocketChat(NotifyBase): + """ + A wrapper for Notify Rocket.Chat Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Rocket.Chat' + + # The services URL + service_url = 'https://rocket.chat/' + + # The default protocol + protocol = 'rocket' + + # The default secure protocol + secure_protocol = 'rockets' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_rocketchat' + + # Allows the user to specify the NotifyImageSize object; this is supported + # through the webhook + image_size = NotifyImageSize.XY_128 + + # The title is not used + title_maxlen = 0 + + # The maximum size of the message + body_maxlen = 1000 + + # Default to markdown + notify_format = NotifyFormat.MARKDOWN + + # Define object templates + templates = ( + '{schema}://{user}:{password}@{host}:{port}/{targets}', + '{schema}://{user}:{password}@{host}/{targets}', + '{schema}://{webhook}@{host}', + '{schema}://{webhook}@{host}:{port}', + '{schema}://{webhook}@{host}/{targets}', + '{schema}://{webhook}@{host}:{port}/{targets}', + ) + + # Define our template arguments + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'user': { + 'name': _('Username'), + 'type': 'string', + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + }, + 'webhook': { + 'name': _('Webhook'), + 'type': 'string', + }, + 'target_channel': { + 'name': _('Target Channel'), + 'type': 'string', + 'prefix': '#', + 'map_to': 'targets', + }, + 'target_user': { + 'name': _('Target User'), + 'type': 'string', + 'prefix': '@', + 'map_to': 'targets', + }, + 'target_room': { + 'name': _('Target Room ID'), + 'type': 'string', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'mode': { + 'name': _('Webhook Mode'), + 'type': 'choice:string', + 'values': ROCKETCHAT_AUTH_MODES, + }, + 'avatar': { + 'name': _('Use Avatar'), + 'type': 'bool', + 'default': False, + }, + 'webhook': { + 'alias_of': 'webhook', + }, + 'to': { + 'alias_of': 'targets', + }, + }) + + def __init__(self, webhook=None, targets=None, mode=None, avatar=None, + **kwargs): + """ + Initialize Notify Rocket.Chat Object + """ + super().__init__(**kwargs) + + # Set our schema + self.schema = 'https' if self.secure else 'http' + + # Prepare our URL + self.api_url = '%s://%s' % (self.schema, self.host) + + if isinstance(self.port, int): + self.api_url += ':%d' % self.port + + # Initialize channels list + self.channels = list() + + # Initialize room list + self.rooms = list() + + # Initialize user list (webhook only) + self.users = list() + + # Assign our webhook (if defined) + self.webhook = webhook + + # Used to track token headers upon authentication (if successful) + # This is only used if not on webhook mode + self.headers = {} + + # Authentication mode + self.mode = None \ + if not isinstance(mode, str) \ + else mode.lower() + + if self.mode and self.mode not in ROCKETCHAT_AUTH_MODES: + msg = 'The authentication mode specified ({}) is invalid.'.format( + mode) + self.logger.warning(msg) + raise TypeError(msg) + + # Detect our mode if it wasn't specified + if not self.mode: + if self.webhook is not None: + # Just a username was specified, we treat this as a webhook + self.mode = RocketChatAuthMode.WEBHOOK + else: + self.mode = RocketChatAuthMode.BASIC + + if self.mode == RocketChatAuthMode.BASIC \ + and not (self.user and self.password): + # Username & Password is required for Rocket Chat to work + msg = 'No Rocket.Chat user/pass combo was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + elif self.mode == RocketChatAuthMode.WEBHOOK and not self.webhook: + msg = 'No Rocket.Chat Incoming Webhook was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + # Validate recipients and drop bad ones: + for recipient in parse_list(targets): + result = IS_CHANNEL.match(recipient) + if result: + # store valid device + self.channels.append(result.group('name')) + continue + + result = IS_ROOM_ID.match(recipient) + if result: + # store valid room + self.rooms.append(result.group('name')) + continue + + result = IS_USER.match(recipient) + if result: + # store valid room + self.users.append(result.group('name')) + continue + + self.logger.warning( + 'Dropped invalid channel/room/user ' + '({}) specified.'.format(recipient), + ) + + if self.mode == RocketChatAuthMode.BASIC and \ + len(self.rooms) == 0 and len(self.channels) == 0: + msg = 'No Rocket.Chat room and/or channels specified to notify.' + self.logger.warning(msg) + raise TypeError(msg) + + # Prepare our avatar setting + # - if specified; that trumps all + # - if not specified and we're dealing with a basic setup, the Avatar + # is disabled by default. This is because if the account doesn't + # have the bot flag set on it it won't work as documented here: + # https://developer.rocket.chat/api/rest-api/endpoints\ + # /team-collaboration-endpoints/chat/postmessage + # - Otherwise if we're a webhook, we enable the avatar by default + # (if not otherwise specified) since it will work nicely. + # Place an avatar image to associate with our content + if self.mode == RocketChatAuthMode.BASIC: + self.avatar = False if avatar is None else avatar + + else: # self.mode == RocketChatAuthMode.WEBHOOK: + self.avatar = True if avatar is None else avatar + + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'avatar': 'yes' if self.avatar else 'no', + 'mode': self.mode, + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Determine Authentication + if self.mode == RocketChatAuthMode.BASIC: + auth = '{user}:{password}@'.format( + user=NotifyRocketChat.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + else: + auth = '{user}{webhook}@'.format( + user='{}:'.format(NotifyRocketChat.quote(self.user, safe='')) + if self.user else '', + webhook=self.pprint(self.webhook, privacy, + mode=PrivacyMode.Secret, safe=''), + ) + + default_port = 443 if self.secure else 80 + + return '{schema}://{auth}{hostname}{port}/{targets}/?{params}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + auth=auth, + # never encode hostname since we're expecting it to be a valid one + hostname=self.host, + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + targets='/'.join( + [NotifyRocketChat.quote(x, safe='@#') for x in chain( + # Channels are prefixed with a pound/hashtag symbol + ['#{}'.format(x) for x in self.channels], + # Rooms are as is + self.rooms, + # Users + ['@{}'.format(x) for x in self.users], + )]), + params=NotifyRocketChat.urlencode(params), + ) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.channels) + len(self.rooms) + len(self.users) + return targets if targets > 0 else 1 + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + wrapper to _send since we can alert more then one channel + """ + + # Call the _send_ function applicable to whatever mode we're in + # - calls _send_webhook_notification if the mode variable is set + # - calls _send_basic_notification if the mode variable is not set + return getattr(self, '_send_{}_notification'.format(self.mode))( + body=body, title=title, notify_type=notify_type, **kwargs) + + def _send_webhook_notification(self, body, title='', + notify_type=NotifyType.INFO, **kwargs): + """ + Sends a webhook notification + """ + + # Our payload object + payload = self._payload(body, title, notify_type) + + # Assemble our webhook URL + path = 'hooks/{}'.format(self.webhook) + + # Build our list of channels/rooms/users (if any identified) + targets = ['@{}'.format(u) for u in self.users] + targets.extend(['#{}'.format(c) for c in self.channels]) + targets.extend(['{}'.format(r) for r in self.rooms]) + + if len(targets) == 0: + # We can take an early exit + return self._send( + payload, notify_type=notify_type, path=path, **kwargs) + + # Otherwise we want to iterate over each of the targets + + # Initiaize our error tracking + has_error = False + + while len(targets): + # Retrieve our target + target = targets.pop(0) + + # Assign our channel/room/user + payload['channel'] = target + + if not self._send( + payload, notify_type=notify_type, path=path, **kwargs): + + # toggle flag + has_error = True + + return not has_error + + def _send_basic_notification(self, body, title='', + notify_type=NotifyType.INFO, **kwargs): + """ + Authenticates with the server using a user/pass combo for + notifications. + """ + # Track whether we authenticated okay + + if not self.login(): + return False + + # prepare JSON Object + _payload = self._payload(body, title, notify_type) + + # Initiaize our error tracking + has_error = False + + # Build our list of channels/rooms/users (if any identified) + channels = ['@{}'.format(u) for u in self.users] + channels.extend(['#{}'.format(c) for c in self.channels]) + + # Create a copy of our channels to notify against + payload = _payload.copy() + while len(channels) > 0: + # Get Channel + channel = channels.pop(0) + payload['channel'] = channel + + if not self._send( + payload, notify_type=notify_type, headers=self.headers, + **kwargs): + + # toggle flag + has_error = True + + # Create a copy of our room id's to notify against + rooms = list(self.rooms) + payload = _payload.copy() + while len(rooms): + # Get Room + room = rooms.pop(0) + payload['roomId'] = room + + if not self._send( + payload, notify_type=notify_type, headers=self.headers, + **kwargs): + + # toggle flag + has_error = True + + # logout + self.logout() + + return not has_error + + def _payload(self, body, title='', notify_type=NotifyType.INFO): + """ + Prepares a payload object + """ + # prepare JSON Object + payload = { + "text": body, + } + + # apply our images if they're set to be displayed + image_url = self.image_url(notify_type) + if self.avatar and image_url: + payload['avatar'] = image_url + + return payload + + def _send(self, payload, notify_type, path='api/v1/chat.postMessage', + headers={}, **kwargs): + """ + Perform Notify Rocket.Chat Notification + """ + + api_url = '{}/{}'.format(self.api_url, path) + + self.logger.debug('Rocket.Chat POST URL: %s (cert_verify=%r)' % ( + api_url, self.verify_certificate)) + self.logger.debug('Rocket.Chat Payload: %s' % str(payload)) + + # Apply minimum headers + headers.update({ + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + }) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + api_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 = \ + NotifyRocketChat.http_response_code_lookup( + r.status_code, RC_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send Rocket.Chat {}:notification: ' + '{}{}error={}.'.format( + self.mode, + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info( + 'Sent Rocket.Chat {}:notification.'.format(self.mode)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Rocket.Chat ' + '{}:notification.'.format(self.mode)) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + def login(self): + """ + login to our server + + """ + + payload = { + 'username': self.user, + 'password': self.password, + } + + api_url = '{}/{}'.format(self.api_url, 'api/v1/login') + + try: + r = requests.post( + api_url, + data=payload, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyRocketChat.http_response_code_lookup( + r.status_code, RC_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to authenticate {} with Rocket.Chat: ' + '{}{}error={}.'.format( + self.user, + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.debug('Rocket.Chat authentication successful') + response = loads(r.content) + if response.get('status') != "success": + self.logger.warning( + 'Could not authenticate {} with Rocket.Chat.'.format( + self.user)) + return False + + # Set our headers for further communication + self.headers['X-Auth-Token'] = response.get( + 'data', {'authToken': None}).get('authToken') + self.headers['X-User-Id'] = response.get( + 'data', {'userId': None}).get('userId') + + except (AttributeError, TypeError, ValueError): + # Our response was not the JSON type we had expected it to be + # - ValueError = r.content is Unparsable + # - TypeError = r.content is None + # - AttributeError = r is None + self.logger.warning( + 'A commuication error occurred authenticating {} on ' + 'Rocket.Chat.'.format(self.user)) + return False + + except requests.RequestException as e: + self.logger.warning( + 'A connection error occurred authenticating {} on ' + 'Rocket.Chat.'.format(self.user)) + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + return True + + def logout(self): + """ + logout of our server + """ + + api_url = '{}/{}'.format(self.api_url, 'api/v1/logout') + + try: + r = requests.post( + api_url, + headers=self.headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyRocketChat.http_response_code_lookup( + r.status_code, RC_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to logoff {} from Rocket.Chat: ' + '{}{}error={}.'.format( + self.user, + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.debug( + 'Rocket.Chat log off successful; response %s.' % ( + r.content)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred logging off the ' + 'Rocket.Chat server') + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + return True + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + + try: + # Attempt to detect the webhook (if specified in the URL) + # If no webhook is specified, then we just pass along as if nothing + # happened. However if we do find a webhook, we want to rebuild our + # URL without it since it conflicts with standard URLs. Support + # %2F since that is a forward slash escaped + + # rocket://webhook@host + # rocket://user:webhook@host + match = re.match( + r'^\s*(?P[^:]+://)((?P[^:]+):)?' + r'(?P[a-z0-9]+(/|%2F)' + r'[a-z0-9]+)\@(?P.+)$', url, re.I) + + except TypeError: + # Not a string + return None + + if match: + # Re-assemble our URL without the webhook + url = '{schema}{user}{url}'.format( + schema=match.group('schema'), + user='{}@'.format(match.group('user')) + if match.group('user') else '', + url=match.group('url'), + ) + + results = NotifyBase.parse_url(url) + if not results: + # We're done early as we couldn't load the results + return results + + if match: + # store our webhook + results['webhook'] = \ + NotifyRocketChat.unquote(match.group('webhook')) + + # Take on the password too in the event we're in basic mode + # We do not unquote() as this is done at a later state + results['password'] = match.group('webhook') + + # Apply our targets + results['targets'] = NotifyRocketChat.split_path(results['fullpath']) + + # The user may have forced the mode + if 'mode' in results['qsd'] and len(results['qsd']['mode']): + results['mode'] = \ + NotifyRocketChat.unquote(results['qsd']['mode']) + + # avatar icon + if 'avatar' in results['qsd'] and len(results['qsd']['avatar']): + results['avatar'] = parse_bool(results['qsd'].get('avatar', True)) + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyRocketChat.parse_list(results['qsd']['to']) + + # The 'webhook' over-ride (if specified) + if 'webhook' in results['qsd'] and len(results['qsd']['webhook']): + results['webhook'] = \ + NotifyRocketChat.unquote(results['qsd']['webhook']) + + return results diff --git a/lib/apprise/plugins/NotifyRyver.py b/lib/apprise/plugins/NotifyRyver.py new file mode 100644 index 0000000..70f2fa4 --- /dev/null +++ b/lib/apprise/plugins/NotifyRyver.py @@ -0,0 +1,358 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# To use this plugin, you need to first generate a webhook. + +# When you're complete, you will recieve a URL that looks something like this: +# https://apprise.ryver.com/application/webhook/ckhrjW8w672m6HG +# ^ ^ +# | | +# These are important <---^----------------------------------------^ +# +import re +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 validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +class RyverWebhookMode: + """ + Ryver supports to webhook modes + """ + SLACK = 'slack' + RYVER = 'ryver' + + +# Define the types in a list for validation purposes +RYVER_WEBHOOK_MODES = ( + RyverWebhookMode.SLACK, + RyverWebhookMode.RYVER, +) + + +class NotifyRyver(NotifyBase): + """ + A wrapper for Ryver Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Ryver' + + # The services URL + service_url = 'https://ryver.com/' + + # The default secure protocol + secure_protocol = 'ryver' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_ryver' + + # 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 = 1000 + + # Define object templates + templates = ( + '{schema}://{organization}/{token}', + '{schema}://{botname}@{organization}/{token}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'organization': { + 'name': _('Organization'), + 'type': 'string', + 'required': True, + 'regex': (r'^[A-Z0-9_-]{3,32}$', 'i'), + }, + 'token': { + 'name': _('Token'), + 'type': 'string', + 'required': True, + 'private': True, + 'regex': (r'^[A-Z0-9]{15}$', 'i'), + }, + 'botname': { + 'name': _('Bot Name'), + 'type': 'string', + 'map_to': 'user', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'mode': { + 'name': _('Webhook Mode'), + 'type': 'choice:string', + 'values': RYVER_WEBHOOK_MODES, + 'default': RyverWebhookMode.RYVER, + }, + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': True, + 'map_to': 'include_image', + }, + }) + + def __init__(self, organization, token, mode=RyverWebhookMode.RYVER, + include_image=True, **kwargs): + """ + Initialize Ryver Object + """ + super().__init__(**kwargs) + + # API Token (associated with project) + self.token = validate_regex( + token, *self.template_tokens['token']['regex']) + if not self.token: + msg = 'An invalid Ryver API Token ' \ + '({}) was specified.'.format(token) + self.logger.warning(msg) + raise TypeError(msg) + + # Organization (associated with project) + self.organization = validate_regex( + organization, *self.template_tokens['organization']['regex']) + if not self.organization: + msg = 'An invalid Ryver Organization ' \ + '({}) was specified.'.format(organization) + self.logger.warning(msg) + raise TypeError(msg) + + # Store our webhook mode + self.mode = None \ + if not isinstance(mode, str) else mode.lower() + + if self.mode not in RYVER_WEBHOOK_MODES: + msg = 'The Ryver webhook mode specified ({}) is invalid.' \ + .format(mode) + self.logger.warning(msg) + raise TypeError(msg) + + # Place an image inline with the message body + self.include_image = include_image + + # Slack formatting requirements are defined here which Ryver supports: + # https://api.slack.com/docs/message-formatting + self._re_formatting_map = { + # New lines must become the string version + r'\r\*\n': '\\n', + # Escape other special characters + r'&': '&', + r'<': '<', + r'>': '>', + } + + # Iterate over above list and store content accordingly + self._re_formatting_rules = re.compile( + r'(' + '|'.join(self._re_formatting_map.keys()) + r')', + re.IGNORECASE, + ) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Ryver Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + } + + if self.mode == RyverWebhookMode.SLACK: + # Perform Slack formatting + title = self._re_formatting_rules.sub( # pragma: no branch + lambda x: self._re_formatting_map[x.group()], title, + ) + body = self._re_formatting_rules.sub( # pragma: no branch + lambda x: self._re_formatting_map[x.group()], body, + ) + + url = 'https://{}.ryver.com/application/webhook/{}'.format( + self.organization, + self.token, + ) + + # prepare JSON Object + payload = { + 'body': body if not title else '**{}**\r\n{}'.format(title, body), + 'createSource': { + 'displayName': self.user, + 'avatar': 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: + payload['createSource']['avatar'] = image_url + + self.logger.debug('Ryver POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('Ryver 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 = \ + NotifyBase.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send Ryver notification: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info('Sent Ryver notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Ryver:%s ' % ( + self.organization) + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + return True + + 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', + 'mode': self.mode, + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Determine if there is a botname present + botname = '' + if self.user: + botname = '{botname}@'.format( + botname=NotifyRyver.quote(self.user, safe=''), + ) + + return '{schema}://{botname}{organization}/{token}/?{params}'.format( + schema=self.secure_protocol, + botname=botname, + organization=NotifyRyver.quote(self.organization, safe=''), + token=self.pprint(self.token, privacy, safe=''), + params=NotifyRyver.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, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + # The first token is stored in the hostname + results['organization'] = NotifyRyver.unquote(results['host']) + + # Now fetch the remaining tokens + try: + results['token'] = \ + NotifyRyver.split_path(results['fullpath'])[0] + + except IndexError: + # no token + results['token'] = None + + # Retrieve the mode + results['mode'] = results['qsd'].get('mode', RyverWebhookMode.RYVER) + + # use image= for consistency with the other plugins + results['include_image'] = \ + parse_bool(results['qsd'].get('image', True)) + + return results + + @staticmethod + def parse_native_url(url): + """ + Support https://RYVER_ORG.ryver.com/application/webhook/TOKEN + """ + + result = re.match( + r'^https?://(?P[A-Z0-9_-]+)\.ryver\.com/application/webhook/' + r'(?P[A-Z0-9]+)/?' + r'(?P\?.+)?$', url, re.I) + + if result: + return NotifyRyver.parse_url( + '{schema}://{org}/{webhook_token}/{params}'.format( + schema=NotifyRyver.secure_protocol, + org=result.group('org'), + webhook_token=result.group('webhook_token'), + params='' if not result.group('params') + else result.group('params'))) + + return None diff --git a/lib/apprise/plugins/NotifySES.py b/lib/apprise/plugins/NotifySES.py new file mode 100644 index 0000000..37a0342 --- /dev/null +++ b/lib/apprise/plugins/NotifySES.py @@ -0,0 +1,942 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# API Information: +# - https://docs.aws.amazon.com/ses/latest/APIReference/API_SendRawEmail.html +# +# AWS Credentials (access_key and secret_access_key) +# - https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/\ +# setup-credentials.html +# - https://docs.aws.amazon.com/toolkit-for-eclipse/v1/user-guide/\ +# setup-credentials.html +# +# Other systems write these credentials to: +# - ~/.aws/credentials on Linux, macOS, or Unix +# - C:\Users\USERNAME\.aws\credentials on Windows +# +# +# To get A users access key ID and secret access key +# +# 1. Open the IAM console: https://console.aws.amazon.com/iam/home +# 2. On the navigation menu, choose Users. +# 3. Choose your IAM user name (not the check box). +# 4. Open the Security credentials tab, and then choose: +# Create Access key - Programmatic access +# 5. To see the new access key, choose Show. Your credentials resemble +# the following: +# Access key ID: AKIAIOSFODNN7EXAMPLE +# Secret access key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +# +# To download the key pair, choose Download .csv file. Store the keys +# The account requries this permssion to 'SES v2 : SendEmail' in order to +# work +# +# To get the root users account (if you're logged in as that) you can +# visit: https://console.aws.amazon.com/iam/home#/\ +# security_credentials$access_key +# +# This information is vital to work with SES + + +# To use/test the service, i logged into the portal via: +# - https://portal.aws.amazon.com +# +# Go to the dashboard of the Amazon SES (Simple Email Service) +# 1. You must have a verified identity; click on that option and create one +# if you don't already have one. Until it's verified, you won't be able to +# do the next step. +# 2. From here you'll be able to retrieve your ARN associated with your +# identity you want Apprise to send emails on behalf. It might look +# something like: +# arn:aws:ses:us-east-2:133216123003:identity/user@example.com +# +# This is your ARN (Amazon Record Name) +# +# + +import re +import hmac +import base64 +import requests +from hashlib import sha256 +from datetime import datetime +from datetime import timezone +from collections import OrderedDict +from xml.etree import ElementTree +from email.mime.text import MIMEText +from email.mime.application import MIMEApplication +from email.mime.multipart import MIMEMultipart +from email.utils import formataddr +from email.header import Header +from urllib.parse import quote + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyFormat +from ..common import NotifyType +from ..utils import parse_emails +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ +from ..utils import is_email + +# Our Regin Identifier +# support us-gov-west-1 syntax as well +IS_REGION = re.compile( + r'^\s*(?P[a-z]{2})-(?P[a-z-]+?)-(?P[0-9]+)\s*$', re.I) + +# Extend HTTP Error Messages +AWS_HTTP_ERROR_MAP = { + 403: 'Unauthorized - Invalid Access/Secret Key Combination.', +} + + +class NotifySES(NotifyBase): + """ + A wrapper for AWS SES (Amazon Simple Email Service) + """ + + # The default descriptive name associated with the Notification + service_name = 'AWS Simple Email Service (SES)' + + # The services URL + service_url = 'https://aws.amazon.com/ses/' + + # The default secure protocol + secure_protocol = 'ses' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_ses' + + # Support attachments + attachment_support = True + + # AWS is pretty good for handling data load so request limits + # can occur in much shorter bursts + request_rate_per_sec = 2.5 + + # Default Notify Format + notify_format = NotifyFormat.HTML + + # Define object templates + templates = ( + '{schema}://{from_email}/{access_key_id}/{secret_access_key}/' + '{region}/{targets}', + '{schema}://{from_email}/{access_key_id}/{secret_access_key}/' + '{region}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'from_email': { + 'name': _('From Email'), + 'type': 'string', + 'map_to': 'from_addr', + 'required': True, + }, + 'access_key_id': { + 'name': _('Access Key ID'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'secret_access_key': { + 'name': _('Secret Access Key'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'region': { + 'name': _('Region'), + 'type': 'string', + 'regex': (r'^[a-z]{2}-[a-z-]+?-[0-9]+$', 'i'), + 'required': True, + 'map_to': 'region_name', + }, + 'targets': { + 'name': _('Target Emails'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'from': { + 'alias_of': 'from_email', + }, + 'reply': { + 'name': _('Reply To Email'), + 'type': 'string', + 'map_to': 'reply_to', + }, + 'name': { + 'name': _('From Name'), + 'type': 'string', + 'map_to': 'from_name', + }, + 'cc': { + 'name': _('Carbon Copy'), + 'type': 'list:string', + }, + 'bcc': { + 'name': _('Blind Carbon Copy'), + 'type': 'list:string', + }, + 'access': { + 'alias_of': 'access_key_id', + }, + 'secret': { + 'alias_of': 'secret_access_key', + }, + 'region': { + 'alias_of': 'region', + }, + }) + + def __init__(self, access_key_id, secret_access_key, region_name, + reply_to=None, from_addr=None, from_name=None, targets=None, + cc=None, bcc=None, **kwargs): + """ + Initialize Notify AWS SES Object + """ + super().__init__(**kwargs) + + # Store our AWS API Access Key + self.aws_access_key_id = validate_regex(access_key_id) + if not self.aws_access_key_id: + msg = 'An invalid AWS Access Key ID was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + # Store our AWS API Secret Access key + self.aws_secret_access_key = validate_regex(secret_access_key) + if not self.aws_secret_access_key: + msg = 'An invalid AWS Secret Access Key ' \ + '({}) was specified.'.format(secret_access_key) + self.logger.warning(msg) + raise TypeError(msg) + + # Acquire our AWS Region Name: + # eg. us-east-1, cn-north-1, us-west-2, ... + self.aws_region_name = validate_regex( + region_name, *self.template_tokens['region']['regex']) + if not self.aws_region_name: + msg = 'An invalid AWS Region ({}) was specified.'.format( + region_name) + self.logger.warning(msg) + raise TypeError(msg) + + # Acquire Email 'To' + self.targets = list() + + # Acquire Carbon Copies + self.cc = set() + + # Acquire Blind Carbon Copies + self.bcc = set() + + # For tracking our email -> name lookups + self.names = {} + + # Set our notify_url based on our region + self.notify_url = 'https://email.{}.amazonaws.com'\ + .format(self.aws_region_name) + + # AWS Service Details + self.aws_service_name = 'ses' + self.aws_canonical_uri = '/' + + # AWS Authentication Details + self.aws_auth_version = 'AWS4' + self.aws_auth_algorithm = 'AWS4-HMAC-SHA256' + self.aws_auth_request = 'aws4_request' + + # Get our From username (if specified) + self.from_name = from_name + + if from_addr: + self.from_addr = from_addr + + else: + # Get our from email address + self.from_addr = '{user}@{host}'.format( + user=self.user, host=self.host) if self.user else None + + if not (self.from_addr and is_email(self.from_addr)): + msg = 'An invalid AWS From ({}) was specified.'.format( + '{user}@{host}'.format(user=self.user, host=self.host)) + self.logger.warning(msg) + raise TypeError(msg) + + self.reply_to = None + if reply_to: + result = is_email(reply_to) + if not result: + msg = 'An invalid AWS Reply To ({}) was specified.'.format( + '{user}@{host}'.format(user=self.user, host=self.host)) + self.logger.warning(msg) + raise TypeError(msg) + + self.reply_to = ( + result['name'] if result['name'] else False, + result['full_email']) + + 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( + (self.from_name if self.from_name else False, self.from_addr)) + + # Validate recipients (cc:) and drop bad ones: + for recipient in parse_emails(cc): + email = is_email(recipient) + if email: + self.cc.add(email['full_email']) + + # Index our name (if one exists) + self.names[email['full_email']] = \ + email['name'] if email['name'] else False + continue + + self.logger.warning( + 'Dropped invalid Carbon Copy email ' + '({}) specified.'.format(recipient), + ) + + # Validate recipients (bcc:) and drop bad ones: + for recipient in parse_emails(bcc): + email = is_email(recipient) + if email: + self.bcc.add(email['full_email']) + + # Index our name (if one exists) + self.names[email['full_email']] = \ + email['name'] if email['name'] else False + continue + + self.logger.warning( + 'Dropped invalid Blind Carbon Copy email ' + '({}) specified.'.format(recipient), + ) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): + """ + wrapper to send_notification since we can alert more then one channel + """ + + if not self.targets: + # There is no one to email; we're done + self.logger.warning( + 'There are no SES email recipients to notify') + return False + + # error tracking (used for function return) + has_error = False + + # Initialize our default from name + from_name = self.from_name if self.from_name \ + else self.reply_to[0] if self.reply_to and \ + self.reply_to[0] else self.app_desc + + reply_to = ( + from_name, self.from_addr + if not self.reply_to else self.reply_to[1]) + + # Create a copy of the targets list + emails = list(self.targets) + while len(emails): + # Get our email to notify + to_name, to_addr = emails.pop(0) + + # Strip target out of cc list if in To or Bcc + cc = (self.cc - self.bcc - set([to_addr])) + + # Strip target out of bcc list if in To + bcc = (self.bcc - set([to_addr])) + + # Format our cc addresses to support the Name field + cc = [formataddr( + (self.names.get(addr, False), addr), charset='utf-8') + for addr in cc] + + # Format our bcc addresses to support the Name field + bcc = [formataddr( + (self.names.get(addr, False), addr), charset='utf-8') + for addr in bcc] + + self.logger.debug('Email From: {} <{}>'.format( + quote(reply_to[0], ' '), + quote(reply_to[1], '@ '))) + + self.logger.debug('Email To: {}'.format(to_addr)) + if cc: + self.logger.debug('Email Cc: {}'.format(', '.join(cc))) + if bcc: + self.logger.debug('Email Bcc: {}'.format(', '.join(bcc))) + + # Prepare Email Message + if self.notify_format == NotifyFormat.HTML: + content = MIMEText(body, 'html', 'utf-8') + + else: + content = MIMEText(body, 'plain', 'utf-8') + + # Create a Multipart container if there is an attachment + base = MIMEMultipart() \ + if attach and self.attachment_support else content + + # TODO: Deduplicate with `NotifyEmail`? + base['Subject'] = Header(title, 'utf-8') + base['From'] = formataddr( + (from_name if from_name else False, self.from_addr), + charset='utf-8') + base['To'] = formataddr((to_name, to_addr), charset='utf-8') + if reply_to[1] != self.from_addr: + base['Reply-To'] = formataddr(reply_to, charset='utf-8') + base['Cc'] = ','.join(cc) + base['Date'] = \ + datetime.now( + timezone.utc).strftime("%a, %d %b %Y %H:%M:%S +0000") + base['X-Application'] = self.app_id + + if attach and self.attachment_support: + # First attach our body to our content as the first element + base.attach(content) + + # Now store our attachments + for attachment in attach: + if not attachment: + # We could not load the attachment; take an early + # exit since this isn't what the end user wanted + + # We could not access the attachment + self.logger.error( + 'Could not access attachment {}.'.format( + attachment.url(privacy=True))) + + return False + + self.logger.debug( + 'Preparing Email attachment {}'.format( + attachment.url(privacy=True))) + + with open(attachment.path, "rb") as abody: + app = MIMEApplication(abody.read()) + app.set_type(attachment.mimetype) + + app.add_header( + 'Content-Disposition', + 'attachment; filename="{}"'.format( + Header(attachment.name, 'utf-8')), + ) + + base.attach(app) + + # Prepare our payload object + payload = { + 'Action': 'SendRawEmail', + 'Version': '2010-12-01', + 'RawMessage.Data': base64.b64encode( + base.as_string().encode('utf-8')).decode('utf-8') + } + + for no, email in enumerate(([to_addr] + bcc + cc), start=1): + payload['Destinations.member.{}'.format(no)] = email + + # Specify from address + payload['Source'] = '{} <{}>'.format( + quote(from_name, ' '), + quote(self.from_addr, '@ ')) + + (result, response) = self._post(payload=payload, to=to_addr) + if not result: + # Mark our failure + has_error = True + continue + + return not has_error + + def _post(self, payload, to): + """ + Wrapper to request.post() to manage it's response better and make + the send() function cleaner and easier to maintain. + + This function returns True if the _post was successful and False + if it wasn't. + """ + + # Always call throttle before any remote server i/o is made; for AWS + # time plays a huge factor in the headers being sent with the payload. + # So for AWS (SES) requests we must throttle before they're generated + # and not directly before the i/o call like other notification + # services do. + self.throttle() + + # Convert our payload from a dict() into a urlencoded string + payload = NotifySES.urlencode(payload) + + # Prepare our Notification URL + # Prepare our AWS Headers based on our payload + headers = self.aws_prepare_request(payload) + + self.logger.debug('AWS SES POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('AWS SES Payload (%d bytes)', len(payload)) + + try: + r = requests.post( + self.notify_url, + data=payload, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifySES.http_response_code_lookup( + r.status_code, AWS_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send AWS SES notification to {}: ' + '{}{}error={}.'.format( + to, + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + return (False, NotifySES.aws_response_to_dict(r.text)) + + else: + self.logger.info( + 'Sent AWS SES notification to "%s".' % (to)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending AWS SES ' + 'notification to "%s".' % (to), + ) + self.logger.debug('Socket Exception: %s' % str(e)) + return (False, NotifySES.aws_response_to_dict(None)) + + return (True, NotifySES.aws_response_to_dict(r.text)) + + def aws_prepare_request(self, payload, reference=None): + """ + Takes the intended payload and returns the headers for it. + + The payload is presumed to have been already urlencoded() + + """ + + # Define our AWS SES header + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', + + # Populated below + 'Content-Length': 0, + 'Authorization': None, + 'X-Amz-Date': None, + } + + # Get a reference time (used for header construction) + reference = datetime.now(timezone.utc) + + # Provide Content-Length + headers['Content-Length'] = str(len(payload)) + + # Amazon Date Format + amzdate = reference.strftime('%Y%m%dT%H%M%SZ') + headers['X-Amz-Date'] = amzdate + + # Credential Scope + scope = '{date}/{region}/{service}/{request}'.format( + date=reference.strftime('%Y%m%d'), + region=self.aws_region_name, + service=self.aws_service_name, + request=self.aws_auth_request, + ) + + # Similar to headers; but a subset. keys must be lowercase + signed_headers = OrderedDict([ + ('content-type', headers['Content-Type']), + ('host', 'email.{region}.amazonaws.com'.format( + region=self.aws_region_name)), + ('x-amz-date', headers['X-Amz-Date']), + ]) + + # + # Build Canonical Request Object + # + canonical_request = '\n'.join([ + # Method + u'POST', + + # URL + self.aws_canonical_uri, + + # Query String (none set for POST) + '', + + # Header Content (must include \n at end!) + # All entries except characters in amazon date must be + # lowercase + '\n'.join(['%s:%s' % (k, v) + for k, v in signed_headers.items()]) + '\n', + + # Header Entries (in same order identified above) + ';'.join(signed_headers.keys()), + + # Payload + sha256(payload.encode('utf-8')).hexdigest(), + ]) + + # Prepare Unsigned Signature + to_sign = '\n'.join([ + self.aws_auth_algorithm, + amzdate, + scope, + sha256(canonical_request.encode('utf-8')).hexdigest(), + ]) + + # Our Authorization header + headers['Authorization'] = ', '.join([ + '{algorithm} Credential={key}/{scope}'.format( + algorithm=self.aws_auth_algorithm, + key=self.aws_access_key_id, + scope=scope, + ), + 'SignedHeaders={signed_headers}'.format( + signed_headers=';'.join(signed_headers.keys()), + ), + 'Signature={signature}'.format( + signature=self.aws_auth_signature(to_sign, reference) + ), + ]) + + return headers + + def aws_auth_signature(self, to_sign, reference): + """ + Generates a AWS v4 signature based on provided payload + which should be in the form of a string. + """ + + def _sign(key, msg, to_hex=False): + """ + Perform AWS Signing + """ + if to_hex: + return hmac.new(key, msg.encode('utf-8'), sha256).hexdigest() + return hmac.new(key, msg.encode('utf-8'), sha256).digest() + + _date = _sign(( + self.aws_auth_version + + self.aws_secret_access_key).encode('utf-8'), + reference.strftime('%Y%m%d')) + + _region = _sign(_date, self.aws_region_name) + _service = _sign(_region, self.aws_service_name) + _signed = _sign(_service, self.aws_auth_request) + return _sign(_signed, to_sign, to_hex=True) + + @staticmethod + def aws_response_to_dict(aws_response): + """ + Takes an AWS Response object as input and returns it as a dictionary + but not befor extracting out what is useful to us first. + + eg: + IN: + + + + + 010f017d87656ee2-a2ea291f-79ea- + 44f3-9d25-00d041de3007-000000 + + + 7abb454e-904b-4e46-a23c-2f4d2fc127a6 + + + + OUT: + { + 'type': 'SendRawEmailResponse', + 'message_id': '010f017d87656ee2-a2ea291f-79ea- + 44f3-9d25-00d041de3007-000000', + 'request_id': '7abb454e-904b-4e46-a23c-2f4d2fc127a6', + } + """ + + # Define ourselves a set of directives we want to keep if found and + # then identify the value we want to map them to in our response + # object + aws_keep_map = { + 'RequestId': 'request_id', + 'MessageId': 'message_id', + + # Error Message Handling + 'Type': 'error_type', + 'Code': 'error_code', + 'Message': 'error_message', + } + + # A default response object that we'll manipulate as we pull more data + # from our AWS Response object + response = { + 'type': None, + 'request_id': None, + 'message_id': None, + } + + try: + # we build our tree, but not before first eliminating any + # reference to namespacing (if present) as it makes parsing + # the tree so much easier. + root = ElementTree.fromstring( + re.sub(' xmlns="[^"]+"', '', aws_response, count=1)) + + # Store our response tag object name + response['type'] = str(root.tag) + + def _xml_iter(root, response): + if len(root) > 0: + for child in root: + # use recursion to parse everything + _xml_iter(child, response) + + elif root.tag in aws_keep_map.keys(): + response[aws_keep_map[root.tag]] = (root.text).strip() + + # Recursivly iterate over our AWS Response to extract the + # fields we're interested in in efforts to populate our response + # object. + _xml_iter(root, response) + + except (ElementTree.ParseError, TypeError): + # bad data just causes us to generate a bad response + pass + + return response + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Acquire any global URL parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + if self.from_name is not None: + # from_name specified; pass it back on the url + params['name'] = self.from_name + + if self.cc: + # Handle our Carbon Copy Addresses + params['cc'] = ','.join( + ['{}{}'.format( + '' if not e not in self.names + else '{}:'.format(self.names[e]), e) for e in self.cc]) + + if self.bcc: + # Handle our Blind Carbon Copy Addresses + params['bcc'] = ','.join(self.bcc) + + if self.reply_to: + # Handle our reply to address + params['reply'] = '{} <{}>'.format(*self.reply_to) \ + if self.reply_to[0] else self.reply_to[1] + + # a simple boolean check as to whether we display our target emails + # or not + has_targets = \ + not (len(self.targets) == 1 + and self.targets[0][1] == self.from_addr) + + return '{schema}://{from_addr}/{key_id}/{key_secret}/{region}/' \ + '{targets}/?{params}'.format( + schema=self.secure_protocol, + from_addr=NotifySES.quote(self.from_addr, safe='@'), + key_id=self.pprint(self.aws_access_key_id, privacy, safe=''), + key_secret=self.pprint( + self.aws_secret_access_key, privacy, + mode=PrivacyMode.Secret, safe=''), + region=NotifySES.quote(self.aws_region_name, safe=''), + targets='' if not has_targets else '/'.join( + [NotifySES.quote('{}{}'.format( + '' if not e[0] else '{}:'.format(e[0]), e[1]), + safe='') for e in self.targets]), + params=NotifySES.urlencode(params), + ) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets > 0 else 1 + + @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 + + # Get our entries; split_path() looks after unquoting content for us + # by default + entries = NotifySES.split_path(results['fullpath']) + + # The AWS Access Key ID is stored in the first entry + access_key_id = entries.pop(0) if entries else None + + # Our AWS Access Key Secret contains slashes in it which unfortunately + # means it is of variable length after the hostname. Since we require + # that the user provides the region code, we intentionally use this + # as our delimiter to detect where our Secret is. + secret_access_key = None + region_name = None + + # We need to iterate over each entry in the fullpath and find our + # region. Once we get there we stop and build our secret from our + # accumulated data. + secret_access_key_parts = list() + + # Section 1: Get Region and Access Secret + index = 0 + for index, entry in enumerate(entries, start=1): + + # Are we at the region yet? + result = IS_REGION.match(entry) + if result: + # Ensure region is nicely formatted + region_name = "{country}-{area}-{no}".format( + country=result.group('country').lower(), + area=result.group('area').lower(), + no=result.group('no'), + ) + + # We're done with Section 1 of our url (the credentials) + break + + elif is_email(entry): + # We're done with Section 1 of our url (the credentials) + index -= 1 + break + + # Store our secret parts + secret_access_key_parts.append(entry) + + # Prepare our Secret Access Key + secret_access_key = '/'.join(secret_access_key_parts) \ + if secret_access_key_parts else None + + # Section 2: Get our Recipients (basically all remaining entries) + results['targets'] = entries[index:] + + if 'name' in results['qsd'] and len(results['qsd']['name']): + # Extract from name to associate with from address + results['from_name'] = \ + NotifySES.unquote(results['qsd']['name']) + + # Handle 'to' email address + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'].append(results['qsd']['to']) + + # Handle Carbon Copy Addresses + if 'cc' in results['qsd'] and len(results['qsd']['cc']): + results['cc'] = NotifySES.parse_list(results['qsd']['cc']) + + # Handle Blind Carbon Copy Addresses + if 'bcc' in results['qsd'] and len(results['qsd']['bcc']): + results['bcc'] = NotifySES.parse_list(results['qsd']['bcc']) + + # Handle From Address handling + if 'from' in results['qsd'] and len(results['qsd']['from']): + results['from_addr'] = \ + NotifySES.unquote(results['qsd']['from']) + + # Handle Reply To Address + if 'reply' in results['qsd'] and len(results['qsd']['reply']): + results['reply_to'] = \ + NotifySES.unquote(results['qsd']['reply']) + + # Handle secret_access_key over-ride + if 'secret' in results['qsd'] and len(results['qsd']['secret']): + results['secret_access_key'] = \ + NotifySES.unquote(results['qsd']['secret']) + else: + results['secret_access_key'] = secret_access_key + + # Handle access key id over-ride + if 'access' in results['qsd'] and len(results['qsd']['access']): + results['access_key_id'] = \ + NotifySES.unquote(results['qsd']['access']) + else: + results['access_key_id'] = access_key_id + + # Handle region name id over-ride + if 'region' in results['qsd'] and len(results['qsd']['region']): + results['region_name'] = \ + NotifySES.unquote(results['qsd']['region']) + else: + results['region_name'] = region_name + + # Return our result set + return results diff --git a/lib/apprise/plugins/NotifySMSEagle.py b/lib/apprise/plugins/NotifySMSEagle.py new file mode 100644 index 0000000..3db131f --- /dev/null +++ b/lib/apprise/plugins/NotifySMSEagle.py @@ -0,0 +1,689 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 re +import requests +from json import dumps, loads +import base64 +from itertools import chain + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import validate_regex +from ..utils import is_phone_no +from ..utils import parse_phone_no +from ..utils import parse_bool +from ..URLBase import PrivacyMode +from ..AppriseLocale import gettext_lazy as _ + + +GROUP_REGEX = re.compile( + r'^\s*(\#|\%35)(?P[a-z0-9_-]+)', re.I) + +CONTACT_REGEX = re.compile( + r'^\s*(\@|\%40)?(?P[a-z0-9_-]+)', re.I) + + +# Priorities +class SMSEaglePriority: + NORMAL = 0 + HIGH = 1 + + +SMSEAGLE_PRIORITIES = ( + SMSEaglePriority.NORMAL, + SMSEaglePriority.HIGH, +) + +SMSEAGLE_PRIORITY_MAP = { + # short for 'normal' + 'normal': SMSEaglePriority.NORMAL, + # short for 'high' + '+': SMSEaglePriority.HIGH, + 'high': SMSEaglePriority.HIGH, +} + + +class SMSEagleCategory: + """ + We define the different category types that we can notify via SMS Eagle + """ + PHONE = 'phone' + GROUP = 'group' + CONTACT = 'contact' + + +SMSEAGLE_CATEGORIES = ( + SMSEagleCategory.PHONE, + SMSEagleCategory.GROUP, + SMSEagleCategory.CONTACT, +) + + +class NotifySMSEagle(NotifyBase): + """ + A wrapper for SMSEagle Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'SMS Eagle' + + # The services URL + service_url = 'https://smseagle.eu' + + # The default protocol + protocol = 'smseagle' + + # The default protocol + secure_protocol = 'smseagles' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_smseagle' + + # The path we send our notification to + notify_path = '/jsonrpc/sms' + + # Support attachments + attachment_support = True + + # The maxumum length of the text message + # The actual limit is 160 but SMSEagle looks after the handling + # of large messages in it's upstream service + body_maxlen = 1200 + + # The maximum targets to include when doing batch transfers + default_batch_size = 10 + + # We don't support titles for SMSEagle notifications + title_maxlen = 0 + + # Define object templates + templates = ( + '{schema}://{token}@{host}/{targets}', + '{schema}://{token}@{host}:{port}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'token': { + 'name': _('Access Token'), + 'type': 'string', + 'required': True, + }, + 'target_phone': { + 'name': _('Target Phone No'), + 'type': 'string', + 'prefix': '+', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + 'map_to': 'targets', + }, + 'target_group': { + 'name': _('Target Group ID'), + 'type': 'string', + 'prefix': '#', + 'regex': (r'^[a-z0-9_-]+$', 'i'), + 'map_to': 'targets', + }, + 'target_contact': { + 'name': _('Target Contact'), + 'type': 'string', + 'prefix': '@', + 'regex': (r'^[a-z0-9_-]+$', 'i'), + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + 'required': True, + } + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'token': { + 'alias_of': 'token', + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + 'status': { + 'name': _('Show Status'), + 'type': 'bool', + 'default': False, + }, + 'test': { + 'name': _('Test Only'), + 'type': 'bool', + 'default': False, + }, + 'flash': { + 'name': _('Flash'), + 'type': 'bool', + 'default': False, + }, + 'priority': { + 'name': _('Priority'), + 'type': 'choice:int', + 'values': SMSEAGLE_PRIORITIES, + 'default': SMSEaglePriority.NORMAL, + }, + }) + + def __init__(self, token=None, targets=None, priority=None, batch=False, + status=False, flash=False, test=False, **kwargs): + """ + Initialize SMSEagle Object + """ + super().__init__(**kwargs) + + # Prepare Flash Mode Flag + self.flash = flash + + # Prepare Test Mode Flag + self.test = test + + # Prepare Batch Mode Flag + self.batch = batch + + # Set Status type + self.status = status + + # Parse our targets + self.target_phones = list() + self.target_groups = list() + self.target_contacts = list() + + # Used for URL generation afterwards only + self.invalid_targets = list() + + # We always use a token if provided + self.token = validate_regex(self.user if not token else token) + if not self.token: + msg = \ + 'An invalid SMSEagle Access Token ({}) was specified.'.format( + self.user if not token else token) + self.logger.warning(msg) + raise TypeError(msg) + + # + # Priority + # + try: + # Acquire our priority if we can: + # - We accept both the integer form as well as a string + # representation + self.priority = int(priority) + + except TypeError: + # NoneType means use Default; this is an okay exception + self.priority = self.template_args['priority']['default'] + + except ValueError: + # Input is a string; attempt to get the lookup from our + # priority mapping + priority = priority.lower().strip() + + # This little bit of black magic allows us to match against + # low, lo, l (for low); + # normal, norma, norm, nor, no, n (for normal) + # ... etc + result = next((key for key in SMSEAGLE_PRIORITY_MAP.keys() + if key.startswith(priority)), None) \ + if priority else None + + # Now test to see if we got a match + if not result: + msg = 'An invalid SMSEagle priority ' \ + '({}) was specified.'.format(priority) + self.logger.warning(msg) + raise TypeError(msg) + + # store our successfully looked up priority + self.priority = SMSEAGLE_PRIORITY_MAP[result] + + if self.priority is not None and \ + self.priority not in SMSEAGLE_PRIORITY_MAP.values(): + msg = 'An invalid SMSEagle priority ' \ + '({}) was specified.'.format(priority) + self.logger.warning(msg) + raise TypeError(msg) + + # Validate our targerts + for target in parse_phone_no(targets): + # Validate targets and drop bad ones: + # Allow 9 digit numbers (without country code) + result = is_phone_no(target, min_len=9) + if result: + # store valid phone number + self.target_phones.append( + '{}{}'.format( + '' if target[0] != '+' else '+', result['full'])) + continue + + result = GROUP_REGEX.match(target) + if result: + # Just store group information + self.target_groups.append(result.group('group')) + continue + + result = CONTACT_REGEX.match(target) + if result: + # Just store contact information + self.target_contacts.append(result.group('contact')) + continue + + self.logger.warning( + 'Dropped invalid phone/group/contact ' + '({}) specified.'.format(target), + ) + self.invalid_targets.append(target) + continue + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): + """ + Perform SMSEagle Notification + """ + + if not self.target_groups and not self.target_phones \ + and not self.target_contacts: + # There were no services to notify + self.logger.warning( + 'There were no SMSEagle targets to notify.') + return False + + # error tracking (used for function return) + has_error = False + + attachments = [] + if attach and self.attachment_support: + 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 + + if not re.match(r'^image/.*', attachment.mimetype, re.I): + # Only support images at this time + self.logger.warning( + 'Ignoring unsupported SMSEagle attachment {}.'.format( + attachment.url(privacy=True))) + continue + + try: + with open(attachment.path, 'rb') as f: + # Prepare our Attachment in Base64 + attachments.append({ + 'content_type': attachment.mimetype, + 'content': 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 + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + } + + # Prepare our payload + params_template = { + # Our Access Token + 'access_token': self.token, + + # The message to send (populated below) + "message": None, + + # 0 = normal priority, 1 = high priority + "highpriority": self.priority, + + # Support unicode characters + "unicode": 1, + + # sms or mms (if attachment) + "message_type": 'sms', + + # Response Types: + # simple: format response as simple object with one result field + # extended: format response as extended JSON object + "responsetype": 'extended', + + # SMS will be sent as flash message (1 = yes, 0 = no) + "flash": 1 if self.flash else 0, + + # Message Simulation + "test": 1 if self.test else 0, + } + + # Set our schema + schema = 'https' if self.secure else 'http' + + # Construct our URL + notify_url = '%s://%s' % (schema, self.host) + if isinstance(self.port, int): + notify_url += ':%d' % self.port + notify_url += self.notify_path + + # Send in batches if identified to do so + batch_size = 1 if not self.batch else self.default_batch_size + + notify_by = { + SMSEagleCategory.PHONE: { + "method": "sms.send_sms", + 'target': 'to', + }, + SMSEagleCategory.GROUP: { + "method": "sms.send_togroup", + 'target': 'groupname', + }, + SMSEagleCategory.CONTACT: { + "method": "sms.send_tocontact", + 'target': 'contactname', + }, + } + + # categories separated into a tuple since notify_by.keys() + # returns an unpredicable list in Python 2.7 which causes + # tests to fail every so often + for category in SMSEAGLE_CATEGORIES: + # Create a copy of our template + payload = { + 'method': notify_by[category]['method'], + 'params': { + notify_by[category]['target']: None, + }, + } + + # Apply Template + payload['params'].update(params_template) + + # Set our Message + payload["params"]["message"] = "{}{}".format( + '' if not self.status else '{} '.format( + self.asset.ascii(notify_type)), body) + + if attachments: + # Store our attachments + payload['params']['message_type'] = 'mms' + payload['params']['attachments'] = attachments + + targets = getattr(self, 'target_{}s'.format(category)) + for index in range(0, len(targets), batch_size): + # Prepare our recipients + payload['params'][notify_by[category]['target']] = \ + ','.join(targets[index:index + batch_size]) + + self.logger.debug('SMSEagle POST URL: %s (cert_verify=%r)' % ( + notify_url, self.verify_certificate, + )) + self.logger.debug('SMSEagle Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + notify_url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + try: + content = loads(r.content) + + # Store our status + status_str = str(content['result']) + + except (AttributeError, TypeError, ValueError, KeyError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + # KeyError = 'result' is not found in result + content = {} + + # The result set can be a list such as: + # b'{"result":[{"message_id":4753,"status":"ok"}]}' + # + # It can also just be as a dictionary: + # b'{"result":{"message_id":4753,"status":"ok"}}' + # + # The below code handles both cases only only fails if a + # non-ok value was returned + + if r.status_code not in ( + requests.codes.ok, requests.codes.created) or \ + not isinstance(content.get('result'), + (dict, list)) or \ + (isinstance(content.get('result'), dict) and + content['result'].get('status') != 'ok') or \ + (isinstance(content.get('result'), list) and + next((True for entry in content.get('result') + if isinstance(entry, dict) and + entry.get('status') != 'ok'), False + ) # pragma: no cover + ): + + # We had a problem + status_str = content.get('result') \ + if content.get('result') else \ + NotifySMSEagle.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send {} {} SMSEagle {} notification: ' + '{}{}error={}.'.format( + len(targets[index:index + batch_size]), + 'to {}'.format(targets[index]) + if batch_size == 1 else '(s)', + category, + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response {} Details:\r\n{}'.format( + category.upper(), r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent {} SMSEagle {} notification{}.' + .format( + len(targets[index:index + batch_size]), + category, + ' to {}'.format(targets[index]) + if batch_size == 1 else '(s)', + )) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending {} SMSEagle ' + '{} notification(s).'.format( + len(targets[index:index + batch_size]), category)) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + 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 = { + 'batch': 'yes' if self.batch else 'no', + 'status': 'yes' if self.status else 'no', + 'flash': 'yes' if self.flash else 'no', + 'test': 'yes' if self.test else 'no', + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + default_priority = self.template_args['priority']['default'] + if self.priority is not None: + # Store our priority; but only if it was specified + params['priority'] = \ + next((key for key, value in SMSEAGLE_PRIORITY_MAP.items() + if value == self.priority), + default_priority) # pragma: no cover + + # Default port handling + default_port = 443 if self.secure else 80 + + return '{schema}://{token}@{hostname}{port}/{targets}?{params}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + token=self.pprint( + self.token, privacy, mode=PrivacyMode.Secret, safe=''), + # never encode hostname since we're expecting it to be a valid one + hostname=self.host, + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + targets='/'.join( + [NotifySMSEagle.quote(x, safe='#@') for x in chain( + # Pass phones directly as is + self.target_phones, + # Contacts + ['@{}'.format(x) for x in self.target_contacts], + # Groups + ['#{}'.format(x) for x in self.target_groups], + # Pass along the same invalid entries as were provided + self.invalid_targets, + )]), + params=NotifySMSEagle.urlencode(params), + ) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + batch_size = 1 if not self.batch else self.default_batch_size + if batch_size > 1: + # Batches can only be sent by group (you can't combine groups into + # a single batch) + total_targets = 0 + for c in SMSEAGLE_CATEGORIES: + targets = len(getattr(self, f'target_{c}s')) + total_targets += int(targets / batch_size) + \ + (1 if targets % batch_size else 0) + return total_targets + + # Normal batch count; just count the targets + return len(self.target_phones) + len(self.target_contacts) + \ + len(self.target_groups) + + @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 + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'] = \ + NotifySMSEagle.split_path(results['fullpath']) + + if 'token' in results['qsd'] and len(results['qsd']['token']): + results['token'] = NotifySMSEagle.unquote(results['qsd']['token']) + + elif not results['password'] and results['user']: + results['token'] = NotifySMSEagle.unquote(results['user']) + + # Support the 'to' variable so that we can support targets this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifySMSEagle.parse_phone_no(results['qsd']['to']) + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get('batch', False)) + + # Get Flash Mode Flag + results['flash'] = \ + parse_bool(results['qsd'].get('flash', False)) + + # Get Test Mode Flag + results['test'] = \ + parse_bool(results['qsd'].get('test', False)) + + # Get status switch + results['status'] = \ + parse_bool(results['qsd'].get('status', False)) + + # Get priority + if 'priority' in results['qsd'] and len(results['qsd']['priority']): + results['priority'] = \ + NotifySMSEagle.unquote(results['qsd']['priority']) + + return results diff --git a/lib/apprise/plugins/NotifySMTP2Go.py b/lib/apprise/plugins/NotifySMTP2Go.py new file mode 100644 index 0000000..45f6615 --- /dev/null +++ b/lib/apprise/plugins/NotifySMTP2Go.py @@ -0,0 +1,581 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# Signup @ https://smtp2go.com (free accounts available) +# +# From your dashboard, you can generate an API Key if you haven't already +# at https://app.smtp2go.com/settings/apikeys/ + +# The API Key from here which will look something like: +# api-60F0DD0AB5BA11ABA421F23C91C88EF4 +# +# Knowing this, you can buid your smtp2go url as follows: +# smtp2go://{user}@{domain}/{apikey} +# smtp2go://{user}@{domain}/{apikey}/{email} +# +# You can email as many addresses as you want as: +# smtp2go://{user}@{domain}/{apikey}/{email1}/{email2}/{emailN} +# +# The {user}@{domain} effectively assembles the 'from' email address +# the email will be transmitted from. If no email address is specified +# then it will also become the 'to' address as well. +# +import base64 +import requests +from json import dumps +from email.utils import formataddr +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..common import NotifyFormat +from ..utils import parse_emails +from ..utils import parse_bool +from ..utils import is_email +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + +SMTP2GO_HTTP_ERROR_MAP = { + 429: 'To many requests.', +} + + +class NotifySMTP2Go(NotifyBase): + """ + A wrapper for SMTP2Go Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'SMTP2Go' + + # The services URL + service_url = 'https://www.smtp2go.com/' + + # All notification requests are secure + secure_protocol = 'smtp2go' + + # SMTP2Go advertises they allow 300 requests per minute. + # 60/300 = 0.2 + request_rate_per_sec = 0.20 + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_smtp2go' + + # Notify URL + notify_url = 'https://api.smtp2go.com/v3/email/send' + + # Support attachments + attachment_support = True + + # Default Notify Format + notify_format = NotifyFormat.HTML + + # The maximum amount of emails that can reside within a single + # batch transfer + default_batch_size = 100 + + # Define object templates + templates = ( + '{schema}://{user}@{host}:{apikey}/', + '{schema}://{user}@{host}:{apikey}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'user': { + 'name': _('User Name'), + 'type': 'string', + 'required': True, + }, + 'host': { + 'name': _('Domain'), + 'type': 'string', + 'required': True, + }, + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'targets': { + 'name': _('Target Emails'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'name': { + 'name': _('From Name'), + 'type': 'string', + 'map_to': 'from_name', + }, + 'to': { + 'alias_of': 'targets', + }, + 'cc': { + 'name': _('Carbon Copy'), + 'type': 'list:string', + }, + 'bcc': { + 'name': _('Blind Carbon Copy'), + 'type': 'list:string', + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + }) + + # Define any kwargs we're using + template_kwargs = { + 'headers': { + 'name': _('Email Header'), + 'prefix': '+', + }, + } + + def __init__(self, apikey, targets, cc=None, bcc=None, from_name=None, + headers=None, batch=False, **kwargs): + """ + Initialize SMTP2Go Object + """ + super().__init__(**kwargs) + + # API Key (associated with project) + self.apikey = validate_regex(apikey) + if not self.apikey: + msg = 'An invalid SMTP2Go API Key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # Validate our username + if not self.user: + msg = 'No SMTP2Go username was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + # Acquire Email 'To' + self.targets = list() + + # Acquire Carbon Copies + self.cc = set() + + # Acquire Blind Carbon Copies + self.bcc = set() + + # For tracking our email -> name lookups + self.names = {} + + self.headers = {} + if headers: + # Store our extra headers + self.headers.update(headers) + + # Prepare Batch Mode Flag + self.batch = batch + + # Get our From username (if specified) + self.from_name = from_name + + # Get our from email address + self.from_addr = '{user}@{host}'.format(user=self.user, host=self.host) + + if not is_email(self.from_addr): + # Parse Source domain based on from_addr + msg = 'Invalid ~From~ email format: {}'.format(self.from_addr) + 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( + (self.from_name if self.from_name else False, self.from_addr)) + + # Validate recipients (cc:) and drop bad ones: + for recipient in parse_emails(cc): + email = is_email(recipient) + if email: + self.cc.add(email['full_email']) + + # Index our name (if one exists) + self.names[email['full_email']] = \ + email['name'] if email['name'] else False + continue + + self.logger.warning( + 'Dropped invalid Carbon Copy email ' + '({}) specified.'.format(recipient), + ) + + # Validate recipients (bcc:) and drop bad ones: + for recipient in parse_emails(bcc): + email = is_email(recipient) + if email: + self.bcc.add(email['full_email']) + + # Index our name (if one exists) + self.names[email['full_email']] = \ + email['name'] if email['name'] else False + continue + + self.logger.warning( + 'Dropped invalid Blind Carbon Copy email ' + '({}) specified.'.format(recipient), + ) + + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): + """ + Perform SMTP2Go Notification + """ + + if not self.targets: + # There is no one to email; we're done + self.logger.warning( + 'There are no Email recipients to notify') + return False + + # error tracking (used for function return) + has_error = False + + # Send in batches if identified to do so + batch_size = 1 if not self.batch else self.default_batch_size + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + } + + # Track our potential attachments + attachments = [] + + if attach and self.attachment_support: + 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: + # Output must be in a DataURL format (that's what + # PushSafer calls it): + attachments.append({ + 'filename': attachment.name, + 'fileblob': base64.b64encode(f.read()) + .decode('utf-8'), + 'mimetype': attachment.mimetype, + }) + + 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 + + sender = formataddr( + (self.from_name if self.from_name else False, + self.from_addr), charset='utf-8') + + # Prepare our payload + payload = { + # API Key + 'api_key': self.apikey, + + # Base payload options + 'sender': sender, + 'subject': title, + + # our To array + 'to': [], + } + + if attachments: + payload['attachments'] = attachments + + if self.notify_format == NotifyFormat.HTML: + payload['html_body'] = body + + else: + payload['text_body'] = body + + # Create a copy of the targets list + emails = list(self.targets) + + for index in range(0, len(emails), batch_size): + # Initialize our cc list + cc = (self.cc - self.bcc) + + # Initialize our bcc list + bcc = set(self.bcc) + + # Initialize our to list + to = list() + + for to_addr in self.targets[index:index + batch_size]: + # Strip target out of cc list if in To + cc = (cc - set([to_addr[1]])) + + # Strip target out of bcc list if in To + bcc = (bcc - set([to_addr[1]])) + + # Prepare our `to` + to.append(formataddr(to_addr, charset='utf-8')) + + # Prepare our To + payload['to'] = to + + if cc: + # Format our cc addresses to support the Name field + payload['cc'] = [formataddr( + (self.names.get(addr, False), addr), charset='utf-8') + for addr in cc] + + # Format our bcc addresses to support the Name field + if bcc: + # set our bcc variable (convert to list first so it's + # JSON serializable) + payload['bcc'] = list(bcc) + + # Store our header entries if defined into the payload + # in their payload + if self.headers: + payload['custom_headers'] = \ + [{'header': k, 'value': v} + for k, v in self.headers.items()] + + # Some Debug Logging + self.logger.debug('SMTP2Go POST URL: {} (cert_verify={})'.format( + self.notify_url, self.verify_certificate)) + self.logger.debug('SMTP2Go Payload: {}' .format(payload)) + + # For logging output of success and errors; we get a head count + # of our outbound details: + verbose_dest = ', '.join( + [x[1] for x in self.targets[index:index + batch_size]]) \ + if len(self.targets[index:index + batch_size]) <= 3 \ + else '{} recipients'.format( + len(self.targets[index:index + batch_size])) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + self.notify_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 = \ + NotifyBase.http_response_code_lookup( + r.status_code, SMTP2GO_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send SMTP2Go notification to {}: ' + '{}{}error={}.'.format( + verbose_dest, + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent SMTP2Go notification to {}.'.format( + verbose_dest)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending SMTP2Go:%s ' % ( + verbose_dest) + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while reading attachments') + self.logger.debug('I/O Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + 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 = { + 'batch': 'yes' if self.batch else 'no', + } + + # Append our headers into our parameters + params.update({'+{}'.format(k): v for k, v in self.headers.items()}) + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + if self.from_name is not None: + # from_name specified; pass it back on the url + params['name'] = self.from_name + + if self.cc: + # Handle our Carbon Copy Addresses + params['cc'] = ','.join( + ['{}{}'.format( + '' if not e not in self.names + else '{}:'.format(self.names[e]), e) for e in self.cc]) + + if self.bcc: + # Handle our Blind Carbon Copy Addresses + params['bcc'] = ','.join(self.bcc) + + # a simple boolean check as to whether we display our target emails + # or not + has_targets = \ + not (len(self.targets) == 1 + and self.targets[0][1] == self.from_addr) + + return '{schema}://{user}@{host}/{apikey}/{targets}?{params}'.format( + schema=self.secure_protocol, + host=self.host, + user=NotifySMTP2Go.quote(self.user, safe=''), + apikey=self.pprint(self.apikey, privacy, safe=''), + targets='' if not has_targets else '/'.join( + [NotifySMTP2Go.quote('{}{}'.format( + '' if not e[0] else '{}:'.format(e[0]), e[1]), + safe='') for e in self.targets]), + params=NotifySMTP2Go.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + batch_size = 1 if not self.batch else self.default_batch_size + targets = len(self.targets) + if batch_size > 1: + targets = int(targets / batch_size) + \ + (1 if targets % batch_size else 0) + + return targets if targets > 0 else 1 + + @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 + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'] = NotifySMTP2Go.split_path(results['fullpath']) + + # Our very first entry is reserved for our api key + try: + results['apikey'] = results['targets'].pop(0) + + except IndexError: + # We're done - no API Key found + results['apikey'] = None + + if 'name' in results['qsd'] and len(results['qsd']['name']): + # Extract from name to associate with from address + results['from_name'] = \ + NotifySMTP2Go.unquote(results['qsd']['name']) + + # Handle 'to' email address + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'].append(results['qsd']['to']) + + # Handle Carbon Copy Addresses + if 'cc' in results['qsd'] and len(results['qsd']['cc']): + results['cc'] = results['qsd']['cc'] + + # Handle Blind Carbon Copy Addresses + if 'bcc' in results['qsd'] and len(results['qsd']['bcc']): + results['bcc'] = results['qsd']['bcc'] + + # Add our Meta Headers that the user can provide with their outbound + # emails + results['headers'] = {NotifyBase.unquote(x): NotifyBase.unquote(y) + for x, y in results['qsd+'].items()} + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get( + 'batch', NotifySMTP2Go.template_args['batch']['default'])) + + return results diff --git a/lib/apprise/plugins/NotifySNS.py b/lib/apprise/plugins/NotifySNS.py new file mode 100644 index 0000000..5edac72 --- /dev/null +++ b/lib/apprise/plugins/NotifySNS.py @@ -0,0 +1,697 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 re +import hmac +import requests +from hashlib import sha256 +from datetime import datetime +from datetime import timezone +from collections import OrderedDict +from xml.etree import ElementTree +from itertools import chain + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..utils import is_phone_no +from ..utils import parse_list +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + +# Topic Detection +# Summary: 256 Characters max, only alpha/numeric plus underscore (_) and +# dash (-) additionally allowed. +# +# Soure: https://docs.aws.amazon.com/AWSSimpleQueueService/latest\ +# /SQSDeveloperGuide/sqs-limits.html#limits-queues +# +# Allow a starting hashtag (#) specification to help eliminate possible +# ambiguity between a topic that is comprised of all digits and a phone number +IS_TOPIC = re.compile(r'^#?(?P[A-Za-z0-9_-]+)\s*$') + +# Because our AWS Access Key Secret contains slashes, we actually use the +# region as a delimiter. This is a bit hacky; but it's much easier than having +# users of this product search though this Access Key Secret and escape all +# of the forward slashes! +IS_REGION = re.compile( + r'^\s*(?P[a-z]{2})-(?P[a-z-]+?)-(?P[0-9]+)\s*$', re.I) + +# Extend HTTP Error Messages +AWS_HTTP_ERROR_MAP = { + 403: 'Unauthorized - Invalid Access/Secret Key Combination.', +} + + +class NotifySNS(NotifyBase): + """ + A wrapper for AWS SNS (Amazon Simple Notification) + """ + + # The default descriptive name associated with the Notification + service_name = 'AWS Simple Notification Service (SNS)' + + # The services URL + service_url = 'https://aws.amazon.com/sns/' + + # The default secure protocol + secure_protocol = 'sns' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_sns' + + # AWS is pretty good for handling data load so request limits + # can occur in much shorter bursts + request_rate_per_sec = 2.5 + + # The maximum length of the body + # Source: https://docs.aws.amazon.com/sns/latest/api/API_Publish.html + body_maxlen = 160 + + # A title can not be used for SMS Messages. Setting this to zero will + # cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + + # Define object templates + templates = ( + '{schema}://{access_key_id}/{secret_access_key}/{region}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'access_key_id': { + 'name': _('Access Key ID'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'secret_access_key': { + 'name': _('Secret Access Key'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'region': { + 'name': _('Region'), + 'type': 'string', + 'required': True, + 'regex': (r'^[a-z]{2}-[a-z-]+?-[0-9]+$', 'i'), + 'required': True, + 'map_to': 'region_name', + }, + 'target_phone_no': { + 'name': _('Target Phone No'), + 'type': 'string', + 'map_to': 'targets', + 'regex': (r'^[0-9\s)(+-]+$', 'i') + }, + 'target_topic': { + 'name': _('Target Topic'), + 'type': 'string', + 'map_to': 'targets', + 'prefix': '#', + 'regex': (r'^[A-Za-z0-9_-]+$', 'i'), + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'access': { + 'alias_of': 'access_key_id', + }, + 'secret': { + 'alias_of': 'secret_access_key', + }, + 'region': { + 'alias_of': 'region', + }, + }) + + def __init__(self, access_key_id, secret_access_key, region_name, + targets=None, **kwargs): + """ + Initialize Notify AWS SNS Object + """ + super().__init__(**kwargs) + + # Store our AWS API Access Key + self.aws_access_key_id = validate_regex(access_key_id) + if not self.aws_access_key_id: + msg = 'An invalid AWS Access Key ID was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + # Store our AWS API Secret Access key + self.aws_secret_access_key = validate_regex(secret_access_key) + if not self.aws_secret_access_key: + msg = 'An invalid AWS Secret Access Key ' \ + '({}) was specified.'.format(secret_access_key) + self.logger.warning(msg) + raise TypeError(msg) + + # Acquire our AWS Region Name: + # eg. us-east-1, cn-north-1, us-west-2, ... + self.aws_region_name = validate_regex( + region_name, *self.template_tokens['region']['regex']) + if not self.aws_region_name: + msg = 'An invalid AWS Region ({}) was specified.'.format( + region_name) + self.logger.warning(msg) + raise TypeError(msg) + + # Initialize topic list + self.topics = list() + + # Initialize numbers list + self.phone = list() + + # Set our notify_url based on our region + self.notify_url = 'https://sns.{}.amazonaws.com/'\ + .format(self.aws_region_name) + + # AWS Service Details + self.aws_service_name = 'sns' + self.aws_canonical_uri = '/' + + # AWS Authentication Details + self.aws_auth_version = 'AWS4' + self.aws_auth_algorithm = 'AWS4-HMAC-SHA256' + self.aws_auth_request = 'aws4_request' + + # Validate targets and drop bad ones: + for target in parse_list(targets): + result = is_phone_no(target) + if result: + # store valid phone number in E.164 format + self.phone.append('+{}'.format(result['full'])) + continue + + result = IS_TOPIC.match(target) + if result: + # store valid topic + self.topics.append(result.group('name')) + continue + + self.logger.warning( + 'Dropped invalid phone/topic ' + '(%s) specified.' % target, + ) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + wrapper to send_notification since we can alert more then one channel + """ + + if len(self.phone) == 0 and len(self.topics) == 0: + # We have a bot token and no target(s) to message + self.logger.warning('No AWS targets to notify.') + return False + + # Initiaize our error tracking + error_count = 0 + + # Create a copy of our phone #'s to notify against + phone = list(self.phone) + topics = list(self.topics) + + while len(phone) > 0: + + # Get Phone No + no = phone.pop(0) + + # Prepare SNS Message Payload + payload = { + 'Action': u'Publish', + 'Message': body, + 'Version': u'2010-03-31', + 'PhoneNumber': no, + } + + (result, _) = self._post(payload=payload, to=no) + if not result: + error_count += 1 + + # Send all our defined topic id's + while len(topics): + + # Get Topic + topic = topics.pop(0) + + # First ensure our topic exists, if it doesn't, it gets created + payload = { + 'Action': u'CreateTopic', + 'Version': u'2010-03-31', + 'Name': topic, + } + + (result, response) = self._post(payload=payload, to=topic) + if not result: + error_count += 1 + continue + + # Get the Amazon Resource Name + topic_arn = response.get('topic_arn') + if not topic_arn: + # Could not acquire our topic; we're done + error_count += 1 + continue + + # Build our payload now that we know our topic_arn + payload = { + 'Action': u'Publish', + 'Version': u'2010-03-31', + 'TopicArn': topic_arn, + 'Message': body, + } + + # Send our payload to AWS + (result, _) = self._post(payload=payload, to=topic) + if not result: + error_count += 1 + + return error_count == 0 + + def _post(self, payload, to): + """ + Wrapper to request.post() to manage it's response better and make + the send() function cleaner and easier to maintain. + + This function returns True if the _post was successful and False + if it wasn't. + """ + + # Always call throttle before any remote server i/o is made; for AWS + # time plays a huge factor in the headers being sent with the payload. + # So for AWS (SNS) requests we must throttle before they're generated + # and not directly before the i/o call like other notification + # services do. + self.throttle() + + # Convert our payload from a dict() into a urlencoded string + payload = NotifySNS.urlencode(payload) + + # Prepare our Notification URL + # Prepare our AWS Headers based on our payload + headers = self.aws_prepare_request(payload) + + self.logger.debug('AWS POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('AWS Payload: %s' % str(payload)) + + try: + r = requests.post( + self.notify_url, + data=payload, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifySNS.http_response_code_lookup( + r.status_code, AWS_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send AWS notification to {}: ' + '{}{}error={}.'.format( + to, + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + return (False, NotifySNS.aws_response_to_dict(r.text)) + + else: + self.logger.info( + 'Sent AWS notification to "%s".' % (to)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending AWS ' + 'notification to "%s".' % (to), + ) + self.logger.debug('Socket Exception: %s' % str(e)) + return (False, NotifySNS.aws_response_to_dict(None)) + + return (True, NotifySNS.aws_response_to_dict(r.text)) + + def aws_prepare_request(self, payload, reference=None): + """ + Takes the intended payload and returns the headers for it. + + The payload is presumed to have been already urlencoded() + + """ + + # Define our AWS header + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', + + # Populated below + 'Content-Length': 0, + 'Authorization': None, + 'X-Amz-Date': None, + } + + # Get a reference time (used for header construction) + reference = datetime.now(timezone.utc) + + # Provide Content-Length + headers['Content-Length'] = str(len(payload)) + + # Amazon Date Format + amzdate = reference.strftime('%Y%m%dT%H%M%SZ') + headers['X-Amz-Date'] = amzdate + + # Credential Scope + scope = '{date}/{region}/{service}/{request}'.format( + date=reference.strftime('%Y%m%d'), + region=self.aws_region_name, + service=self.aws_service_name, + request=self.aws_auth_request, + ) + + # Similar to headers; but a subset. keys must be lowercase + signed_headers = OrderedDict([ + ('content-type', headers['Content-Type']), + ('host', '{service}.{region}.amazonaws.com'.format( + service=self.aws_service_name, + region=self.aws_region_name)), + ('x-amz-date', headers['X-Amz-Date']), + ]) + + # + # Build Canonical Request Object + # + canonical_request = '\n'.join([ + # Method + u'POST', + + # URL + self.aws_canonical_uri, + + # Query String (none set for POST) + '', + + # Header Content (must include \n at end!) + # All entries except characters in amazon date must be + # lowercase + '\n'.join(['%s:%s' % (k, v) + for k, v in signed_headers.items()]) + '\n', + + # Header Entries (in same order identified above) + ';'.join(signed_headers.keys()), + + # Payload + sha256(payload.encode('utf-8')).hexdigest(), + ]) + + # Prepare Unsigned Signature + to_sign = '\n'.join([ + self.aws_auth_algorithm, + amzdate, + scope, + sha256(canonical_request.encode('utf-8')).hexdigest(), + ]) + + # Our Authorization header + headers['Authorization'] = ', '.join([ + '{algorithm} Credential={key}/{scope}'.format( + algorithm=self.aws_auth_algorithm, + key=self.aws_access_key_id, + scope=scope, + ), + 'SignedHeaders={signed_headers}'.format( + signed_headers=';'.join(signed_headers.keys()), + ), + 'Signature={signature}'.format( + signature=self.aws_auth_signature(to_sign, reference) + ), + ]) + + return headers + + def aws_auth_signature(self, to_sign, reference): + """ + Generates a AWS v4 signature based on provided payload + which should be in the form of a string. + """ + + def _sign(key, msg, to_hex=False): + """ + Perform AWS Signing + """ + if to_hex: + return hmac.new(key, msg.encode('utf-8'), sha256).hexdigest() + return hmac.new(key, msg.encode('utf-8'), sha256).digest() + + _date = _sign(( + self.aws_auth_version + + self.aws_secret_access_key).encode('utf-8'), + reference.strftime('%Y%m%d')) + + _region = _sign(_date, self.aws_region_name) + _service = _sign(_region, self.aws_service_name) + _signed = _sign(_service, self.aws_auth_request) + return _sign(_signed, to_sign, to_hex=True) + + @staticmethod + def aws_response_to_dict(aws_response): + """ + Takes an AWS Response object as input and returns it as a dictionary + but not befor extracting out what is useful to us first. + + eg: + IN: + + + arn:aws:sns:us-east-1:000000000000:abcd + + + 604bef0f-369c-50c5-a7a4-bbd474c83d6a + + + + OUT: + { + type: 'CreateTopicResponse', + request_id: '604bef0f-369c-50c5-a7a4-bbd474c83d6a', + topic_arn: 'arn:aws:sns:us-east-1:000000000000:abcd', + } + """ + + # Define ourselves a set of directives we want to keep if found and + # then identify the value we want to map them to in our response + # object + aws_keep_map = { + 'RequestId': 'request_id', + 'TopicArn': 'topic_arn', + 'MessageId': 'message_id', + + # Error Message Handling + 'Type': 'error_type', + 'Code': 'error_code', + 'Message': 'error_message', + } + + # A default response object that we'll manipulate as we pull more data + # from our AWS Response object + response = { + 'type': None, + 'request_id': None, + } + + try: + # we build our tree, but not before first eliminating any + # reference to namespacing (if present) as it makes parsing + # the tree so much easier. + root = ElementTree.fromstring( + re.sub(' xmlns="[^"]+"', '', aws_response, count=1)) + + # Store our response tag object name + response['type'] = str(root.tag) + + def _xml_iter(root, response): + if len(root) > 0: + for child in root: + # use recursion to parse everything + _xml_iter(child, response) + + elif root.tag in aws_keep_map.keys(): + response[aws_keep_map[root.tag]] = (root.text).strip() + + # Recursivly iterate over our AWS Response to extract the + # fields we're interested in in efforts to populate our response + # object. + _xml_iter(root, response) + + except (ElementTree.ParseError, TypeError): + # bad data just causes us to generate a bad response + pass + + return response + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Our URL parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + return '{schema}://{key_id}/{key_secret}/{region}/{targets}/'\ + '?{params}'.format( + schema=self.secure_protocol, + key_id=self.pprint(self.aws_access_key_id, privacy, safe=''), + key_secret=self.pprint( + self.aws_secret_access_key, privacy, + mode=PrivacyMode.Secret, safe=''), + region=NotifySNS.quote(self.aws_region_name, safe=''), + targets='/'.join( + [NotifySNS.quote(x) for x in chain( + # Phone # are already prefixed with a plus symbol + self.phone, + # Topics are prefixed with a pound/hashtag symbol + ['#{}'.format(x) for x in self.topics], + )]), + params=NotifySNS.urlencode(params), + ) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.phone) + len(self.topics) + + @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 + + # The AWS Access Key ID is stored in the hostname + access_key_id = NotifySNS.unquote(results['host']) + + # Our AWS Access Key Secret contains slashes in it which unfortunately + # means it is of variable length after the hostname. Since we require + # that the user provides the region code, we intentionally use this + # as our delimiter to detect where our Secret is. + secret_access_key = None + region_name = None + + # We need to iterate over each entry in the fullpath and find our + # region. Once we get there we stop and build our secret from our + # accumulated data. + secret_access_key_parts = list() + + # Start with a list of entries to work with + entries = NotifySNS.split_path(results['fullpath']) + + # Section 1: Get Region and Access Secret + index = 0 + for i, entry in enumerate(entries): + + # Are we at the region yet? + result = IS_REGION.match(entry) + if result: + # We found our Region; Rebuild our access key secret based on + # all entries we found prior to this: + secret_access_key = '/'.join(secret_access_key_parts) + + # Ensure region is nicely formatted + region_name = "{country}-{area}-{no}".format( + country=result.group('country').lower(), + area=result.group('area').lower(), + no=result.group('no'), + ) + + # Track our index as we'll use this to grab the remaining + # content in the next Section + index = i + 1 + + # We're done with Section 1 + break + + # Store our secret parts + secret_access_key_parts.append(entry) + + # Section 2: Get our Recipients (basically all remaining entries) + results['targets'] = entries[index:] + + # 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'] += \ + NotifySNS.parse_list(results['qsd']['to']) + + # Handle secret_access_key over-ride + if 'secret' in results['qsd'] and len(results['qsd']['secret']): + results['secret_access_key'] = \ + NotifySNS.unquote(results['qsd']['secret']) + else: + results['secret_access_key'] = secret_access_key + + # Handle access key id over-ride + if 'access' in results['qsd'] and len(results['qsd']['access']): + results['access_key_id'] = \ + NotifySNS.unquote(results['qsd']['access']) + else: + results['access_key_id'] = access_key_id + + # Handle region name id over-ride + if 'region' in results['qsd'] and len(results['qsd']['region']): + results['region_name'] = \ + NotifySNS.unquote(results['qsd']['region']) + else: + results['region_name'] = region_name + + # Return our result set + return results diff --git a/lib/apprise/plugins/NotifySendGrid.py b/lib/apprise/plugins/NotifySendGrid.py new file mode 100644 index 0000000..b7f4a8a --- /dev/null +++ b/lib/apprise/plugins/NotifySendGrid.py @@ -0,0 +1,475 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# You will need an API Key for this plugin to work. +# From the Settings -> API Keys you can click "Create API Key" if you don't +# have one already. The key must have at least the "Mail Send" permission +# to work. +# +# The schema to use the plugin looks like this: +# {schema}://{apikey}:{from_email} +# +# Your {from_email} must be comprissed of your Sendgrid Authenticated +# Domain. The same domain must have 'Link Branding' turned on as well or it +# will not work. This can be seen from Settings -> Sender Authentication. + +# If you're (SendGrid) verified domain is example.com, then your schema may +# look something like this: + +# Simple API Reference: +# - https://sendgrid.com/docs/API_Reference/Web_API_v3/index.html +# - https://sendgrid.com/docs/ui/sending-email/\ +# how-to-send-an-email-with-dynamic-transactional-templates/ + +import requests +from json import dumps + +from .NotifyBase import NotifyBase +from ..common import NotifyFormat +from ..common import NotifyType +from ..utils import parse_list +from ..utils import is_email +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + +# Extend HTTP Error Messages +SENDGRID_HTTP_ERROR_MAP = { + 401: 'Unauthorized - You do not have authorization to make the request.', + 413: 'Payload To Large - The JSON payload you have included in your ' + 'request is too large.', + 429: 'Too Many Requests - The number of requests you have made exceeds ' + 'SendGrid’s rate limitations.', +} + + +class NotifySendGrid(NotifyBase): + """ + A wrapper for Notify SendGrid Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'SendGrid' + + # The services URL + service_url = 'https://sendgrid.com' + + # The default secure protocol + secure_protocol = 'sendgrid' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_sendgrid' + + # Default to markdown + notify_format = NotifyFormat.HTML + + # The default Email API URL to use + notify_url = 'https://api.sendgrid.com/v3/mail/send' + + # Allow 300 requests per minute. + # 60/300 = 0.2 + request_rate_per_sec = 0.2 + + # The default subject to use if one isn't specified. + default_empty_subject = '' + + # Define object templates + templates = ( + '{schema}://{apikey}:{from_email}', + '{schema}://{apikey}:{from_email}/{targets}', + ) + + # Define our template arguments + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[A-Z0-9._-]+$', 'i'), + }, + 'from_email': { + 'name': _('Source Email'), + 'type': 'string', + 'required': True, + }, + 'target_email': { + 'name': _('Target Email'), + 'type': 'string', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'cc': { + 'name': _('Carbon Copy'), + 'type': 'list:string', + }, + 'bcc': { + 'name': _('Blind Carbon Copy'), + 'type': 'list:string', + }, + 'template': { + # Template ID + # The template ID is 64 characters with one dash (d-uuid) + 'name': _('Template'), + 'type': 'string', + }, + }) + + # Support Template Dynamic Variables (Substitutions) + template_kwargs = { + 'template_data': { + 'name': _('Template Data'), + 'prefix': '+', + }, + } + + def __init__(self, apikey, from_email, targets=None, cc=None, + bcc=None, template=None, template_data=None, **kwargs): + """ + Initialize Notify SendGrid Object + """ + super().__init__(**kwargs) + + # API Key (associated with project) + self.apikey = validate_regex( + apikey, *self.template_tokens['apikey']['regex']) + if not self.apikey: + msg = 'An invalid SendGrid API Key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + result = is_email(from_email) + if not result: + msg = 'Invalid ~From~ email specified: {}'.format(from_email) + self.logger.warning(msg) + raise TypeError(msg) + + # Store email address + self.from_email = result['full_email'] + + # Acquire Targets (To Emails) + self.targets = list() + + # Acquire Carbon Copies + self.cc = set() + + # Acquire Blind Carbon Copies + self.bcc = set() + + # Now our dynamic template (if defined) + self.template = template + + # Now our dynamic template data (if defined) + self.template_data = template_data \ + if isinstance(template_data, dict) else {} + + # Validate recipients (to:) and drop bad ones: + for recipient in parse_list(targets): + + result = is_email(recipient) + if result: + self.targets.append(result['full_email']) + continue + + self.logger.warning( + 'Dropped invalid email ' + '({}) specified.'.format(recipient), + ) + + # Validate recipients (cc:) and drop bad ones: + for recipient in parse_list(cc): + + result = is_email(recipient) + if result: + self.cc.add(result['full_email']) + continue + + self.logger.warning( + 'Dropped invalid Carbon Copy email ' + '({}) specified.'.format(recipient), + ) + + # Validate recipients (bcc:) and drop bad ones: + for recipient in parse_list(bcc): + + result = is_email(recipient) + if result: + self.bcc.add(result['full_email']) + continue + + self.logger.warning( + 'Dropped invalid Blind Carbon Copy email ' + '({}) specified.'.format(recipient), + ) + + if len(self.targets) == 0: + # Notify ourselves + self.targets.append(self.from_email) + + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Our URL parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + if len(self.cc) > 0: + # Handle our Carbon Copy Addresses + params['cc'] = ','.join(self.cc) + + if len(self.bcc) > 0: + # Handle our Blind Carbon Copy Addresses + params['bcc'] = ','.join(self.bcc) + + if self.template: + # Handle our Template ID if if was specified + params['template'] = self.template + + # Append our template_data into our parameter list + params.update( + {'+{}'.format(k): v for k, v in self.template_data.items()}) + + # a simple boolean check as to whether we display our target emails + # or not + has_targets = \ + not (len(self.targets) == 1 and self.targets[0] == self.from_email) + + return '{schema}://{apikey}:{from_email}/{targets}?{params}'.format( + schema=self.secure_protocol, + apikey=self.pprint(self.apikey, privacy, safe=''), + # never encode email since it plays a huge role in our hostname + from_email=self.from_email, + targets='' if not has_targets else '/'.join( + [NotifySendGrid.quote(x, safe='') for x in self.targets]), + params=NotifySendGrid.urlencode(params), + ) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.targets) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform SendGrid Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + 'Authorization': 'Bearer {}'.format(self.apikey), + } + + # error tracking (used for function return) + has_error = False + + # A Simple Email Payload Template + _payload = { + 'personalizations': [{ + # Placeholder + 'to': [{'email': None}], + }], + 'from': { + 'email': self.from_email, + }, + # A subject is a requirement, so if none is specified we must + # set a default with at least 1 character or SendGrid will deny + # our request + 'subject': title if title else self.default_empty_subject, + 'content': [{ + 'type': 'text/plain' + if self.notify_format == NotifyFormat.TEXT else 'text/html', + 'value': body, + }], + } + + if self.template: + _payload['template_id'] = self.template + + if self.template_data: + _payload['personalizations'][0]['dynamic_template_data'] = \ + {k: v for k, v in self.template_data.items()} + + targets = list(self.targets) + while len(targets) > 0: + target = targets.pop(0) + + # Create a copy of our template + payload = _payload.copy() + + # the cc, bcc, to field must be unique or SendMail will fail, the + # below code prepares this by ensuring the target isn't in the cc + # list or bcc list. It also makes sure the cc list does not contain + # any of the bcc entries + cc = (self.cc - self.bcc - set([target])) + bcc = (self.bcc - set([target])) + + # Set our target + payload['personalizations'][0]['to'][0]['email'] = target + + if len(cc): + payload['personalizations'][0]['cc'] = \ + [{'email': email} for email in cc] + + if len(bcc): + payload['personalizations'][0]['bcc'] = \ + [{'email': email} for email in bcc] + + self.logger.debug('SendGrid POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('SendGrid Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + self.notify_url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code not in ( + requests.codes.ok, requests.codes.accepted): + # We had a problem + status_str = \ + NotifySendGrid.http_response_code_lookup( + r.status_code, SENDGRID_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send SendGrid notification to {}: ' + '{}{}error={}.'.format( + target, + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent SendGrid notification to {}.'.format(target)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending SendGrid ' + 'notification to {}.'.format(target)) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + return not has_error + + @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 + + # Our URL looks like this: + # {schema}://{apikey}:{from_email}/{targets} + # + # which actually equates to: + # {schema}://{user}:{password}@{host}/{email1}/{email2}/etc.. + # ^ ^ ^ + # | | | + # apikey -from addr- + + if not results.get('user'): + # An API Key as not properly specified + return None + + if not results.get('password'): + # A From Email was not correctly specified + return None + + # Prepare our API Key + results['apikey'] = NotifySendGrid.unquote(results['user']) + + # Prepare our From Email Address + results['from_email'] = '{}@{}'.format( + NotifySendGrid.unquote(results['password']), + NotifySendGrid.unquote(results['host']), + ) + + # Acquire our targets + results['targets'] = NotifySendGrid.split_path(results['fullpath']) + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifySendGrid.parse_list(results['qsd']['to']) + + # Handle Carbon Copy Addresses + if 'cc' in results['qsd'] and len(results['qsd']['cc']): + results['cc'] = \ + NotifySendGrid.parse_list(results['qsd']['cc']) + + # Handle Blind Carbon Copy Addresses + if 'bcc' in results['qsd'] and len(results['qsd']['bcc']): + results['bcc'] = \ + NotifySendGrid.parse_list(results['qsd']['bcc']) + + # Handle Blind Carbon Copy Addresses + if 'template' in results['qsd'] and len(results['qsd']['template']): + results['template'] = \ + NotifySendGrid.unquote(results['qsd']['template']) + + # Add any template substitutions + results['template_data'] = results['qsd+'] + + return results diff --git a/lib/apprise/plugins/NotifyServerChan.py b/lib/apprise/plugins/NotifyServerChan.py new file mode 100644 index 0000000..87a294a --- /dev/null +++ b/lib/apprise/plugins/NotifyServerChan.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 re +import requests + +from ..common import NotifyType +from .NotifyBase import NotifyBase +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +# Register at https://sct.ftqq.com/ +# - do as the page describe and you will get the token + +# Syntax: +# schan://{access_token}/ + + +class NotifyServerChan(NotifyBase): + """ + A wrapper for ServerChan Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'ServerChan' + + # The services URL + service_url = 'https://sct.ftqq.com/' + + # All notification requests are secure + secure_protocol = 'schan' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_serverchan' + + # ServerChan API + notify_url = 'https://sctapi.ftqq.com/{token}.send' + + # Define object templates + templates = ( + '{schema}://{token}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'token': { + 'name': _('Token'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[a-z0-9]+$', 'i'), + }, + }) + + def __init__(self, token, **kwargs): + """ + Initialize ServerChan Object + """ + super().__init__(**kwargs) + + # Token (associated with project) + self.token = validate_regex( + token, *self.template_tokens['token']['regex']) + if not self.token: + msg = 'An invalid ServerChan API Token ' \ + '({}) was specified.'.format(token) + self.logger.warning(msg) + raise TypeError(msg) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform ServerChan Notification + """ + payload = { + 'title': title, + 'desp': body, + } + + # Our Notification URL + notify_url = self.notify_url.format(token=self.token) + + # Some Debug Logging + self.logger.debug('ServerChan URL: {} (cert_verify={})'.format( + notify_url, self.verify_certificate)) + self.logger.debug('ServerChan Payload: {}'.format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + notify_url, + data=payload, + ) + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyServerChan.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send ServerChan notification: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + return False + + else: + self.logger.info('Sent ServerChan notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending ServerChan ' + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + return True + + def url(self, privacy=False): + """ + Returns the URL built dynamically based on specified arguments. + """ + + return '{schema}://{token}'.format( + schema=self.secure_protocol, + token=self.pprint(self.token, privacy, safe='')) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to substantiate this object. + """ + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early as we couldn't parse the URL + return results + + pattern = 'schan://([a-zA-Z0-9]+)/' + \ + ('?' if not url.endswith('/') else '') + result = re.match(pattern, url) + results['token'] = result.group(1) if result else '' + return results diff --git a/lib/apprise/plugins/NotifySignalAPI.py b/lib/apprise/plugins/NotifySignalAPI.py new file mode 100644 index 0000000..a2a31de --- /dev/null +++ b/lib/apprise/plugins/NotifySignalAPI.py @@ -0,0 +1,491 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 re +import requests +from json import dumps +import base64 + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import is_phone_no +from ..utils import parse_phone_no +from ..utils import parse_bool +from ..URLBase import PrivacyMode +from ..AppriseLocale import gettext_lazy as _ + + +GROUP_REGEX = re.compile( + r'^\s*((\@|\%40)?(group\.)|\@|\%40)(?P[a-z0-9_=-]+)', re.I) + + +class NotifySignalAPI(NotifyBase): + """ + A wrapper for SignalAPI Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Signal API' + + # The services URL + service_url = 'https://bbernhard.github.io/signal-cli-rest-api/' + + # The default protocol + protocol = 'signal' + + # The default protocol + secure_protocol = 'signals' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_signal' + + # Support attachments + attachment_support = True + + # The maximum targets to include when doing batch transfers + default_batch_size = 10 + + # We don't support titles for Signal notifications + title_maxlen = 0 + + # Define object templates + templates = ( + '{schema}://{host}/{from_phone}', + '{schema}://{host}:{port}/{from_phone}', + '{schema}://{user}@{host}/{from_phone}', + '{schema}://{user}@{host}:{port}/{from_phone}', + '{schema}://{user}:{password}@{host}/{from_phone}', + '{schema}://{user}:{password}@{host}:{port}/{from_phone}', + '{schema}://{host}/{from_phone}/{targets}', + '{schema}://{host}:{port}/{from_phone}/{targets}', + '{schema}://{user}@{host}/{from_phone}/{targets}', + '{schema}://{user}@{host}:{port}/{from_phone}/{targets}', + '{schema}://{user}:{password}@{host}/{from_phone}/{targets}', + '{schema}://{user}:{password}@{host}:{port}/{from_phone}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'user': { + 'name': _('Username'), + 'type': 'string', + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + }, + 'from_phone': { + 'name': _('From Phone No'), + 'type': 'string', + 'required': True, + 'regex': (r'^\+?[0-9\s)(+-]+$', 'i'), + 'map_to': 'source', + }, + 'target_phone': { + 'name': _('Target Phone No'), + 'type': 'string', + 'prefix': '+', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + 'map_to': 'targets', + }, + 'target_channel': { + 'name': _('Target Group ID'), + 'type': 'string', + 'prefix': '@', + 'regex': (r'^[a-z0-9_=-]+$', 'i'), + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + } + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'from': { + 'alias_of': 'from_phone', + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + 'status': { + 'name': _('Show Status'), + 'type': 'bool', + 'default': False, + }, + }) + + def __init__(self, source=None, targets=None, batch=False, status=False, + **kwargs): + """ + Initialize SignalAPI Object + """ + super().__init__(**kwargs) + + # Prepare Batch Mode Flag + self.batch = batch + + # Set Status type + self.status = status + + # Parse our targets + self.targets = list() + + # Used for URL generation afterwards only + self.invalid_targets = list() + + # Manage our Source Phone + result = is_phone_no(source) + if not result: + msg = 'An invalid Signal API Source Phone No ' \ + '({}) was provided.'.format(source) + self.logger.warning(msg) + raise TypeError(msg) + + self.source = '+{}'.format(result['full']) + + if targets: + # Validate our targerts + for target in parse_phone_no(targets): + # Validate targets and drop bad ones: + result = is_phone_no(target) + if result: + # store valid phone number + self.targets.append('+{}'.format(result['full'])) + continue + + result = GROUP_REGEX.match(target) + 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: + # Send a message to ourselves + self.targets.append(self.source) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): + """ + Perform Signal API Notification + """ + + if len(self.targets) == 0: + # There were no services to notify + self.logger.warning( + 'There were no Signal API targets to notify.') + return False + + # error tracking (used for function return) + has_error = False + + attachments = [] + if attach and self.attachment_support: + 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 + headers = { + 'User-Agent': self.app_id, + '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 + payload = { + 'message': "{}{}".format( + '' if not self.status else '{} '.format( + self.asset.ascii(notify_type)), body).rstrip(), + "number": self.source, + "recipients": [] + } + + if attachments: + # Store our attachments + payload['base64_attachments'] = attachments + + # Determine Authentication + auth = None + if self.user: + auth = (self.user, self.password) + + # Set our schema + schema = 'https' if self.secure else 'http' + + # Construct our URL + notify_url = '%s://%s' % (schema, self.host) + if isinstance(self.port, int): + notify_url += ':%d' % self.port + notify_url += '/v2/send' + + # Send in batches if identified to do so + batch_size = 1 if not self.batch else self.default_batch_size + + for index in range(0, len(self.targets), batch_size): + # Prepare our recipients + payload['recipients'] = self.targets[index:index + batch_size] + + self.logger.debug('Signal API POST URL: %s (cert_verify=%r)' % ( + notify_url, self.verify_certificate, + )) + self.logger.debug('Signal API Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + notify_url, + auth=auth, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code not in ( + requests.codes.ok, requests.codes.created): + # We had a problem + status_str = \ + NotifySignalAPI.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send {} Signal API notification{}: ' + '{}{}error={}.'.format( + len(self.targets[index:index + batch_size]), + ' to {}'.format(self.targets[index]) + if batch_size == 1 else '(s)', + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent {} Signal API notification{}.' + .format( + len(self.targets[index:index + batch_size]), + ' to {}'.format(self.targets[index]) + if batch_size == 1 else '(s)', + )) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending {} Signal API ' + 'notification(s).'.format( + len(self.targets[index:index + batch_size]))) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + 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 = { + 'batch': 'yes' if self.batch else 'no', + 'status': 'yes' if self.status else 'no', + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=NotifySignalAPI.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=NotifySignalAPI.quote(self.user, safe=''), + ) + + default_port = 443 if self.secure else 80 + + # So we can strip out our own phone (if present); create a copy of our + # targets + if len(self.targets) == 1 and self.source in self.targets: + targets = [] + + elif len(self.targets) == 0: + # invalid phone-no were specified + targets = self.invalid_targets + + else: + # 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( + schema=self.secure_protocol if self.secure else self.protocol, + auth=auth, + # never encode hostname since we're expecting it to be a valid one + hostname=self.host, + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + src=self.source, + dst='/'.join( + [NotifySignalAPI.quote(x, safe='@+') for x in targets]), + params=NotifySignalAPI.urlencode(params), + ) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + batch_size = 1 if not self.batch else self.default_batch_size + targets = len(self.targets) + if batch_size > 1: + targets = int(targets / batch_size) + \ + (1 if targets % batch_size else 0) + + return 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 + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'] = \ + NotifySignalAPI.split_path(results['fullpath']) + + # The hostname is our authentication key + results['apikey'] = NotifySignalAPI.unquote(results['host']) + + if 'from' in results['qsd'] and len(results['qsd']['from']): + results['source'] = \ + NotifySignalAPI.unquote(results['qsd']['from']) + + elif results['targets']: + # The from phone no is the first entry in the list otherwise + results['source'] = results['targets'].pop(0) + + # Support the 'to' variable so that we can support targets this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifySignalAPI.parse_phone_no(results['qsd']['to']) + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get('batch', False)) + + # Get status switch + results['status'] = \ + parse_bool(results['qsd'].get('status', False)) + + return results diff --git a/lib/apprise/plugins/NotifySimplePush.py b/lib/apprise/plugins/NotifySimplePush.py new file mode 100644 index 0000000..d6bd2ab --- /dev/null +++ b/lib/apprise/plugins/NotifySimplePush.py @@ -0,0 +1,335 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +from os import urandom +from json import loads +import requests + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + +from base64 import urlsafe_b64encode +import hashlib + +try: + from cryptography.hazmat.primitives import padding + from cryptography.hazmat.primitives.ciphers import Cipher + from cryptography.hazmat.primitives.ciphers import algorithms + from cryptography.hazmat.primitives.ciphers import modes + from cryptography.hazmat.backends import default_backend + + # We're good to go! + NOTIFY_SIMPLEPUSH_ENABLED = True + +except ImportError: + # cryptography is required in order for this package to work + NOTIFY_SIMPLEPUSH_ENABLED = False + + +class NotifySimplePush(NotifyBase): + """ + A wrapper for SimplePush Notifications + """ + + # Set our global enabled flag + enabled = NOTIFY_SIMPLEPUSH_ENABLED + + requirements = { + # Define our required packaging in order to work + 'packages_required': 'cryptography' + } + + # The default descriptive name associated with the Notification + service_name = 'SimplePush' + + # The services URL + service_url = 'https://simplepush.io/' + + # The default secure protocol + secure_protocol = 'spush' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_simplepush' + + # SimplePush uses the http protocol with SimplePush requests + notify_url = 'https://api.simplepush.io/send' + + # The maximum allowable characters allowed in the body per message + body_maxlen = 10000 + + # Defines the maximum allowable characters in the title + title_maxlen = 1024 + + # Define object templates + templates = ( + '{schema}://{apikey}', + '{schema}://{salt}:{password}@{apikey}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'private': True, + 'required': True, + }, + + # Used for encrypted logins + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + }, + 'salt': { + 'name': _('Salt'), + 'type': 'string', + 'private': True, + 'map_to': 'user', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'event': { + 'name': _('Event'), + 'type': 'string', + }, + }) + + def __init__(self, apikey, event=None, **kwargs): + """ + Initialize SimplePush Object + """ + super().__init__(**kwargs) + + # API Key (associated with project) + self.apikey = validate_regex(apikey) + if not self.apikey: + msg = 'An invalid SimplePush API Key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + if event: + # Event Name (associated with project) + self.event = validate_regex(event) + if not self.event: + msg = 'An invalid SimplePush Event Name ' \ + '({}) was specified.'.format(event) + self.logger.warning(msg) + raise TypeError(msg) + + else: + # Default Event Name + self.event = None + + # Used/cached in _encrypt() function + self._iv = None + self._iv_hex = None + self._key = None + + def _encrypt(self, content): + """ + Encrypts message for use with SimplePush + """ + + if self._iv is None: + # initialization vector and cache it + self._iv = urandom(algorithms.AES.block_size // 8) + + # convert vector into hex string (used in payload) + self._iv_hex = ''.join(["{:02x}".format(ord(self._iv[idx:idx + 1])) + for idx in range(len(self._iv))]).upper() + + # encrypted key and cache it + self._key = bytes(bytearray.fromhex( + hashlib.sha1('{}{}'.format(self.password, self.user) + .encode('utf-8')).hexdigest()[0:32])) + + padder = padding.PKCS7(algorithms.AES.block_size).padder() + content = padder.update(content.encode()) + padder.finalize() + + encryptor = Cipher( + algorithms.AES(self._key), + modes.CBC(self._iv), + default_backend()).encryptor() + + return urlsafe_b64encode( + encryptor.update(content) + encryptor.finalize()) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform SimplePush Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-type': "application/x-www-form-urlencoded", + } + + # Prepare our payload + payload = { + 'key': self.apikey, + } + + if self.password and self.user: + body = self._encrypt(body) + title = self._encrypt(title) + payload.update({ + 'encrypted': 'true', + 'iv': self._iv_hex, + }) + + # prepare SimplePush Object + payload.update({ + 'msg': body, + 'title': title, + }) + + if self.event: + # Store Event + payload['event'] = self.event + + self.logger.debug('SimplePush POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('SimplePush Payload: %s' % str(payload)) + + # We need to rely on the status string returned in the SimplePush + # response + status_str = None + status = None + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + self.notify_url, + data=payload, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + # Get our SimplePush response (if it's possible) + try: + json_response = loads(r.content) + status_str = json_response.get('message') + status = json_response.get('status') + + except (TypeError, ValueError, AttributeError): + # TypeError = r.content is not a String + # ValueError = r.content is Unparsable + # AttributeError = r.content is None + pass + + if r.status_code != requests.codes.ok or status != 'OK': + # We had a problem + status_str = status_str if status_str else\ + NotifyBase.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send SimplePush notification:' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info('Sent SimplePush notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending SimplePush notification.') + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Our URL parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + if self.event: + params['event'] = self.event + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{salt}:{password}@'.format( + salt=self.pprint( + self.user, privacy, mode=PrivacyMode.Secret, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + + return '{schema}://{auth}{apikey}/?{params}'.format( + schema=self.secure_protocol, + auth=auth, + apikey=self.pprint(self.apikey, privacy, safe=''), + params=NotifySimplePush.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, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + # Set the API Key + results['apikey'] = NotifySimplePush.unquote(results['host']) + + # Event + if 'event' in results['qsd'] and len(results['qsd']['event']): + # Extract the account sid from an argument + results['event'] = \ + NotifySimplePush.unquote(results['qsd']['event']) + + return results diff --git a/lib/apprise/plugins/NotifySinch.py b/lib/apprise/plugins/NotifySinch.py new file mode 100644 index 0000000..b2c5683 --- /dev/null +++ b/lib/apprise/plugins/NotifySinch.py @@ -0,0 +1,471 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# To use this service you will need a Sinch account to which you can get your +# API_TOKEN and SERVICE_PLAN_ID right from your console/dashboard at: +# https://dashboard.sinch.com/sms/overview +# +# You will also need to send the SMS From a phone number or account id name. + +# This is identified as the source (or where the SMS message will originate +# from). Activated phone numbers can be found on your dashboard here: +# - https://dashboard.sinch.com/numbers/your-numbers/numbers +# +import requests +import json + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..utils import is_phone_no +from ..utils import parse_phone_no +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +class SinchRegion: + """ + Defines the Sinch Server Regions + """ + USA = 'us' + EUROPE = 'eu' + + +# Used for verification purposes +SINCH_REGIONS = (SinchRegion.USA, SinchRegion.EUROPE) + + +class NotifySinch(NotifyBase): + """ + A wrapper for Sinch Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Sinch' + + # The services URL + service_url = 'https://sinch.com/' + + # All notification requests are secure + secure_protocol = 'sinch' + + # Allow 300 requests per minute. + # 60/300 = 0.2 + request_rate_per_sec = 0.20 + + # the number of seconds undelivered messages should linger for + # in the Sinch queue + validity_period = 14400 + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_sinch' + + # Sinch uses the http protocol with JSON requests + # - the 'spi' gets substituted with the Service Provider ID + # provided as part of the Apprise URL. + notify_url = 'https://{region}.sms.api.sinch.com/xms/v1/{spi}/batches' + + # The maximum length of the body + body_maxlen = 160 + + # A title can not be used for SMS Messages. Setting this to zero will + # cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + + # Define object templates + templates = ( + '{schema}://{service_plan_id}:{api_token}@{from_phone}', + '{schema}://{service_plan_id}:{api_token}@{from_phone}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'service_plan_id': { + 'name': _('Account SID'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[a-f0-9]+$', 'i'), + }, + 'api_token': { + 'name': _('Auth Token'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[a-f0-9]+$', 'i'), + }, + 'from_phone': { + 'name': _('From Phone No'), + 'type': 'string', + 'required': True, + 'regex': (r'^\+?[0-9\s)(+-]+$', 'i'), + 'map_to': 'source', + }, + 'target_phone': { + 'name': _('Target Phone No'), + 'type': 'string', + 'prefix': '+', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + 'map_to': 'targets', + }, + 'short_code': { + 'name': _('Target Short Code'), + 'type': 'string', + 'regex': (r'^[0-9]{5,6}$', 'i'), + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'from': { + 'alias_of': 'from_phone', + }, + 'spi': { + 'alias_of': 'service_plan_id', + }, + 'region': { + 'name': _('Region'), + 'type': 'string', + 'regex': (r'^[a-z]{2}$', 'i'), + 'default': SinchRegion.USA, + }, + 'token': { + 'alias_of': 'api_token', + }, + }) + + def __init__(self, service_plan_id, api_token, source, targets=None, + region=None, **kwargs): + """ + Initialize Sinch Object + """ + super().__init__(**kwargs) + + # The Account SID associated with the account + self.service_plan_id = validate_regex( + service_plan_id, *self.template_tokens['service_plan_id']['regex']) + if not self.service_plan_id: + msg = 'An invalid Sinch Account SID ' \ + '({}) was specified.'.format(service_plan_id) + self.logger.warning(msg) + raise TypeError(msg) + + # The Authentication Token associated with the account + self.api_token = validate_regex( + api_token, *self.template_tokens['api_token']['regex']) + if not self.api_token: + msg = 'An invalid Sinch Authentication Token ' \ + '({}) was specified.'.format(api_token) + self.logger.warning(msg) + raise TypeError(msg) + + # Setup our region + self.region = self.template_args['region']['default'] \ + if not isinstance(region, str) else region.lower() + if self.region and self.region not in SINCH_REGIONS: + msg = 'The region specified ({}) is invalid.'.format(region) + self.logger.warning(msg) + raise TypeError(msg) + + # The Source Phone # and/or short-code + result = is_phone_no(source, min_len=5) + if not result: + msg = 'The Account (From) Phone # or Short-code specified ' \ + '({}) is invalid.'.format(source) + self.logger.warning(msg) + raise TypeError(msg) + + # Tidy source + self.source = result['full'] + + if len(self.source) < 11 or len(self.source) > 14: + # A short code is a special 5 or 6 digit telephone number + # that's shorter than a full phone number. + if len(self.source) not in (5, 6): + msg = 'The Account (From) Phone # specified ' \ + '({}) is invalid.'.format(source) + self.logger.warning(msg) + raise TypeError(msg) + + # else... it as a short code so we're okay + + else: + # We're dealing with a phone number; so we need to just + # place a plus symbol at the end of it + self.source = '+{}'.format(self.source) + + # Parse our targets + self.targets = list() + + for target in parse_phone_no(targets): + # Parse each phone number we found + result = is_phone_no(target) + if not result: + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) + continue + + # store valid phone number + self.targets.append('+{}'.format(result['full'])) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Sinch Notification + """ + + if not self.targets: + if len(self.source) in (5, 6): + # Generate a warning since we're a short-code. We need + # a number to message at minimum + self.logger.warning( + 'There are no valid Sinch targets to notify.') + return False + + # error tracking (used for function return) + has_error = False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Authorization': 'Bearer {}'.format(self.api_token), + 'Content-Type': 'application/json', + } + + # Prepare our payload + payload = { + 'body': body, + 'from': self.source, + + # The To gets populated in the loop below + 'to': None, + } + + # Prepare our Sinch URL (spi = Service Provider ID) + url = self.notify_url.format( + region=self.region, spi=self.service_plan_id) + + # Create a copy of the targets list + targets = list(self.targets) + + if len(targets) == 0: + # No sources specified, use our own phone no + targets.append(self.source) + + while len(targets): + # Get our target to notify + target = targets.pop(0) + + # Prepare our user + payload['to'] = [target] + + # Some Debug Logging + self.logger.debug('Sinch POST URL: {} (cert_verify={})'.format( + url, self.verify_certificate)) + self.logger.debug('Sinch Payload: {}' .format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + url, + data=json.dumps(payload), + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + # The responsne might look like: + # { + # "id": "CJloRJOe3MtDITqx", + # "to": ["15551112222"], + # "from": "15553334444", + # "canceled": false, + # "body": "This is a test message from your Sinch account", + # "type": "mt_text", + # "created_at": "2020-01-14T01:05:20.694Z", + # "modified_at": "2020-01-14T01:05:20.694Z", + # "delivery_report": "none", + # "expire_at": "2020-01-17T01:05:20.694Z", + # "flash_message": false + # } + if r.status_code not in ( + requests.codes.created, requests.codes.ok): + # We had a problem + status_str = \ + NotifyBase.http_response_code_lookup(r.status_code) + + # set up our status code to use + status_code = r.status_code + + try: + # Update our status response if we can + json_response = json.loads(r.content) + status_code = json_response.get('code', status_code) + status_str = json_response.get('message', status_str) + + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + + # We could not parse JSON response. + # We will just use the status we already have. + pass + + self.logger.warning( + 'Failed to send Sinch notification to {}: ' + '{}{}error={}.'.format( + target, + status_str, + ', ' if status_str else '', + status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent Sinch notification to {}.'.format(target)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Sinch:%s ' % ( + target) + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + 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 = { + 'region': self.region, + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{spi}:{token}@{source}/{targets}/?{params}'.format( + schema=self.secure_protocol, + spi=self.pprint( + self.service_plan_id, privacy, mode=PrivacyMode.Tail, safe=''), + token=self.pprint(self.api_token, privacy, safe=''), + source=NotifySinch.quote(self.source, safe=''), + targets='/'.join( + [NotifySinch.quote(x, safe='') for x in self.targets]), + params=NotifySinch.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets > 0 else 1 + + @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 + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'] = NotifySinch.split_path(results['fullpath']) + + # The hostname is our source number + results['source'] = NotifySinch.unquote(results['host']) + + # Get our service_plan_ide and api_token from the user/pass config + results['service_plan_id'] = NotifySinch.unquote(results['user']) + results['api_token'] = NotifySinch.unquote(results['password']) + + # Auth Token + if 'token' in results['qsd'] and len(results['qsd']['token']): + # Extract the account spi from an argument + results['api_token'] = \ + NotifySinch.unquote(results['qsd']['token']) + + # Account SID + if 'spi' in results['qsd'] and len(results['qsd']['spi']): + # Extract the account spi from an argument + results['service_plan_id'] = \ + NotifySinch.unquote(results['qsd']['spi']) + + # Support the 'from' and 'source' variable so that we can support + # targets this way too. + # The 'from' makes it easier to use yaml configuration + if 'from' in results['qsd'] and len(results['qsd']['from']): + results['source'] = \ + NotifySinch.unquote(results['qsd']['from']) + + if 'source' in results['qsd'] and len(results['qsd']['source']): + results['source'] = \ + NotifySinch.unquote(results['qsd']['source']) + + # Allow one to define a region + if 'region' in results['qsd'] and len(results['qsd']['region']): + results['region'] = \ + NotifySinch.unquote(results['qsd']['region']) + + # Support the 'to' variable so that we can support targets this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifySinch.parse_phone_no(results['qsd']['to']) + + return results diff --git a/lib/apprise/plugins/NotifySlack.py b/lib/apprise/plugins/NotifySlack.py new file mode 100644 index 0000000..bbd2bf2 --- /dev/null +++ b/lib/apprise/plugins/NotifySlack.py @@ -0,0 +1,1123 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# There are 2 ways to use this plugin... +# Method 1: Via Webhook: +# Visit https://my.slack.com/services/new/incoming-webhook/ +# to create a new incoming webhook for your account. You'll need to +# follow the wizard to pre-determine the channel(s) you want your +# message to broadcast to, and when you're complete, you will +# recieve a URL that looks something like this: +# https://hooks.slack.com/services/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7 +# ^ ^ ^ +# | | | +# These are important <--------------^---------^---------------^ +# +# Method 2: Via a Bot: +# 1. visit: https://api.slack.com/apps?new_app=1 +# 2. Pick an App Name (such as Apprise) and select your workspace. Then +# press 'Create App' +# 3. You'll be able to click on 'Bots' from here where you can then choose +# to add a 'Bot User'. Give it a name and choose 'Add Bot User'. +# 4. Now you can choose 'Install App' to which you can choose 'Install App +# to Workspace'. +# 5. You will need to authorize the app which you get prompted to do. +# 6. Finally you'll get some important information providing you your +# 'OAuth Access Token' and 'Bot User OAuth Access Token' such as: +# slack://{Oauth Access Token} +# +# ... which might look something like: +# slack://xoxp-1234-1234-1234-4ddbc191d40ee098cbaae6f3523ada2d +# ... or: +# slack://xoxb-1234-1234-4ddbc191d40ee098cbaae6f3523ada2d +# +# You must at least give your bot the following access for it to +# be useful: +# - chat:write - MUST be set otherwise you can not post into +# a channel +# - users:read.email - Required if you want to be able to lookup +# users by their email address. +# +# The easiest way to bring a bot into a channel (so that it can send +# a message to it is to invite it. At this time Apprise does not support +# an auto-join functionality. To do this: +# - In the 'Details' section of your channel +# - Click on the 'More' [...] (elipse icon) +# - Click 'Add apps' +# - You will be able to select the Bot App you previously created +# - Your bot will join your channel. + +import re +import requests +from json import dumps +from json import loads +from time import time + +from .NotifyBase import NotifyBase +from ..common import NotifyImageSize +from ..common import NotifyType +from ..common import NotifyFormat +from ..utils import is_email +from ..utils import parse_bool +from ..utils import parse_list +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + +# Extend HTTP Error Messages +SLACK_HTTP_ERROR_MAP = { + 401: 'Unauthorized - Invalid Token.', +} + +# Used to break path apart into list of channels +CHANNEL_LIST_DELIM = re.compile(r'[ \t\r\n,#\\/]+') + + +class SlackMode: + """ + Tracks the mode of which we're using Slack + """ + # We're dealing with a webhook + # Our token looks like: T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7 + WEBHOOK = 'webhook' + + # We're dealing with a bot (using the OAuth Access Token) + # Our token looks like: xoxp-1234-1234-1234-abc124 or + # Our token looks like: xoxb-1234-1234-abc124 or + BOT = 'bot' + + +# Define our Slack Modes +SLACK_MODES = ( + SlackMode.WEBHOOK, + SlackMode.BOT, +) + + +class NotifySlack(NotifyBase): + """ + A wrapper for Slack Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Slack' + + # The services URL + service_url = 'https://slack.com/' + + # The default secure protocol + secure_protocol = 'slack' + + # Allow 50 requests per minute (Tier 2). + # 60/50 = 0.2 + request_rate_per_sec = 1.2 + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_slack' + + # Support attachments + attachment_support = True + + # The maximum targets to include when doing batch transfers + # Slack Webhook URL + webhook_url = 'https://hooks.slack.com/services' + + # Slack API URL (used with Bots) + api_url = 'https://slack.com/api/{}' + + # 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 = 35000 + + # Default Notification Format + notify_format = NotifyFormat.MARKDOWN + + # Bot's do not have default channels to notify; so #general + # becomes the default channel in BOT mode + default_notification_channel = '#general' + + # Define object templates + templates = ( + # Webhook + '{schema}://{token_a}/{token_b}/{token_c}', + '{schema}://{botname}@{token_a}/{token_b}{token_c}', + '{schema}://{token_a}/{token_b}/{token_c}/{targets}', + '{schema}://{botname}@{token_a}/{token_b}/{token_c}/{targets}', + + # Bot + '{schema}://{access_token}/', + '{schema}://{access_token}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'botname': { + 'name': _('Bot Name'), + 'type': 'string', + 'map_to': 'user', + }, + # Bot User OAuth Access Token + # which always starts with xoxp- e.g.: + # xoxb-1234-1234-4ddbc191d40ee098cbaae6f3523ada2d + 'access_token': { + 'name': _('OAuth Access Token'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^xox[abp]-[A-Z0-9-]+$', 'i'), + }, + # Token required as part of the Webhook request + # /AAAAAAAAA/........./........................ + 'token_a': { + 'name': _('Token A'), + 'type': 'string', + 'private': True, + 'regex': (r'^[A-Z0-9]+$', 'i'), + }, + # Token required as part of the Webhook request + # /........./BBBBBBBBB/........................ + 'token_b': { + 'name': _('Token B'), + 'type': 'string', + 'private': True, + 'regex': (r'^[A-Z0-9]+$', 'i'), + }, + # Token required as part of the Webhook request + # /........./........./CCCCCCCCCCCCCCCCCCCCCCCC + 'token_c': { + 'name': _('Token C'), + 'type': 'string', + 'private': True, + 'regex': (r'^[A-Za-z0-9]+$', 'i'), + }, + 'target_encoded_id': { + 'name': _('Target Encoded ID'), + 'type': 'string', + 'prefix': '+', + 'map_to': 'targets', + }, + 'target_email': { + 'name': _('Target Email'), + 'type': 'string', + 'map_to': 'targets', + }, + 'target_user': { + 'name': _('Target User'), + 'type': 'string', + 'prefix': '@', + 'map_to': 'targets', + }, + 'target_channels': { + 'name': _('Target Channel'), + 'type': 'string', + 'prefix': '#', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': True, + 'map_to': 'include_image', + }, + 'footer': { + 'name': _('Include Footer'), + 'type': 'bool', + 'default': True, + 'map_to': 'include_footer', + }, + # Use Payload in Blocks (vs legacy way): + # See: https://api.slack.com/reference/messaging/payload + 'blocks': { + 'name': _('Use Blocks'), + 'type': 'bool', + 'default': False, + 'map_to': 'use_blocks', + }, + 'to': { + 'alias_of': 'targets', + }, + 'token': { + 'name': _('Token'), + 'alias_of': ('access_token', 'token_a', 'token_b', 'token_c'), + }, + }) + + def __init__(self, access_token=None, token_a=None, token_b=None, + token_c=None, targets=None, include_image=True, + include_footer=True, use_blocks=None, **kwargs): + """ + Initialize Slack Object + """ + super().__init__(**kwargs) + + # Setup our mode + self.mode = SlackMode.BOT if access_token else SlackMode.WEBHOOK + + if self.mode is SlackMode.WEBHOOK: + self.token_a = validate_regex( + token_a, *self.template_tokens['token_a']['regex']) + if not self.token_a: + msg = 'An invalid Slack (first) Token ' \ + '({}) was specified.'.format(token_a) + self.logger.warning(msg) + raise TypeError(msg) + + self.token_b = validate_regex( + token_b, *self.template_tokens['token_b']['regex']) + if not self.token_b: + msg = 'An invalid Slack (second) Token ' \ + '({}) was specified.'.format(token_b) + self.logger.warning(msg) + raise TypeError(msg) + + self.token_c = validate_regex( + token_c, *self.template_tokens['token_c']['regex']) + if not self.token_c: + msg = 'An invalid Slack (third) Token ' \ + '({}) was specified.'.format(token_c) + self.logger.warning(msg) + raise TypeError(msg) + else: + self.access_token = validate_regex( + access_token, *self.template_tokens['access_token']['regex']) + if not self.access_token: + msg = 'An invalid Slack OAuth Access Token ' \ + '({}) was specified.'.format(access_token) + self.logger.warning(msg) + raise TypeError(msg) + + # Look the users up by their email address and map them back to their + # id here for future queries (if needed). This allows people to + # specify a full email as a recipient via slack + self._lookup_users = {} + + self.use_blocks = parse_bool( + use_blocks, self.template_args['blocks']['default']) \ + if use_blocks is not None \ + else self.template_args['blocks']['default'] + + # Build list of channels + self.channels = parse_list(targets) + if len(self.channels) == 0: + # No problem; the webhook is smart enough to just notify the + # channel it was created for; adding 'None' is just used as + # a flag lower to not set the channels + self.channels.append( + None if self.mode is SlackMode.WEBHOOK + else self.default_notification_channel) + + # Formatting requirements are defined here: + # https://api.slack.com/docs/message-formatting + self._re_formatting_map = { + # New lines must become the string version + r'\r\*\n': '\\n', + # Escape other special characters + r'&': '&', + r'<': '<', + r'>': '>', + } + + # To notify a channel, one uses + self._re_channel_support = re.compile( + r'(?P(?:<|\<)?[ \t]*' + r'!(?P[^| \n]+)' + r'(?:[ \t]*\|[ \t]*(?:(?P[^\n]+?)[ \t]*)?(?:>|\>)' + r'|(?:>|\>)))', re.IGNORECASE) + + # The markdown in slack isn't [desc](url), it's + # + # To accomodate this, we need to ensure we don't escape URLs that match + self._re_url_support = re.compile( + r'(?P(?:<|\<)?[ \t]*' + r'(?P(?:https?|mailto)://[^| \n]+)' + r'(?:[ \t]*\|[ \t]*(?:(?P[^\n]+?)[ \t]*)?(?:>|\>)' + r'|(?:>|\>)))', re.IGNORECASE) + + # Iterate over above list and store content accordingly + self._re_formatting_rules = re.compile( + r'(' + '|'.join(self._re_formatting_map.keys()) + r')', + re.IGNORECASE, + ) + + # Place a thumbnail image inline with the message body + self.include_image = include_image + + # Place a footer with each post + self.include_footer = include_footer + return + + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): + """ + Perform Slack Notification + """ + + # error tracking (used for function return) + has_error = False + + # + # Prepare JSON Object (applicable to both WEBHOOK and BOT mode) + # + if self.use_blocks: + # Our slack format + _slack_format = 'mrkdwn' \ + if self.notify_format == NotifyFormat.MARKDOWN \ + else 'plain_text' + + payload = { + 'username': self.user if self.user else self.app_id, + 'attachments': [{ + 'blocks': [{ + 'type': 'section', + 'text': { + 'type': _slack_format, + 'text': body + } + }], + 'color': self.color(notify_type), + }] + } + + # Slack only accepts non-empty header sections + if title: + payload['attachments'][0]['blocks'].insert(0, { + 'type': 'header', + 'text': { + 'type': 'plain_text', + 'text': title, + 'emoji': True + } + }) + + # Include the footer only if specified to do so + if self.include_footer: + + # Acquire our to-be footer icon if configured to do so + image_url = None if not self.include_image \ + else self.image_url(notify_type) + + # Prepare our footer based on the block structure + _footer = { + 'type': 'context', + 'elements': [{ + 'type': _slack_format, + 'text': self.app_id + }] + } + + if image_url: + payload['icon_url'] = image_url + + _footer['elements'].insert(0, { + 'type': 'image', + 'image_url': image_url, + 'alt_text': notify_type + }) + + payload['attachments'][0]['blocks'].append(_footer) + + else: + # + # Legacy API Formatting + # + if self.notify_format == NotifyFormat.MARKDOWN: + body = self._re_formatting_rules.sub( # pragma: no branch + lambda x: self._re_formatting_map[x.group()], body, + ) + + # Support , entries + for match in self._re_channel_support.findall(body): + # Swap back any ampersands previously updaated + channel = match[1].strip() + desc = match[2].strip() + + # Update our string + body = re.sub( + re.escape(match[0]), + ''.format( + channel=channel, desc=desc) + if desc else ''.format(channel=channel), + body, + re.IGNORECASE) + + # Support , entries + for match in self._re_url_support.findall(body): + # Swap back any ampersands previously updaated + url = match[1].replace('&', '&') + desc = match[2].strip() + + # Update our string + body = re.sub( + re.escape(match[0]), + '<{url}|{desc}>'.format(url=url, desc=desc) + if desc else '<{url}>'.format(url=url), + body, + re.IGNORECASE) + + # Perform Formatting on title here; this is not needed for block + # mode above + title = self._re_formatting_rules.sub( # pragma: no branch + lambda x: self._re_formatting_map[x.group()], title, + ) + + # Prepare JSON Object (applicable to both WEBHOOK and BOT mode) + payload = { + 'username': self.user if self.user else self.app_id, + # Use Markdown language + 'mrkdwn': (self.notify_format == NotifyFormat.MARKDOWN), + 'attachments': [{ + 'title': title, + 'text': body, + 'color': self.color(notify_type), + # Time + 'ts': time(), + }], + } + # Acquire our to-be footer icon if configured to do so + image_url = None if not self.include_image \ + else self.image_url(notify_type) + + if image_url: + payload['icon_url'] = image_url + + # Include the footer only if specified to do so + if self.include_footer: + if image_url: + payload['attachments'][0]['footer_icon'] = image_url + + # Include the footer only if specified to do so + payload['attachments'][0]['footer'] = self.app_id + + if attach and self.attachment_support \ + and self.mode is SlackMode.WEBHOOK: + # Be friendly; let the user know why they can't send their + # attachments if using the Webhook mode + self.logger.warning( + 'Slack Webhooks do not support attachments.') + + # Prepare our Slack URL (depends on mode) + if self.mode is SlackMode.WEBHOOK: + url = '{}/{}/{}/{}'.format( + self.webhook_url, + self.token_a, + self.token_b, + self.token_c, + ) + + else: # SlackMode.BOT + url = self.api_url.format('chat.postMessage') + + # Create a copy of the channel list + channels = list(self.channels) + + attach_channel_list = [] + while len(channels): + channel = channels.pop(0) + + if channel is not None: + channel = validate_regex(channel, r'[+#@]?[A-Z0-9_]{1,32}') + if not channel: + # Channel over-ride was specified + self.logger.warning( + "The specified target {} is invalid;" + "skipping.".format(channel)) + + # Mark our failure + has_error = True + continue + + if channel[0] == '+': + # Treat as encoded id if prefixed with a + + payload['channel'] = channel[1:] + + elif channel[0] == '@': + # Treat @ value 'as is' + payload['channel'] = channel + + else: + # We'll perform a user lookup if we detect an email + email = is_email(channel) + if email: + payload['channel'] = \ + self.lookup_userid(email['full_email']) + + if not payload['channel']: + # Move along; any notifications/logging would have + # come from lookup_userid() + has_error = True + continue + else: + # Prefix with channel hash tag (if not already) + payload['channel'] = \ + channel if channel[0] == '#' \ + else '#{}'.format(channel) + + # Store the valid and massaged payload that is recognizable by + # slack. This list is used for sending attachments later. + attach_channel_list.append(payload['channel']) + + response = self._send(url, payload) + if not response: + # Handle any error + has_error = True + continue + + self.logger.info( + 'Sent Slack notification{}.'.format( + ' to {}'.format(channel) + if channel is not None else '')) + + if attach and self.attachment_support and \ + self.mode is SlackMode.BOT and attach_channel_list: + # Send our attachments (can only be done in bot mode) + 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 + + self.logger.debug( + 'Posting Slack attachment {}'.format( + attachment.url(privacy=True))) + + # Prepare API Upload Payload + _payload = { + 'filename': attachment.name, + 'channels': ','.join(attach_channel_list) + } + + # Our URL + _url = self.api_url.format('files.upload') + + response = self._send(_url, _payload, attach=attachment) + if not (response and response.get('file') and + response['file'].get('url_private')): + # We failed to post our attachments, take an early exit + return False + + return not has_error + + def lookup_userid(self, email): + """ + Takes an email address and attempts to resolve/acquire it's user + id for notification purposes. + """ + if email in self._lookup_users: + # We're done as entry has already been retrieved + return self._lookup_users[email] + + if self.mode is not SlackMode.BOT: + # You can not look up + self.logger.warning( + 'Emails can not be resolved to Slack User IDs unless you ' + 'have a bot configured.') + return None + + lookup_url = self.api_url.format('users.lookupByEmail') + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': 'Bearer {}'.format(self.access_token), + } + + # we pass in our email address as the argument + params = { + 'email': email, + } + + self.logger.debug('Slack User Lookup POST URL: %s (cert_verify=%r)' % ( + lookup_url, self.verify_certificate, + )) + self.logger.debug('Slack User Lookup Parameters: %s' % str(params)) + + # Initialize our HTTP JSON response + response = {'ok': False} + + # Initialize our detected user id (also the response to this function) + user_id = None + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.get( + lookup_url, + headers=headers, + params=params, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + # Attachment posts return a JSON string + try: + response = loads(r.content) + + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + pass + + # We can get a 200 response, but still fail. A failure message + # might look like this (missing bot permissions): + # { + # 'ok': False, + # 'error': 'missing_scope', + # 'needed': 'users:read.email', + # 'provided': 'calls:write,chat:write' + # } + + if r.status_code != requests.codes.ok \ + or not (response and response.get('ok', False)): + + # We had a problem + status_str = \ + NotifySlack.http_response_code_lookup( + r.status_code, SLACK_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send Slack User Lookup:' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + # Return; we're done + return False + + # If we reach here, then we were successful in looking up + # the user. A response generally looks like this: + # { + # 'ok': True, + # 'user': { + # 'id': 'J1ZQB9T9Y', + # 'team_id': 'K1WR6TML2', + # 'name': 'l2g', + # 'deleted': False, + # 'color': '9f69e7', + # 'real_name': 'Chris C', + # 'tz': 'America/New_York', + # 'tz_label': 'Eastern Standard Time', + # 'tz_offset': -18000, + # 'profile': { + # 'title': '', + # 'phone': '', + # 'skype': '', + # 'real_name': 'Chris C', + # 'real_name_normalized': + # 'Chris C', + # 'display_name': 'l2g', + # 'display_name_normalized': 'l2g', + # 'fields': None, + # 'status_text': '', + # 'status_emoji': '', + # 'status_expiration': 0, + # 'avatar_hash': 'g785e9c0ddf6', + # 'email': 'lead2gold@gmail.com', + # 'first_name': 'Chris', + # 'last_name': 'C', + # 'image_24': 'https://secure.gravatar.com/...', + # 'image_32': 'https://secure.gravatar.com/...', + # 'image_48': 'https://secure.gravatar.com/...', + # 'image_72': 'https://secure.gravatar.com/...', + # 'image_192': 'https://secure.gravatar.com/...', + # 'image_512': 'https://secure.gravatar.com/...', + # 'status_text_canonical': '', + # 'team': 'K1WR6TML2' + # }, + # 'is_admin': True, + # 'is_owner': True, + # 'is_primary_owner': True, + # 'is_restricted': False, + # 'is_ultra_restricted': False, + # 'is_bot': False, + # 'is_app_user': False, + # 'updated': 1603904274 + # } + # } + # We're only interested in the id + user_id = response['user']['id'] + + # Cache it for future + self._lookup_users[email] = user_id + self.logger.info( + 'Email %s resolves to the Slack User ID: %s.', email, user_id) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred looking up Slack User.', + ) + self.logger.debug('Socket Exception: %s' % str(e)) + # Return; we're done + return None + + return user_id + + def _send(self, url, payload, attach=None, **kwargs): + """ + Wrapper to the requests (post) object + """ + + self.logger.debug('Slack POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('Slack Payload: %s' % str(payload)) + + headers = { + 'User-Agent': self.app_id, + 'Accept': 'application/json', + } + + if not attach: + headers['Content-Type'] = 'application/json; charset=utf-8' + + if self.mode is SlackMode.BOT: + headers['Authorization'] = 'Bearer {}'.format(self.access_token) + + # Our response object + response = {'ok': False} + + # Always call throttle before any remote server i/o is made + self.throttle() + + # Our attachment path (if specified) + files = None + + try: + # Open our attachment path if required: + if attach: + files = {'file': (attach.name, open(attach.path, 'rb'))} + + r = requests.post( + url, + data=payload if attach else dumps(payload), + headers=headers, + files=files, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + # Posts return a JSON string + try: + response = loads(r.content) + + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + pass + + # Another response type is: + # { + # 'ok': False, + # 'error': 'not_in_channel', + # } + # + # The text 'ok' is returned if this is a Webhook request + # So the below captures that as well. + status_okay = (response and response.get('ok', False)) \ + if self.mode is SlackMode.BOT else r.content == b'ok' + + if r.status_code != requests.codes.ok or not status_okay: + # We had a problem + status_str = \ + NotifySlack.http_response_code_lookup( + r.status_code, SLACK_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send {}to Slack: ' + '{}{}error={}.'.format( + attach.name if attach else '', + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + return False + + # Message Post Response looks like this: + # { + # "attachments": [ + # { + # "color": "3AA3E3", + # "fallback": "test", + # "id": 1, + # "text": "my body", + # "title": "my title", + # "ts": 1573694687 + # } + # ], + # "bot_id": "BAK4K23G5", + # "icons": { + # "image_48": "https://s3-us-west-2.amazonaws.com/... + # }, + # "subtype": "bot_message", + # "text": "", + # "ts": "1573694689.003700", + # "type": "message", + # "username": "Apprise" + # } + + # File Attachment Responses look like this + # { + # "file": { + # "channels": [], + # "comments_count": 0, + # "created": 1573617523, + # "display_as_bot": false, + # "editable": false, + # "external_type": "", + # "filetype": "png", + # "groups": [], + # "has_rich_preview": false, + # "id": "FQJJLDAHM", + # "image_exif_rotation": 1, + # "ims": [], + # "is_external": false, + # "is_public": false, + # "is_starred": false, + # "mimetype": "image/png", + # "mode": "hosted", + # "name": "apprise-test.png", + # "original_h": 640, + # "original_w": 640, + # "permalink": "https://{name}.slack.com/files/... + # "permalink_public": "https://slack-files.com/... + # "pretty_type": "PNG", + # "public_url_shared": false, + # "shares": {}, + # "size": 238810, + # "thumb_160": "https://files.slack.com/files-tmb/... + # "thumb_360": "https://files.slack.com/files-tmb/... + # "thumb_360_h": 360, + # "thumb_360_w": 360, + # "thumb_480": "https://files.slack.com/files-tmb/... + # "thumb_480_h": 480, + # "thumb_480_w": 480, + # "thumb_64": "https://files.slack.com/files-tmb/... + # "thumb_80": "https://files.slack.com/files-tmb/... + # "thumb_tiny": abcd... + # "timestamp": 1573617523, + # "title": "apprise-test", + # "url_private": "https://files.slack.com/files-pri/... + # "url_private_download": "https://files.slack.com/files-... + # "user": "UADKLLMJT", + # "username": "" + # }, + # "ok": true + # } + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred posting {}to Slack.'.format( + attach.name if attach else '')) + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while reading {}.'.format( + attach.name if attach else 'attachment')) + self.logger.debug('I/O Exception: %s' % str(e)) + return False + + finally: + # Close our file (if it's open) stored in the second element + # of our files tuple (index 1) + if files: + files['file'][1].close() + + # Return the response for processing + return response + + 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', + 'footer': 'yes' if self.include_footer else 'no', + 'blocks': 'yes' if self.use_blocks else 'no', + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Determine if there is a botname present + botname = '' + if self.user: + botname = '{botname}@'.format( + botname=NotifySlack.quote(self.user, safe=''), + ) + + if self.mode == SlackMode.WEBHOOK: + return '{schema}://{botname}{token_a}/{token_b}/{token_c}/'\ + '{targets}/?{params}'.format( + schema=self.secure_protocol, + botname=botname, + token_a=self.pprint(self.token_a, privacy, safe=''), + token_b=self.pprint(self.token_b, privacy, safe=''), + token_c=self.pprint(self.token_c, privacy, safe=''), + targets='/'.join( + [NotifySlack.quote(x, safe='') + for x in self.channels]), + params=NotifySlack.urlencode(params), + ) + # else -> self.mode == SlackMode.BOT: + return '{schema}://{botname}{access_token}/{targets}/'\ + '?{params}'.format( + schema=self.secure_protocol, + botname=botname, + access_token=self.pprint(self.access_token, privacy, safe=''), + targets='/'.join( + [NotifySlack.quote(x, safe='') for x in self.channels]), + params=NotifySlack.urlencode(params), + ) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.channels) + + @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 + + # The first token is stored in the hostname + token = NotifySlack.unquote(results['host']) + + # Get unquoted entries + entries = NotifySlack.split_path(results['fullpath']) + + # Verify if our token_a us a bot token or part of a webhook: + if token.startswith('xo'): + # We're dealing with a bot + results['access_token'] = token + + else: + # We're dealing with a webhook + results['token_a'] = token + results['token_b'] = entries.pop(0) if entries else None + results['token_c'] = entries.pop(0) if entries else None + + # assign remaining entries to the channels we wish to notify + results['targets'] = entries + + # Support the token flag where you can set it to the bot token + # or the webhook token (with slash delimiters) + if 'token' in results['qsd'] and len(results['qsd']['token']): + # Break our entries up into a list; we can ue the Channel + # list delimiter above since it doesn't contain any characters + # we don't otherwise accept anyway in our token + entries = [x for x in filter( + bool, CHANNEL_LIST_DELIM.split( + NotifySlack.unquote(results['qsd']['token'])))] + + # check to see if we're dealing with a bot/user token + if entries and entries[0].startswith('xo'): + # We're dealing with a bot + results['access_token'] = entries[0] + results['token_a'] = None + results['token_b'] = None + results['token_c'] = None + + else: # Webhook + results['access_token'] = None + results['token_a'] = entries.pop(0) if entries else None + results['token_b'] = entries.pop(0) if entries else None + results['token_c'] = entries.pop(0) if entries else None + + # Support the 'to' variable so that we can support rooms this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += [x for x in filter( + bool, CHANNEL_LIST_DELIM.split( + NotifySlack.unquote(results['qsd']['to'])))] + + # Get Image Flag + results['include_image'] = \ + parse_bool(results['qsd'].get('image', True)) + + # Get Payload structure (use blocks?) + if 'blocks' in results['qsd'] and len(results['qsd']['blocks']): + results['use_blocks'] = parse_bool(results['qsd']['blocks']) + + # Get Footer Flag + results['include_footer'] = \ + parse_bool(results['qsd'].get('footer', True)) + + return results + + @staticmethod + def parse_native_url(url): + """ + Support https://hooks.slack.com/services/TOKEN_A/TOKEN_B/TOKEN_C + """ + + result = re.match( + r'^https?://hooks\.slack\.com/services/' + r'(?P[A-Z0-9]+)/' + r'(?P[A-Z0-9]+)/' + r'(?P[A-Z0-9]+)/?' + r'(?P\?.+)?$', url, re.I) + + if result: + return NotifySlack.parse_url( + '{schema}://{token_a}/{token_b}/{token_c}/{params}'.format( + schema=NotifySlack.secure_protocol, + token_a=result.group('token_a'), + token_b=result.group('token_b'), + token_c=result.group('token_c'), + params='' if not result.group('params') + else result.group('params'))) + + return None diff --git a/lib/apprise/plugins/NotifySparkPost.py b/lib/apprise/plugins/NotifySparkPost.py new file mode 100644 index 0000000..282f550 --- /dev/null +++ b/lib/apprise/plugins/NotifySparkPost.py @@ -0,0 +1,800 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# Signup @ https://www.sparkpost.com +# +# Ensure you've added a Senders Domain and have generated yourself an +# API Key at: +# https://app.sparkpost.com/dashboard + +# Note: For SMTP Access, your API key must have at least been granted the +# 'Send via SMTP' privileges. + +# From here you can click on the domain you're interested in. You can acquire +# the API Key from here which will look something like: +# 1e1d479fcf1a87527e9411e083c700689fa1acdc +# +# Knowing this, you can buid your sparkpost url as follows: +# sparkpost://{user}@{domain}/{apikey} +# sparkpost://{user}@{domain}/{apikey}/{email} +# +# You can email as many addresses as you want as: +# sparkpost://{user}@{domain}/{apikey}/{email1}/{email2}/{emailN} +# +# The {user}@{domain} effectively assembles the 'from' email address +# the email will be transmitted from. If no email address is specified +# then it will also become the 'to' address as well. +# +# The {domain} must cross reference a domain you've set up with Spark Post +# +# API Documentation: https://developers.sparkpost.com/api/ +# Specifically: https://developers.sparkpost.com/api/transmissions/ +import requests +import base64 +from json import loads +from json import dumps +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..common import NotifyFormat +from ..utils import is_email +from email.utils import formataddr +from ..utils import validate_regex +from ..utils import parse_emails +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + +# Provide some known codes SparkPost uses and what they translate to: +# Based on https://www.sparkpost.com/docs/tech-resources/extended-error-codes/ +SPARKPOST_HTTP_ERROR_MAP = { + 400: 'A bad request was made to the server', + 401: 'Invalid User ID and/or Unauthorized User', + 403: 'Permission Denied; the provided API Key was not valid', + 404: 'There is a problem with the server query URI.', + 405: 'Invalid HTTP method', + 420: 'Sending limit reached.', + 422: 'Invalid data/format/type/length', + 429: 'To many requests per sec; rate limit', +} + + +class SparkPostRegion: + """ + Regions + """ + US = 'us' + EU = 'eu' + + +# SparkPost APIs +SPARKPOST_API_LOOKUP = { + SparkPostRegion.US: 'https://api.sparkpost.com/api/v1', + SparkPostRegion.EU: 'https://api.eu.sparkpost.com/api/v1', +} + +# A List of our regions we can use for verification +SPARKPOST_REGIONS = ( + SparkPostRegion.US, + SparkPostRegion.EU, +) + + +class NotifySparkPost(NotifyBase): + """ + A wrapper for SparkPost Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'SparkPost' + + # The services URL + service_url = 'https://sparkpost.com/' + + # Support attachments + attachment_support = True + + # All notification requests are secure + secure_protocol = 'sparkpost' + + # SparkPost advertises they allow 300 requests per minute. + # 60/300 = 0.2 + request_rate_per_sec = 0.20 + + # Words straight from their website: + # https://developers.sparkpost.com/api/#header-rate-limiting + # These limits are dynamic, but as a general rule, wait 1 to 5 seconds + # after receiving a 429 response before requesting again. + + # As a simple work around, this is what we will do... Wait X seconds + # (defined below) before trying again when we get a 429 error + sparkpost_retry_wait_sec = 5 + + # The maximum number of times we'll retry to send our message when we've + # reached a throttling situatin before giving up + sparkpost_retry_attempts = 3 + + # The maximum amount of emails that can reside within a single + # batch transfer based on: + # https://www.sparkpost.com/docs/tech-resources/\ + # smtp-rest-api-performance/#sending-via-the-transmission-rest-api + default_batch_size = 2000 + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_sparkpost' + + # Default Notify Format + notify_format = NotifyFormat.HTML + + # Define object templates + templates = ( + '{schema}://{user}@{host}:{apikey}/', + '{schema}://{user}@{host}:{apikey}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'user': { + 'name': _('User Name'), + 'type': 'string', + 'required': True, + }, + 'host': { + 'name': _('Domain'), + 'type': 'string', + 'required': True, + }, + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'targets': { + 'name': _('Target Emails'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'name': { + 'name': _('From Name'), + 'type': 'string', + 'map_to': 'from_name', + }, + 'region': { + 'name': _('Region Name'), + 'type': 'choice:string', + 'values': SPARKPOST_REGIONS, + 'default': SparkPostRegion.US, + 'map_to': 'region_name', + }, + 'to': { + 'alias_of': 'targets', + }, + 'cc': { + 'name': _('Carbon Copy'), + 'type': 'list:string', + }, + 'bcc': { + 'name': _('Blind Carbon Copy'), + 'type': 'list:string', + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + }) + + # Define any kwargs we're using + template_kwargs = { + 'headers': { + 'name': _('Email Header'), + 'prefix': '+', + }, + 'tokens': { + 'name': _('Template Tokens'), + 'prefix': ':', + }, + } + + def __init__(self, apikey, targets, cc=None, bcc=None, from_name=None, + region_name=None, headers=None, tokens=None, batch=None, + **kwargs): + """ + Initialize SparkPost Object + """ + super().__init__(**kwargs) + + # API Key (associated with project) + self.apikey = validate_regex(apikey) + if not self.apikey: + msg = 'An invalid SparkPost API Key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # Validate our username + if not self.user: + msg = 'No SparkPost username was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + # Acquire Email 'To' + self.targets = list() + + # Acquire Carbon Copies + self.cc = set() + + # Acquire Blind Carbon Copies + self.bcc = set() + + # For tracking our email -> name lookups + self.names = {} + + # Store our region + try: + self.region_name = self.template_args['region']['default'] \ + if region_name is None else region_name.lower() + + if self.region_name not in SPARKPOST_REGIONS: + # allow the outer except to handle this common response + raise + except: + # Invalid region specified + msg = 'The SparkPost region specified ({}) is invalid.' \ + .format(region_name) + self.logger.warning(msg) + raise TypeError(msg) + + # Get our From username (if specified) + self.from_name = from_name + + # Get our from email address + self.from_addr = '{user}@{host}'.format(user=self.user, host=self.host) + + if not is_email(self.from_addr): + # Parse Source domain based on from_addr + msg = 'Invalid ~From~ email format: {}'.format(self.from_addr) + self.logger.warning(msg) + raise TypeError(msg) + + self.headers = {} + if headers: + # Store our extra headers + self.headers.update(headers) + + self.tokens = {} + if tokens: + # Store our template tokens + self.tokens.update(tokens) + + # Prepare Batch Mode Flag + self.batch = self.template_args['batch']['default'] \ + if batch is None else batch + + 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( + (self.from_name if self.from_name else False, self.from_addr)) + + # Validate recipients (cc:) and drop bad ones: + for recipient in parse_emails(cc): + email = is_email(recipient) + if email: + self.cc.add(email['full_email']) + + # Index our name (if one exists) + self.names[email['full_email']] = \ + email['name'] if email['name'] else False + continue + + self.logger.warning( + 'Dropped invalid Carbon Copy email ' + '({}) specified.'.format(recipient), + ) + + # Validate recipients (bcc:) and drop bad ones: + for recipient in parse_emails(bcc): + email = is_email(recipient) + if email: + self.bcc.add(email['full_email']) + + # Index our name (if one exists) + self.names[email['full_email']] = \ + email['name'] if email['name'] else False + continue + + self.logger.warning( + 'Dropped invalid Blind Carbon Copy email ' + '({}) specified.'.format(recipient), + ) + + def __post(self, payload, retry): + """ + Performs the actual post and returns the response + + """ + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': self.apikey, + } + + # Prepare our URL as it's based on our hostname + url = '{}/transmissions/'.format( + SPARKPOST_API_LOOKUP[self.region_name]) + + # Some Debug Logging + self.logger.debug('SparkPost POST URL: {} (cert_verify={})'.format( + url, self.verify_certificate)) + + if 'attachments' in payload['content']: + # Since we print our payload; attachments make it a bit too noisy + # we just strip out the data block to accomodate it + log_payload = \ + {k: v for k, v in payload.items() if k != "content"} + log_payload['content'] = \ + {k: v for k, v in payload['content'].items() + if k != "attachments"} + log_payload['content']['attachments'] = \ + [{k: v for k, v in x.items() if k != "data"} + for x in payload['content']['attachments']] + else: + # No tidying is needed + log_payload = payload + + self.logger.debug('SparkPost Payload: {}' .format(log_payload)) + + wait = None + + # For logging output of success and errors; we get a head count + # of our outbound details: + verbose_dest = ', '.join( + [x['address']['email'] for x in payload['recipients']]) \ + if len(payload['recipients']) <= 3 \ + else '{} recipients'.format(len(payload['recipients'])) + + # Initialize our response object + json_response = {} + + # Set ourselves a status code + status_code = -1 + + while 1: # pragma: no branch + + # Always call throttle before any remote server i/o is made + self.throttle(wait=wait) + try: + r = requests.post( + url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + # A Good response (200) looks like this: + # "results": { + # "total_rejected_recipients": 0, + # "total_accepted_recipients": 1, + # "id": "11668787484950529" + # } + # } + # + # A Bad response looks like this: + # { + # "errors": [ + # { + # "description": + # "Unconfigured or unverified sending domain.", + # "code": "7001", + # "message": "Invalid domain" + # } + # ] + # } + # + try: + # Update our status response if we can + json_response = loads(r.content) + + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + + # We could not parse JSON response. + # We will just use the status we already have. + pass + + status_code = r.status_code + + payload['recipients'] = list() + if status_code == requests.codes.ok: + self.logger.info( + 'Sent SparkPost notification to {}.'.format( + verbose_dest)) + return status_code, json_response + + # We had a problem if we get here + status_str = \ + NotifyBase.http_response_code_lookup( + status_code, SPARKPOST_API_LOOKUP) + + self.logger.warning( + 'Failed to send SparkPost notification to {}: ' + '{}{}error={}.'.format( + verbose_dest, + status_str, + ', ' if status_str else '', + status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + if status_code == requests.codes.too_many_requests and retry: + retry = retry - 1 + if retry > 0: + wait = self.sparkpost_retry_wait_sec + continue + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending SparkPost ' + 'notification') + self.logger.debug('Socket Exception: %s' % str(e)) + + # Anything else and we're done + return status_code, json_response + + # Our code will never reach here (outside of infinite while loop above) + + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): + """ + Perform SparkPost Notification + """ + + if not self.targets: + # There is no one to email; we're done + self.logger.warning( + 'There are no Email recipients to notify') + return False + + # Initialize our has_error flag + has_error = False + + # Send in batches if identified to do so + batch_size = 1 if not self.batch else self.default_batch_size + + reply_to = formataddr((self.from_name if self.from_name else False, + self.from_addr), charset='utf-8') + + payload = { + "options": { + # When set to True, an image is included with the email which + # is used to detect if the user looked at the image or not. + 'open_tracking': False, + + # Track if links were clicked that were found within email + 'click_tracking': False, + }, + "content": { + "from": { + "name": self.from_name + if self.from_name else self.app_desc, + "email": self.from_addr, + }, + + # SparkPost does not allow empty subject lines or lines that + # only contain whitespace; Since Apprise allows an empty title + # parameter we swap empty title entries with the period + "subject": title if title.strip() else '.', + "reply_to": reply_to, + } + } + + if self.notify_format == NotifyFormat.HTML: + payload['content']['html'] = body + + else: + payload['content']['text'] = body + + if attach and self.attachment_support: + # Prepare ourselves an attachment object + payload['content']['attachments'] = [] + + 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 + + self.logger.debug( + 'Preparing SparkPost attachment {}'.format( + attachment.url(privacy=True))) + + try: + with open(attachment.path, 'rb') as fp: + # Prepare API Upload Payload + payload['content']['attachments'].append({ + 'name': attachment.name, + 'type': attachment.mimetype, + 'data': base64.b64encode(fp.read()).decode("ascii") + }) + + 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 + + # Take a copy of our token dictionary + tokens = self.tokens.copy() + + # Apply some defaults template values + tokens['app_body'] = body + tokens['app_title'] = title + tokens['app_type'] = notify_type + tokens['app_id'] = self.app_id + tokens['app_desc'] = self.app_desc + tokens['app_color'] = self.color(notify_type) + tokens['app_url'] = self.app_url + + # Store our tokens if they're identified + payload['substitution_data'] = self.tokens + + # Create a copy of the targets list + emails = list(self.targets) + + for index in range(0, len(emails), batch_size): + # Generate our email listing + payload['recipients'] = list() + + # Initialize our cc list + cc = (self.cc - self.bcc) + + # Initialize our bcc list + bcc = set(self.bcc) + + # Initialize our headers + headers = self.headers.copy() + + for addr in self.targets[index:index + batch_size]: + entry = { + 'address': { + 'email': addr[1], + } + } + + # Strip target out of cc list if in To + cc = (cc - set([addr[1]])) + + # Strip target out of bcc list if in To + bcc = (bcc - set([addr[1]])) + + if addr[0]: + entry['address']['name'] = addr[0] + + # Add our recipient to our list + payload['recipients'].append(entry) + + if cc: + # Handle our cc List + for addr in cc: + entry = { + 'address': { + 'email': addr, + 'header_to': + # Take the first email in the To + self.targets[index:index + batch_size][0][1], + }, + } + + if self.names.get(addr): + entry['address']['name'] = self.names[addr] + + # Add our recipient to our list + payload['recipients'].append(entry) + + headers['CC'] = ','.join(cc) + + # Handle our bcc + for addr in bcc: + # Add our recipient to our list + payload['recipients'].append({ + 'address': { + 'email': addr, + 'header_to': + # Take the first email in the To + self.targets[index:index + batch_size][0][1], + }, + }) + + if headers: + payload['content']['headers'] = headers + + # Send our message + status_code, response = \ + self.__post(payload, self.sparkpost_retry_attempts) + + # Failed + if status_code != requests.codes.ok: + has_error = True + + 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 = { + 'region': self.region_name, + 'batch': 'yes' if self.batch else 'no', + } + + # Append our headers into our parameters + params.update({'+{}'.format(k): v for k, v in self.headers.items()}) + + # Append our template tokens into our parameters + params.update({':{}'.format(k): v for k, v in self.tokens.items()}) + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + if self.from_name is not None: + # from_name specified; pass it back on the url + params['name'] = self.from_name + + if self.cc: + # Handle our Carbon Copy Addresses + params['cc'] = ','.join( + ['{}{}'.format( + '' if not e not in self.names + else '{}:'.format(self.names[e]), e) for e in self.cc]) + + if self.bcc: + # Handle our Blind Carbon Copy Addresses + params['bcc'] = ','.join(self.bcc) + + # a simple boolean check as to whether we display our target emails + # or not + has_targets = \ + not (len(self.targets) == 1 + and self.targets[0][1] == self.from_addr) + + return '{schema}://{user}@{host}/{apikey}/{targets}/?{params}'.format( + schema=self.secure_protocol, + host=self.host, + user=NotifySparkPost.quote(self.user, safe=''), + apikey=self.pprint(self.apikey, privacy, safe=''), + targets='' if not has_targets else '/'.join( + [NotifySparkPost.quote('{}{}'.format( + '' if not e[0] else '{}:'.format(e[0]), e[1]), + safe='') for e in self.targets]), + params=NotifySparkPost.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + batch_size = 1 if not self.batch else self.default_batch_size + targets = len(self.targets) + if batch_size > 1: + targets = int(targets / batch_size) + \ + (1 if targets % batch_size else 0) + + return targets if targets > 0 else 1 + + @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 + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'] = NotifySparkPost.split_path(results['fullpath']) + + # Our very first entry is reserved for our api key + try: + results['apikey'] = results['targets'].pop(0) + + except IndexError: + # We're done - no API Key found + results['apikey'] = None + + if 'name' in results['qsd'] and len(results['qsd']['name']): + # Extract from name to associate with from address + results['from_name'] = \ + NotifySparkPost.unquote(results['qsd']['name']) + + if 'region' in results['qsd'] and len(results['qsd']['region']): + # Extract region + results['region_name'] = \ + NotifySparkPost.unquote(results['qsd']['region']) + + # Handle 'to' email address + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'].append(results['qsd']['to']) + + # Handle Carbon Copy Addresses + if 'cc' in results['qsd'] and len(results['qsd']['cc']): + results['cc'] = results['qsd']['cc'] + + # Handle Blind Carbon Copy Addresses + if 'bcc' in results['qsd'] and len(results['qsd']['bcc']): + results['bcc'] = results['qsd']['bcc'] + + # Add our Meta Headers that the user can provide with their outbound + # emails + results['headers'] = {NotifyBase.unquote(x): NotifyBase.unquote(y) + for x, y in results['qsd+'].items()} + + # Add our template tokens (if defined) + results['tokens'] = {NotifyBase.unquote(x): NotifyBase.unquote(y) + for x, y in results['qsd:'].items()} + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get( + 'batch', NotifySparkPost.template_args['batch']['default'])) + + return results diff --git a/lib/apprise/plugins/NotifySpontit.py b/lib/apprise/plugins/NotifySpontit.py new file mode 100644 index 0000000..4705fc0 --- /dev/null +++ b/lib/apprise/plugins/NotifySpontit.py @@ -0,0 +1,386 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# To use this service you will need a Spontit account from their website +# at https://spontit.com/ +# +# After you have an account created: +# - Visit your profile at https://spontit.com/profile and take note of your +# {username}. It might look something like: user12345678901 +# - Next generate an API key at https://spontit.com/secret_keys. This will +# generate a very long alpha-numeric string we'll refer to as the +# {apikey} + +# The Spontit Syntax is as follows: +# spontit://{username}@{apikey} + +import re +import requests +from json import loads + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import parse_list +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + +# Syntax suggests you use a hashtag '#' to help distinguish we're dealing +# with a channel. +# Secondly we extract the user information only if it's +# specified. If not, we use the user of the person sending the notification +# Finally the channel identifier is detected +CHANNEL_REGEX = re.compile( + r'^\s*(\#|\%23)?((\@|\%40)?(?P[a-z0-9_]+)([/\\]|\%2F))?' + r'(?P[a-z0-9_-]+)\s*$', re.I) + + +class NotifySpontit(NotifyBase): + """ + A wrapper for Spontit Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Spontit' + + # The services URL + service_url = 'https://spontit.com/' + + # All notification requests are secure + secure_protocol = 'spontit' + + # Allow 300 requests per minute. + # 60/300 = 0.2 + request_rate_per_sec = 0.20 + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_spontit' + + # Spontit single notification URL + notify_url = 'https://api.spontit.com/v3/push' + + # The maximum length of the body + body_maxlen = 5000 + + # The maximum length of the title + title_maxlen = 100 + + # If we don't have the specified min length, then we don't bother using + # the body directive + spontit_body_minlen = 100 + + # Subtitle support; this is the maximum allowed characters defined by + # the API page + spontit_subtitle_maxlen = 20 + + # Define object templates + templates = ( + '{schema}://{user}@{apikey}', + '{schema}://{user}@{apikey}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'user': { + 'name': _('User ID'), + 'type': 'string', + 'required': True, + 'regex': (r'^[a-z0-9_-]+$', 'i'), + }, + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'required': True, + 'private': True, + 'regex': (r'^[a-z0-9]+$', 'i'), + }, + # Target Channel ID's + # If a slash is used; you must escape it + # If no slash is used; channel is presumed to be your own + 'target_channel': { + 'name': _('Target Channel ID'), + 'type': 'string', + 'prefix': '#', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'subtitle': { + # Subtitle is available for MacOS users + 'name': _('Subtitle'), + 'type': 'string', + }, + }) + + def __init__(self, apikey, targets=None, subtitle=None, **kwargs): + """ + Initialize Spontit Object + """ + super().__init__(**kwargs) + + # User ID (associated with project) + user = validate_regex( + self.user, *self.template_tokens['user']['regex']) + if not user: + msg = 'An invalid Spontit User ID ' \ + '({}) was specified.'.format(self.user) + self.logger.warning(msg) + raise TypeError(msg) + # use cleaned up version + self.user = user + + # API Key (associated with project) + self.apikey = validate_regex( + apikey, *self.template_tokens['apikey']['regex']) + if not self.apikey: + msg = 'An invalid Spontit API Key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # Save our subtitle information + self.subtitle = subtitle + + # Parse our targets + self.targets = list() + + for target in parse_list(targets): + # Validate targets and drop bad ones: + result = CHANNEL_REGEX.match(target) + if result: + # Just extract the channel + self.targets.append( + '{}'.format(result.group('channel'))) + continue + + self.logger.warning( + 'Dropped invalid channel/user ({}) specified.'.format(target)) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Sends Message + """ + + # error tracking (used for function return) + has_error = False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + 'X-Authorization': self.apikey, + 'X-UserId': self.user, + } + + # use the list directly + targets = list(self.targets) + + if not len(targets): + # The user did not specify a channel and therefore wants to notify + # the main account only. We just set a substitute marker of + # None so that our while loop below can still process one iteration + targets = [None, ] + + while len(targets): + # Get our target(s) to notify + target = targets.pop(0) + + # Prepare our payload + payload = { + 'message': body, + } + + # Use our body directive if we exceed the minimum message + # limitation + if len(body) > self.spontit_body_minlen: + payload['message'] = '{}...'.format( + body[:self.spontit_body_minlen - 3]) + payload['body'] = body + + if self.subtitle: + # Set title if specified + payload['subtitle'] = \ + self.subtitle[:self.spontit_subtitle_maxlen] + + elif self.app_desc: + # fall back to app description + payload['subtitle'] = \ + self.app_desc[:self.spontit_subtitle_maxlen] + + elif self.app_id: + # fall back to app id + payload['subtitle'] = \ + self.app_id[:self.spontit_subtitle_maxlen] + + if title: + # Set title if specified + payload['pushTitle'] = title + + if target is not None: + payload['channelName'] = target + + # Some Debug Logging + self.logger.debug( + 'Spontit POST URL: {} (cert_verify={})'.format( + self.notify_url, self.verify_certificate)) + self.logger.debug('Spontit Payload: {}' .format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + self.notify_url, + params=payload, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + if r.status_code not in ( + requests.codes.created, requests.codes.ok): + status_str = \ + NotifyBase.http_response_code_lookup( + r.status_code) + + try: + # Update our status response if we can + json_response = loads(r.content) + status_str = json_response.get('message', status_str) + + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + + # We could not parse JSON response. + # We will just use the status we already have. + pass + + self.logger.warning( + 'Failed to send Spontit notification to {}: ' + '{}{}error={}.'.format( + target, + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + # If we reach here; the message was sent + self.logger.info( + 'Sent Spontit notification to {}.'.format(target)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Spontit:%s ' % ( + ', '.join(self.targets)) + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + # Mark our failure + has_error = True + continue + + return not has_error + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Our URL parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + if self.subtitle: + params['subtitle'] = self.subtitle + + return '{schema}://{userid}@{apikey}/{targets}?{params}'.format( + schema=self.secure_protocol, + userid=self.user, + apikey=self.pprint(self.apikey, privacy, safe=''), + targets='/'.join( + [NotifySpontit.quote(x, safe='') for x in self.targets]), + params=NotifySpontit.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets > 0 else 1 + + @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 + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'] = NotifySpontit.split_path(results['fullpath']) + + # The hostname is our authentication key + results['apikey'] = NotifySpontit.unquote(results['host']) + + # Support MacOS subtitle option + if 'subtitle' in results['qsd'] and len(results['qsd']['subtitle']): + results['subtitle'] = \ + NotifySpontit.unquote(results['qsd']['subtitle']) + + # Support the 'to' variable so that we can support targets this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifySpontit.parse_list(results['qsd']['to']) + + return results diff --git a/lib/apprise/plugins/NotifyStreamlabs.py b/lib/apprise/plugins/NotifyStreamlabs.py new file mode 100644 index 0000000..56b577e --- /dev/null +++ b/lib/apprise/plugins/NotifyStreamlabs.py @@ -0,0 +1,469 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# For this to work correctly you need to register an app +# and generate an access token +# +# +# This plugin will simply work using the url of: +# streamlabs://access_token/ +# +# API Documentation on Webhooks: +# - https://dev.streamlabs.com/ +# +import requests + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +# calls +class StrmlabsCall: + ALERT = 'ALERTS' + DONATION = 'DONATIONS' + + +# A List of calls we can use for verification +STRMLABS_CALLS = ( + StrmlabsCall.ALERT, + StrmlabsCall.DONATION, +) + + +# alerts +class StrmlabsAlert: + FOLLOW = 'follow' + SUBSCRIPTION = 'subscription' + DONATION = 'donation' + HOST = 'host' + + +# A List of calls we can use for verification +STRMLABS_ALERTS = ( + StrmlabsAlert.FOLLOW, + StrmlabsAlert.SUBSCRIPTION, + StrmlabsAlert.DONATION, + StrmlabsAlert.HOST, +) + + +class NotifyStreamlabs(NotifyBase): + """ + A wrapper to Streamlabs Donation Notifications + + """ + # The default descriptive name associated with the Notification + service_name = 'Streamlabs' + + # The services URL + service_url = 'https://streamlabs.com/' + + # The default secure protocol + secure_protocol = 'strmlabs' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_streamlabs' + + # Streamlabs Api endpoint + notify_url = 'https://streamlabs.com/api/v1.0/' + + # The maximum allowable characters allowed in the body per message + body_maxlen = 255 + + # Define object templates + templates = ( + '{schema}://{access_token}/', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'access_token': { + 'name': _('Access Token'), + 'private': True, + 'required': True, + 'type': 'string', + 'regex': (r'^[a-z0-9]{40}$', 'i') + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'call': { + 'name': _('Call'), + 'type': 'choice:string', + 'values': STRMLABS_CALLS, + 'default': StrmlabsCall.ALERT, + }, + 'alert_type': { + 'name': _('Alert Type'), + 'type': 'choice:string', + 'values': STRMLABS_ALERTS, + 'default': StrmlabsAlert.DONATION, + }, + 'image_href': { + 'name': _('Image Link'), + 'type': 'string', + 'default': '', + }, + 'sound_href': { + 'name': _('Sound Link'), + 'type': 'string', + 'default': '', + }, + 'duration': { + 'name': _('Duration'), + 'type': 'int', + 'default': 1000, + 'min': 0 + }, + 'special_text_color': { + 'name': _('Special Text Color'), + 'type': 'string', + 'default': '', + 'regex': (r'^[A-Z]$', 'i'), + }, + 'amount': { + 'name': _('Amount'), + 'type': 'int', + 'default': 0, + 'min': 0 + }, + 'currency': { + 'name': _('Currency'), + 'type': 'string', + 'default': 'USD', + 'regex': (r'^[A-Z]{3}$', 'i'), + }, + 'name': { + 'name': _('Name'), + 'type': 'string', + 'default': 'Anon', + 'regex': (r'^[^\s].{1,24}$', 'i') + }, + 'identifier': { + 'name': _('Identifier'), + 'type': 'string', + 'default': 'Apprise', + }, + }) + + def __init__(self, access_token, + call=StrmlabsCall.ALERT, + alert_type=StrmlabsAlert.DONATION, + image_href='', sound_href='', duration=1000, + special_text_color='', + amount=0, currency='USD', name='Anon', + identifier='Apprise', + **kwargs): + """ + Initialize Streamlabs Object + + """ + super().__init__(**kwargs) + + # access token is generated by user + # using https://streamlabs.com/api/v1.0/token + # Tokens for Streamlabs never need to be refreshed. + self.access_token = validate_regex( + access_token, + *self.template_tokens['access_token']['regex'] + ) + if not self.access_token: + msg = 'An invalid Streamslabs access token was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + # Store the call + try: + if call not in STRMLABS_CALLS: + # allow the outer except to handle this common response + raise + else: + self.call = call + except Exception as e: + # Invalid region specified + msg = 'The streamlabs call specified ({}) is invalid.' \ + .format(call) + self.logger.warning(msg) + self.logger.debug('Socket Exception: %s' % str(e)) + raise TypeError(msg) + + # Store the alert_type + # only applicable when calling /alerts + try: + if alert_type not in STRMLABS_ALERTS: + # allow the outer except to handle this common response + raise + else: + self.alert_type = alert_type + except Exception as e: + # Invalid region specified + msg = 'The streamlabs alert type specified ({}) is invalid.' \ + .format(call) + self.logger.warning(msg) + self.logger.debug('Socket Exception: %s' % str(e)) + raise TypeError(msg) + + # params only applicable when calling /alerts + self.image_href = image_href + self.sound_href = sound_href + self.duration = duration + self.special_text_color = special_text_color + + # only applicable when calling /donations + # The amount of this donation. + self.amount = amount + + # only applicable when calling /donations + # The 3 letter currency code for this donation. + # Must be one of the supported currency codes. + self.currency = validate_regex( + currency, + *self.template_args['currency']['regex'] + ) + + # only applicable when calling /donations + if not self.currency: + msg = 'An invalid Streamslabs currency was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + # only applicable when calling /donations + # The name of the donor + self.name = validate_regex( + name, + *self.template_args['name']['regex'] + ) + if not self.name: + msg = 'An invalid Streamslabs donor was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + # An identifier for this donor, + # which is used to group donations with the same donor. + # only applicable when calling /donations + self.identifier = identifier + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Streamlabs notification call (either donation or alert) + """ + + headers = { + 'User-Agent': self.app_id, + } + if self.call == StrmlabsCall.ALERT: + + data = { + 'access_token': self.access_token, + 'type': self.alert_type.lower(), + 'image_href': self.image_href, + 'sound_href': self.sound_href, + 'message': title, + 'user_massage': body, + 'duration': self.duration, + 'special_text_color': self.special_text_color, + } + + try: + r = requests.post( + self.notify_url + self.call.lower(), + headers=headers, + data=data, + verify=self.verify_certificate, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyStreamlabs.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send Streamlabs alert: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + return False + + else: + self.logger.info('Sent Streamlabs alert.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending Streamlabs ' + 'alert.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + if self.call == StrmlabsCall.DONATION: + data = { + 'name': self.name, + 'identifier': self.identifier, + 'amount': self.amount, + 'currency': self.currency, + 'access_token': self.access_token, + 'message': body, + } + + try: + r = requests.post( + self.notify_url + self.call.lower(), + headers=headers, + data=data, + verify=self.verify_certificate, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyStreamlabs.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send Streamlabs donation: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + return False + + else: + self.logger.info('Sent Streamlabs donation.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending Streamlabs ' + 'donation.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'call': self.call, + # donation + 'name': self.name, + 'identifier': self.identifier, + 'amount': self.amount, + 'currency': self.currency, + # alert + 'alert_type': self.alert_type, + 'image_href': self.image_href, + 'sound_href': self.sound_href, + 'duration': self.duration, + 'special_text_color': self.special_text_color, + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + return '{schema}://{access_token}/?{params}'.format( + schema=self.secure_protocol, + access_token=self.pprint(self.access_token, privacy, safe=''), + params=NotifyStreamlabs.urlencode(params), + ) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + Syntax: + strmlabs://access_token + + """ + 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 access code + access_token = NotifyStreamlabs.unquote(results['host']) + results['access_token'] = access_token + + # call + if 'call' in results['qsd'] and results['qsd']['call']: + results['call'] = NotifyStreamlabs.unquote( + results['qsd']['call'].strip().upper()) + # donation - amount + if 'amount' in results['qsd'] and results['qsd']['amount']: + results['amount'] = NotifyStreamlabs.unquote( + results['qsd']['amount']) + # donation - currency + if 'currency' in results['qsd'] and results['qsd']['currency']: + results['currency'] = NotifyStreamlabs.unquote( + results['qsd']['currency'].strip().upper()) + # donation - name + if 'name' in results['qsd'] and results['qsd']['name']: + results['name'] = NotifyStreamlabs.unquote( + results['qsd']['name'].strip().upper()) + # donation - identifier + if 'identifier' in results['qsd'] and results['qsd']['identifier']: + results['identifier'] = NotifyStreamlabs.unquote( + results['qsd']['identifier'].strip().upper()) + # alert - alert_type + if 'alert_type' in results['qsd'] and results['qsd']['alert_type']: + results['alert_type'] = NotifyStreamlabs.unquote( + results['qsd']['alert_type']) + # alert - image_href + if 'image_href' in results['qsd'] and results['qsd']['image_href']: + results['image_href'] = NotifyStreamlabs.unquote( + results['qsd']['image_href']) + # alert - sound_href + if 'sound_href' in results['qsd'] and results['qsd']['sound_href']: + results['sound_href'] = NotifyStreamlabs.unquote( + results['qsd']['sound_href'].strip().upper()) + # alert - duration + if 'duration' in results['qsd'] and results['qsd']['duration']: + results['duration'] = NotifyStreamlabs.unquote( + results['qsd']['duration'].strip().upper()) + # alert - special_text_color + if 'special_text_color' in results['qsd'] \ + and results['qsd']['special_text_color']: + results['special_text_color'] = NotifyStreamlabs.unquote( + results['qsd']['special_text_color'].strip().upper()) + + return results diff --git a/lib/apprise/plugins/NotifySyslog.py b/lib/apprise/plugins/NotifySyslog.py new file mode 100644 index 0000000..3ff1f25 --- /dev/null +++ b/lib/apprise/plugins/NotifySyslog.py @@ -0,0 +1,323 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 syslog + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + + +class SyslogFacility: + """ + All of the supported facilities + """ + KERN = 'kern' + USER = 'user' + MAIL = 'mail' + DAEMON = 'daemon' + AUTH = 'auth' + SYSLOG = 'syslog' + LPR = 'lpr' + NEWS = 'news' + UUCP = 'uucp' + CRON = 'cron' + LOCAL0 = 'local0' + LOCAL1 = 'local1' + LOCAL2 = 'local2' + LOCAL3 = 'local3' + LOCAL4 = 'local4' + LOCAL5 = 'local5' + LOCAL6 = 'local6' + LOCAL7 = 'local7' + + +SYSLOG_FACILITY_MAP = { + SyslogFacility.KERN: syslog.LOG_KERN, + SyslogFacility.USER: syslog.LOG_USER, + SyslogFacility.MAIL: syslog.LOG_MAIL, + SyslogFacility.DAEMON: syslog.LOG_DAEMON, + SyslogFacility.AUTH: syslog.LOG_AUTH, + SyslogFacility.SYSLOG: syslog.LOG_SYSLOG, + SyslogFacility.LPR: syslog.LOG_LPR, + SyslogFacility.NEWS: syslog.LOG_NEWS, + SyslogFacility.UUCP: syslog.LOG_UUCP, + SyslogFacility.CRON: syslog.LOG_CRON, + SyslogFacility.LOCAL0: syslog.LOG_LOCAL0, + SyslogFacility.LOCAL1: syslog.LOG_LOCAL1, + SyslogFacility.LOCAL2: syslog.LOG_LOCAL2, + SyslogFacility.LOCAL3: syslog.LOG_LOCAL3, + SyslogFacility.LOCAL4: syslog.LOG_LOCAL4, + SyslogFacility.LOCAL5: syslog.LOG_LOCAL5, + SyslogFacility.LOCAL6: syslog.LOG_LOCAL6, + SyslogFacility.LOCAL7: syslog.LOG_LOCAL7, +} + +SYSLOG_FACILITY_RMAP = { + syslog.LOG_KERN: SyslogFacility.KERN, + syslog.LOG_USER: SyslogFacility.USER, + syslog.LOG_MAIL: SyslogFacility.MAIL, + syslog.LOG_DAEMON: SyslogFacility.DAEMON, + syslog.LOG_AUTH: SyslogFacility.AUTH, + syslog.LOG_SYSLOG: SyslogFacility.SYSLOG, + syslog.LOG_LPR: SyslogFacility.LPR, + syslog.LOG_NEWS: SyslogFacility.NEWS, + syslog.LOG_UUCP: SyslogFacility.UUCP, + syslog.LOG_CRON: SyslogFacility.CRON, + syslog.LOG_LOCAL0: SyslogFacility.LOCAL0, + syslog.LOG_LOCAL1: SyslogFacility.LOCAL1, + syslog.LOG_LOCAL2: SyslogFacility.LOCAL2, + syslog.LOG_LOCAL3: SyslogFacility.LOCAL3, + syslog.LOG_LOCAL4: SyslogFacility.LOCAL4, + syslog.LOG_LOCAL5: SyslogFacility.LOCAL5, + syslog.LOG_LOCAL6: SyslogFacility.LOCAL6, + syslog.LOG_LOCAL7: SyslogFacility.LOCAL7, +} + +# Used as a lookup when handling the Apprise -> Syslog Mapping +SYSLOG_PUBLISH_MAP = { + NotifyType.INFO: syslog.LOG_INFO, + NotifyType.SUCCESS: syslog.LOG_NOTICE, + NotifyType.FAILURE: syslog.LOG_CRIT, + NotifyType.WARNING: syslog.LOG_WARNING, +} + + +class NotifySyslog(NotifyBase): + """ + A wrapper for Syslog Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Syslog' + + # The services URL + service_url = 'https://tools.ietf.org/html/rfc5424' + + # The default protocol + protocol = 'syslog' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_syslog' + + # Disable throttle rate for Syslog requests since they are normally + # local anyway + request_rate_per_sec = 0 + + # Define object templates + templates = ( + '{schema}://', + '{schema}://{facility}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'facility': { + 'name': _('Facility'), + 'type': 'choice:string', + 'values': [k for k in SYSLOG_FACILITY_MAP.keys()], + 'default': SyslogFacility.USER, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'facility': { + # We map back to the same element defined in template_tokens + 'alias_of': 'facility', + }, + 'logpid': { + 'name': _('Log PID'), + 'type': 'bool', + 'default': True, + 'map_to': 'log_pid', + }, + 'logperror': { + 'name': _('Log to STDERR'), + 'type': 'bool', + 'default': False, + 'map_to': 'log_perror', + }, + }) + + def __init__(self, facility=None, log_pid=True, log_perror=False, + **kwargs): + """ + Initialize Syslog Object + """ + super().__init__(**kwargs) + + if facility: + try: + self.facility = SYSLOG_FACILITY_MAP[facility] + + except KeyError: + msg = 'An invalid syslog facility ' \ + '({}) was specified.'.format(facility) + self.logger.warning(msg) + raise TypeError(msg) + else: + self.facility = \ + SYSLOG_FACILITY_MAP[ + self.template_tokens['facility']['default']] + + # Logging Options + self.logoptions = 0 + + # Include PID with each message. + # This may not appear evident if using journalctl since the pid + # will always display itself; however it will appear visible + # for log_perror combinations + self.log_pid = log_pid + + # Print to stderr as well. + self.log_perror = log_perror + + if log_pid: + self.logoptions |= syslog.LOG_PID + + if log_perror: + self.logoptions |= syslog.LOG_PERROR + + # Initialize our logging + syslog.openlog( + self.app_id, logoption=self.logoptions, facility=self.facility) + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Syslog Notification + """ + + SYSLOG_PUBLISH_MAP = { + NotifyType.INFO: syslog.LOG_INFO, + NotifyType.SUCCESS: syslog.LOG_NOTICE, + NotifyType.FAILURE: syslog.LOG_CRIT, + NotifyType.WARNING: syslog.LOG_WARNING, + } + + if title: + # Format title + body = '{}: {}'.format(title, body) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + syslog.syslog(SYSLOG_PUBLISH_MAP[notify_type], body) + + except KeyError: + # An invalid notification type was specified + self.logger.warning( + 'An invalid notification type ' + '({}) was specified.'.format(notify_type)) + return False + + self.logger.info('Sent Syslog notification.') + + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'logperror': 'yes' if self.log_perror else 'no', + 'logpid': 'yes' if self.log_pid else 'no', + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{facility}/?{params}'.format( + facility=self.template_tokens['facility']['default'] + if self.facility not in SYSLOG_FACILITY_RMAP + else SYSLOG_FACILITY_RMAP[self.facility], + schema=self.protocol, + params=NotifySyslog.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, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + tokens = [] + if results['host']: + tokens.append(NotifySyslog.unquote(results['host'])) + + # Get our path values + tokens.extend(NotifySyslog.split_path(results['fullpath'])) + + # Initialization + facility = None + + if tokens: + # Store the last entry as the facility + facility = tokens[-1].lower() + + # However if specified on the URL, that will over-ride what was + # identified + if 'facility' in results['qsd'] and len(results['qsd']['facility']): + facility = results['qsd']['facility'].lower() + + if facility and facility not in SYSLOG_FACILITY_MAP: + # Find first match; if no match is found we set the result + # to the matching key. This allows us to throw a TypeError + # during the __init__() call. The benifit of doing this + # check here is if we do have a valid match, we can support + # short form matches like 'u' which will match against user + facility = next((f for f in SYSLOG_FACILITY_MAP.keys() + if f.startswith(facility)), facility) + + # Save facility if set + if facility: + results['facility'] = facility + + # Include PID as part of the message logged + results['log_pid'] = parse_bool( + results['qsd'].get( + 'logpid', + NotifySyslog.template_args['logpid']['default'])) + + # Print to stderr as well. + results['log_perror'] = parse_bool( + results['qsd'].get( + 'logperror', + NotifySyslog.template_args['logperror']['default'])) + + return results diff --git a/lib/apprise/plugins/NotifyTechulusPush.py b/lib/apprise/plugins/NotifyTechulusPush.py new file mode 100644 index 0000000..3e2085c --- /dev/null +++ b/lib/apprise/plugins/NotifyTechulusPush.py @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# To use this plugin, you need to download the app +# - Apple: https://itunes.apple.com/us/app/\ +# push-by-techulus/id1444391917?ls=1&mt=8 +# - Android: https://play.google.com/store/apps/\ +# details?id=com.techulus.push +# +# You have to sign up through the account via your mobile device. +# +# Once you've got your account, you can get your API key from here: +# https://push.techulus.com/login.html +# +# You can also just get the {apikey} right out of the phone app that is +# installed. +# +# your {apikey} will look something like: +# b444a40f-3db9-4224-b489-9a514c41c009 +# +# You will need to assemble all of your URLs for this plugin to work as: +# push://{apikey} +# +# Resources +# - https://push.techulus.com/ - Main Website +# - https://pushtechulus.docs.apiary.io - API Documentation + +import requests +from json import dumps + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + +# Token required as part of the API request +# Used to prepare our UUID regex matching +UUID4_RE = \ + r'[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}' + + +class NotifyTechulusPush(NotifyBase): + """ + A wrapper for Techulus Push Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Techulus Push' + + # The services URL + service_url = 'https://push.techulus.com' + + # The default secure protocol + secure_protocol = 'push' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_techulus' + + # Techulus Push uses the http protocol with JSON requests + notify_url = 'https://push.techulus.com/api/v1/notify' + + # The maximum allowable characters allowed in the body per message + body_maxlen = 1000 + + # Define object templates + templates = ( + '{schema}://{apikey}', + ) + + # Define our template apikeys + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^{}$'.format(UUID4_RE), 'i'), + }, + }) + + def __init__(self, apikey, **kwargs): + """ + Initialize Techulus Push Object + """ + super().__init__(**kwargs) + + # The apikey associated with the account + self.apikey = validate_regex( + apikey, *self.template_tokens['apikey']['regex']) + if not self.apikey: + msg = 'An invalid Techulus Push API key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Techulus Push Notification + """ + + # Setup our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + 'x-api-key': self.apikey, + } + + payload = { + 'title': title, + 'body': body, + } + + self.logger.debug('Techulus Push POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('Techulus Push Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + self.notify_url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code not in ( + requests.codes.ok, requests.codes.no_content): + # We had a problem + status_str = \ + NotifyTechulusPush.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send Techulus Push notification: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + return False + + else: + self.logger.info( + 'Sent Techulus Push notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Techulus Push ' + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + return False + + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Our URL parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + return '{schema}://{apikey}/?{params}'.format( + schema=self.secure_protocol, + apikey=self.pprint(self.apikey, privacy, safe=''), + params=NotifyTechulusPush.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, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + # The first apikey is stored in the hostname + results['apikey'] = NotifyTechulusPush.unquote(results['host']) + + return results diff --git a/lib/apprise/plugins/NotifyTelegram.py b/lib/apprise/plugins/NotifyTelegram.py new file mode 100644 index 0000000..1727fe8 --- /dev/null +++ b/lib/apprise/plugins/NotifyTelegram.py @@ -0,0 +1,992 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# To use this plugin, you need to first access https://api.telegram.org +# You need to create a bot and acquire it's Token Identifier (bot_token) +# +# Basically you need to create a chat with a user called the 'BotFather' +# and type: /newbot +# +# Then follow through the wizard, it will provide you an api key +# that looks like this:123456789:alphanumeri_characters +# +# For each chat_id a bot joins will have a chat_id associated with it. +# You will need this value as well to send the notification. +# +# Log into the webpage version of the site if you like by accessing: +# https://web.telegram.org +# +# You can't check out to see if your entry is working using: +# https://api.telegram.org/botAPI_KEY/getMe +# +# Pay attention to the word 'bot' that must be present infront of your +# api key that the BotFather gave you. +# +# For example, a url might look like this: +# https://api.telegram.org/bot123456789:alphanumeric_characters/getMe +# +# Development API Reference:: +# - https://core.telegram.org/bots/api +import requests +import re +import os + +from json import loads +from json import dumps + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..common import NotifyImageSize +from ..common import NotifyFormat +from ..utils import parse_bool +from ..utils import parse_list +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ +from ..attachment.AttachBase import AttachBase + +TELEGRAM_IMAGE_XY = NotifyImageSize.XY_256 + +# Chat ID is required +# If the Chat ID is positive, then it's addressed to a single person +# If the Chat ID is negative, then it's targeting a group +IS_CHAT_ID_RE = re.compile( + r'^(@*(?P-?[0-9]{1,32})|(?P[a-z_-][a-z0-9_-]+))$', + re.IGNORECASE, +) + + +class TelegramContentPlacement: + """ + The Telegram Content Placement + """ + # Before Attachments + BEFORE = "before" + # After Attachments + AFTER = "after" + + +# Identify Placement Categories +TELEGRAM_CONTENT_PLACEMENT = ( + TelegramContentPlacement.BEFORE, + TelegramContentPlacement.AFTER, +) + + +class NotifyTelegram(NotifyBase): + """ + A wrapper for Telegram Notifications + """ + # The default descriptive name associated with the Notification + service_name = 'Telegram' + + # The services URL + service_url = 'https://telegram.org/' + + # The default secure protocol + secure_protocol = 'tgram' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_telegram' + + # Default Notify Format + notify_format = NotifyFormat.HTML + + # Telegram uses the http protocol with JSON requests + notify_url = 'https://api.telegram.org/bot' + + # Support attachments + attachment_support = True + + # Allows the user to specify the NotifyImageSize object + image_size = NotifyImageSize.XY_256 + + # The maximum allowable characters allowed in the body per message + body_maxlen = 4096 + + # Title is to be part of body + title_maxlen = 0 + + # Telegram is limited to sending a maximum of 100 requests per second. + request_rate_per_sec = 0.001 + + # Define object templates + templates = ( + '{schema}://{bot_token}', + '{schema}://{bot_token}/{targets}', + ) + + # Telegram Attachment Support + mime_lookup = ( + # This list is intentionally ordered so that it can be scanned + # from top to bottom. The last entry is a catch-all + + # Animations are documented to only support gif or H.264/MPEG-4 + # Source: https://core.telegram.org/bots/api#sendanimation + { + 'regex': re.compile(r'^(image/gif|video/H264)', re.I), + 'function_name': 'sendAnimation', + 'key': 'animation', + }, + + # This entry is intentially placed below the sendAnimiation allowing + # it to catch gif files. This then becomes a catch all to remaining + # image types. + # Source: https://core.telegram.org/bots/api#sendphoto + { + 'regex': re.compile(r'^image/.*', re.I), + 'function_name': 'sendPhoto', + 'key': 'photo', + }, + + # Video is documented to only support .mp4 + # Source: https://core.telegram.org/bots/api#sendvideo + { + 'regex': re.compile(r'^video/mp4', re.I), + 'function_name': 'sendVideo', + 'key': 'video', + }, + + # Voice supports ogg + # Source: https://core.telegram.org/bots/api#sendvoice + { + 'regex': re.compile(r'^(application|audio)/ogg', re.I), + 'function_name': 'sendVoice', + 'key': 'voice', + }, + + # Audio supports mp3 and m4a only + # Source: https://core.telegram.org/bots/api#sendaudio + { + 'regex': re.compile(r'^audio/(mpeg|mp4a-latm)', re.I), + 'function_name': 'sendAudio', + 'key': 'audio', + }, + + # Catch All (all other types) + # Source: https://core.telegram.org/bots/api#senddocument + { + 'regex': re.compile(r'.*', re.I), + 'function_name': 'sendDocument', + 'key': 'document', + }, + ) + + # Telegram's HTML support doesn't like having HTML escaped + # characters passed into it. to handle this situation, we need to + # search the body for these sequences and convert them to the + # output the user expected + __telegram_escape_html_entries = ( + # Comments + (re.compile( + r'\s*\s*', + (re.I | re.M | re.S)), '', {}), + + # the following tags are not supported + (re.compile( + r'\s*<\s*(!?DOCTYPE|p|div|span|body|script|link|' + r'meta|html|font|head|label|form|input|textarea|select|iframe|' + r'source|script)([^a-z0-9>][^>]*)?>\s*', + (re.I | re.M | re.S)), '', {}), + + # All closing tags to be removed are put here + (re.compile( + r'\s*<\s*/(span|body|script|meta|html|font|head|' + r'label|form|input|textarea|select|ol|ul|link|' + r'iframe|source|script)([^a-z0-9>][^>]*)?>\s*', + (re.I | re.M | re.S)), '', {}), + + # Bold + (re.compile( + r'<\s*(strong)([^a-z0-9>][^>]*)?>', + (re.I | re.M | re.S)), '', {}), + (re.compile( + r'<\s*/\s*(strong)([^a-z0-9>][^>]*)?>', + (re.I | re.M | re.S)), '', {}), + (re.compile( + r'\s*<\s*(h[1-6]|title)([^a-z0-9>][^>]*)?>\s*', + (re.I | re.M | re.S)), '{}', {'html': '\r\n'}), + (re.compile( + r'\s*<\s*/\s*(h[1-6]|title)([^a-z0-9>][^>]*)?>\s*', + (re.I | re.M | re.S)), + '{}', {'html': '
'}), + + # Italic + (re.compile( + r'<\s*(caption|em)([^a-z0-9>][^>]*)?>', + (re.I | re.M | re.S)), '', {}), + (re.compile( + r'<\s*/\s*(caption|em)([^a-z0-9>][^>]*)?>', + (re.I | re.M | re.S)), '', {}), + + # Bullet Lists + (re.compile( + r'<\s*li([^a-z0-9>][^>]*)?>\s*', + (re.I | re.M | re.S)), ' -', {}), + + # convert pre tags to code (supported by Telegram) + (re.compile( + r'<\s*pre([^a-z0-9>][^>]*)?>', + (re.I | re.M | re.S)), '{}', {'html': '\r\n'}), + (re.compile( + r'<\s*/\s*pre([^a-z0-9>][^>]*)?>', + (re.I | re.M | re.S)), '{}', {'html': '\r\n'}), + + # New Lines + (re.compile( + r'\s*<\s*/?\s*(ol|ul|br|hr)\s*/?>\s*', + (re.I | re.M | re.S)), '\r\n', {}), + (re.compile( + r'\s*<\s*/\s*(br|p|hr|li|div)([^a-z0-9>][^>]*)?>\s*', + (re.I | re.M | re.S)), '\r\n', {}), + + # HTML Spaces ( ) and tabs ( ) aren't supported + # See https://core.telegram.org/bots/api#html-style + (re.compile(r'\ ?', re.I), ' ', {}), + + # Tabs become 3 spaces + (re.compile(r'\ ?', re.I), ' ', {}), + + # Some characters get re-escaped by the Telegram upstream + # service so we need to convert these back, + (re.compile(r'\'?', re.I), '\'', {}), + (re.compile(r'\"?', re.I), '"', {}), + + # New line cleanup + (re.compile(r'\r*\n[\r\n]+', re.I), '\r\n', {}), + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'bot_token': { + 'name': _('Bot Token'), + 'type': 'string', + 'private': True, + 'required': True, + # Token required as part of the API request, allow the word 'bot' + # infront of it + 'regex': (r'^(bot)?(?P[0-9]+:[a-z0-9_-]+)$', 'i'), + }, + 'target_user': { + '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': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': False, + 'map_to': 'include_image', + }, + 'detect': { + 'name': _('Detect Bot Owner'), + 'type': 'bool', + 'default': True, + 'map_to': 'detect_owner', + }, + 'silent': { + 'name': _('Silent Notification'), + 'type': 'bool', + 'default': False, + }, + 'preview': { + 'name': _('Web Page Preview'), + 'type': 'bool', + 'default': False, + }, + 'topic': { + 'name': _('Topic Thread ID'), + 'type': 'int', + }, + 'to': { + 'alias_of': 'targets', + }, + 'content': { + 'name': _('Content Placement'), + 'type': 'choice:string', + 'values': TELEGRAM_CONTENT_PLACEMENT, + 'default': TelegramContentPlacement.BEFORE, + }, + }) + + def __init__(self, bot_token, targets, detect_owner=True, + include_image=False, silent=None, preview=None, topic=None, + content=None, **kwargs): + """ + Initialize Telegram Object + """ + super().__init__(**kwargs) + + self.bot_token = validate_regex( + bot_token, *self.template_tokens['bot_token']['regex'], + fmt='{key}') + if not self.bot_token: + err = 'The Telegram Bot Token specified ({}) is invalid.'.format( + bot_token) + self.logger.warning(err) + raise TypeError(err) + + # Parse our list + self.targets = parse_list(targets) + + # Define whether or not we should make audible alarms + self.silent = self.template_args['silent']['default'] \ + if silent is None else bool(silent) + + # Define whether or not we should display a web page preview + self.preview = self.template_args['preview']['default'] \ + if preview is None else bool(preview) + + # Setup our content placement + self.content = self.template_args['content']['default'] \ + if not isinstance(content, str) else content.lower() + if self.content and self.content not in TELEGRAM_CONTENT_PLACEMENT: + msg = 'The content placement specified ({}) is invalid.'\ + .format(content) + self.logger.warning(msg) + raise TypeError(msg) + + if topic: + try: + self.topic = int(topic) + + except (TypeError, ValueError): + # Not a valid integer; ignore entry + err = 'The Telegram Topic ID specified ({}) is invalid.'\ + .format(topic) + self.logger.warning(err) + raise TypeError(err) + else: + # No Topic Thread + self.topic = None + + # if detect_owner is set to True, we will attempt to determine who + # the bot owner is based on the first person who messaged it. This + # is not a fool proof way of doing things as over time Telegram removes + # the message history for the bot. So what appears (later on) to be + # the first message to it, maybe another user who sent it a message + # much later. Users who set this flag should update their Apprise + # URL later to directly include the user that we should message. + self.detect_owner = detect_owner + + if self.user: + # Treat this as a channel too + self.targets.append(self.user) + + # Track whether or not we want to send an image with our notification + # or not. + self.include_image = include_image + + def send_media(self, chat_id, notify_type, attach=None): + """ + Sends a sticker based on the specified notify type + + """ + + # Prepare our Headers + headers = { + 'User-Agent': self.app_id, + } + + # Our function name and payload are determined on the path + function_name = 'SendPhoto' + key = 'photo' + path = None + + if isinstance(attach, AttachBase): + if not attach: + # We could not access the attachment + self.logger.error( + 'Could not access attachment {}.'.format( + attach.url(privacy=True))) + return False + + self.logger.debug( + 'Posting Telegram attachment {}'.format( + attach.url(privacy=True))) + + # Store our path to our file + path = attach.path + file_name = attach.name + mimetype = attach.mimetype + + # Process our attachment + function_name, key = \ + next(((x['function_name'], x['key']) for x in self.mime_lookup + if x['regex'].match(mimetype))) # pragma: no cover + + else: + attach = self.image_path(notify_type) if attach is None else attach + if attach is None: + # Nothing specified to send + return True + + # Take on specified attachent as path + path = attach + file_name = os.path.basename(path) + + url = '%s%s/%s' % ( + self.notify_url, + self.bot_token, + function_name, + ) + + # Always call throttle before any remote server i/o is made; + # Telegram throttles to occur before sending the image so that + # content can arrive together. + self.throttle() + + payload = {'chat_id': chat_id} + if self.topic: + payload['message_thread_id'] = self.topic + + try: + with open(path, 'rb') as f: + # Configure file payload (for upload) + files = {key: (file_name, f)} + + self.logger.debug( + 'Telegram attachment POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate)) + + r = requests.post( + url, + headers=headers, + files=files, + data=payload, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = NotifyTelegram\ + .http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send Telegram attachment: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + return False + + # Content was sent successfully if we got here + return True + + except requests.RequestException as e: + self.logger.warning( + 'A connection error occurred posting Telegram ' + 'attachment.') + self.logger.debug('Socket Exception: %s' % str(e)) + + except (IOError, OSError): + # IOError is present for backwards compatibility with Python + # versions older then 3.3. >= 3.3 throw OSError now. + + # Could not open and/or read the file; this is not a problem since + # we scan a lot of default paths. + self.logger.error( + 'File can not be opened for read: {}'.format(path)) + + return False + + def detect_bot_owner(self): + """ + Takes a bot and attempts to detect it's chat id from that + + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + } + + url = '%s%s/%s' % ( + self.notify_url, + self.bot_token, + 'getUpdates' + ) + + self.logger.debug( + 'Telegram User Detection POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate)) + + # Track our response object + response = None + + try: + r = requests.post( + url, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyTelegram.http_response_code_lookup(r.status_code) + + try: + # Try to get the error message if we can: + error_msg = loads(r.content).get('description', 'unknown') + + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + error_msg = None + + if error_msg: + self.logger.warning( + 'Failed to detect the Telegram user: (%s) %s.' % ( + r.status_code, error_msg)) + + else: + self.logger.warning( + 'Failed to detect the Telegram user: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + return 0 + + # Load our response and attempt to fetch our userid + response = loads(r.content) + + except (AttributeError, TypeError, ValueError): + # Our response was not the JSON type we had expected it to be + # - ValueError = r.content is Unparsable + # - TypeError = r.content is None + # - AttributeError = r is None + self.logger.warning( + 'A communication error occurred detecting the Telegram User.') + return 0 + + except requests.RequestException as e: + self.logger.warning( + 'A connection error occurred detecting the Telegram User.') + self.logger.debug('Socket Exception: %s' % str(e)) + return 0 + + # A Response might look something like this: + # { + # "ok":true, + # "result":[{ + # "update_id":645421321, + # "message":{ + # "message_id":1, + # "from":{ + # "id":532389719, + # "is_bot":false, + # "first_name":"Chris", + # "language_code":"en-US" + # }, + # "chat":{ + # "id":532389719, + # "first_name":"Chris", + # "type":"private" + # }, + # "date":1519694394, + # "text":"/start", + # "entities":[{"offset":0,"length":6,"type":"bot_command"}]}}] + + if response.get('ok', False): + for entry in response.get('result', []): + if 'message' in entry and 'from' in entry['message']: + _id = entry['message']['from'].get('id', 0) + _user = entry['message']['from'].get('first_name') + self.logger.info( + 'Detected Telegram user %s (userid=%d)' % (_user, _id)) + # Return our detected userid + return _id + + self.logger.warning( + 'Failed to detect a Telegram user; ' + 'try sending your bot a message first.') + return 0 + + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + body_format=None, **kwargs): + """ + Perform Telegram Notification + """ + + if len(self.targets) == 0 and self.detect_owner: + _id = self.detect_bot_owner() + if _id: + # Permanently store our id in our target list for next time + self.targets.append(str(_id)) + self.logger.info( + 'Update your Telegram Apprise URL to read: ' + '{}'.format(self.url(privacy=True))) + + if len(self.targets) == 0: + self.logger.warning('There were not Telegram chat_ids to notify.') + return False + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + } + + # error tracking (used for function return) + has_error = False + + url = '%s%s/%s' % ( + self.notify_url, + self.bot_token, + 'sendMessage' + ) + + payload = { + # Notification Audible Control + 'disable_notification': self.silent, + # Display Web Page Preview (if possible) + 'disable_web_page_preview': not self.preview, + } + + if self.topic: + payload['message_thread_id'] = self.topic + + # Prepare Message Body + if self.notify_format == NotifyFormat.MARKDOWN: + payload['parse_mode'] = 'MARKDOWN' + + payload['text'] = body + + else: # HTML + + # Use Telegram's HTML mode + payload['parse_mode'] = 'HTML' + for r, v, m in self.__telegram_escape_html_entries: + + if 'html' in m: + # Handle special cases where we need to alter new lines + # for presentation purposes + v = v.format(m['html'] if body_format in ( + NotifyFormat.HTML, NotifyFormat.MARKDOWN) else '') + + body = r.sub(v, body) + + # Prepare our payload based on HTML or TEXT + payload['text'] = body + + # Handle payloads without a body specified (but an attachment present) + attach_content = \ + TelegramContentPlacement.AFTER if not body else self.content + + # Create a copy of the chat_ids list + targets = list(self.targets) + while len(targets): + chat_id = targets.pop(0) + chat_id = IS_CHAT_ID_RE.match(chat_id) + if not chat_id: + self.logger.warning( + "The specified chat_id '%s' is invalid; skipping." % ( + chat_id, + ) + ) + + # Flag our error + has_error = True + continue + + if chat_id.group('name') is not None: + # Name + payload['chat_id'] = '@%s' % chat_id.group('name') + + else: + # ID + payload['chat_id'] = int(chat_id.group('idno')) + + if self.include_image is True: + # Define our path + if not self.send_media(payload['chat_id'], notify_type): + # We failed to send the image associated with our + notify_type + self.logger.warning( + 'Failed to send Telegram type image to {}.', + payload['chat_id']) + + if attach and self.attachment_support and \ + attach_content == TelegramContentPlacement.AFTER: + # Send our attachments now (if specified and if it exists) + if not self._send_attachments( + chat_id=payload['chat_id'], notify_type=notify_type, + attach=attach): + + has_error = True + continue + + if not body: + # Nothing more to do; move along to the next attachment + continue + + # Always call throttle before any remote server i/o is made; + # Telegram throttles to occur before sending the image so that + # content can arrive together. + self.throttle() + + self.logger.debug('Telegram POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('Telegram Payload: %s' % str(payload)) + + 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 = \ + NotifyTelegram.http_response_code_lookup(r.status_code) + + try: + # Try to get the error message if we can: + error_msg = loads(r.content).get( + 'description', 'unknown') + + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + error_msg = None + + self.logger.warning( + 'Failed to send Telegram notification to {}: ' + '{}, error={}.'.format( + payload['chat_id'], + error_msg if error_msg else status_str, + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Flag our error + has_error = True + continue + + except requests.RequestException as e: + self.logger.warning( + 'A connection error occurred sending Telegram:%s ' % ( + payload['chat_id']) + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Flag our error + has_error = True + continue + + self.logger.info('Sent Telegram notification.') + + if attach and self.attachment_support \ + and attach_content == TelegramContentPlacement.BEFORE: + # Send our attachments now (if specified and if it exists) as + # it was identified to send the content before the attachments + # which is now done. + if not self._send_attachments( + chat_id=payload['chat_id'], + notify_type=notify_type, + attach=attach): + + has_error = True + continue + + return not has_error + + def _send_attachments(self, chat_id, notify_type, attach): + """ + Sends our attachments + """ + has_error = False + # Send our attachments now (if specified and if it exists) + for attachment in attach: + if not self.send_media(chat_id, notify_type, attach=attachment): + + # We failed; don't continue + has_error = True + break + + self.logger.info( + 'Sent Telegram attachment: {}.'.format(attachment)) + + 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': self.include_image, + 'detect': 'yes' if self.detect_owner else 'no', + 'silent': 'yes' if self.silent else 'no', + 'preview': 'yes' if self.preview else 'no', + 'content': self.content, + } + + if self.topic: + params['topic'] = self.topic + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # No need to check the user token because the user automatically gets + # appended into the list of chat ids + return '{schema}://{bot_token}/{targets}/?{params}'.format( + schema=self.secure_protocol, + bot_token=self.pprint(self.bot_token, privacy, safe=''), + targets='/'.join( + [NotifyTelegram.quote('@{}'.format(x)) for x in self.targets]), + params=NotifyTelegram.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.targets) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + # This is a dirty hack; but it's the only work around to tgram:// + # messages since the bot_token has a colon in it. It invalidates a + # normal URL. + + # This hack searches for this bogus URL and corrects it so we can + # properly load it further down. The other alternative is to ask users + # to actually change the colon into a slash (which will work too), but + # it's more likely to cause confusion... So this is the next best thing + # we also check for %3A (incase the URL is encoded) as %3A == : + try: + tgram = re.match( + r'(?P{schema}://)(bot)?(?P([a-z0-9_-]+)' + r'(:[a-z0-9_-]+)?@)?(?P[0-9]+)(:|%3A)+' + r'(?P.*)$'.format( + schema=NotifyTelegram.secure_protocol), url, re.I) + + except (TypeError, AttributeError): + # url is bad; force tgram to be None + tgram = None + + if not tgram: + # Content is simply not parseable + return None + + if tgram.group('prefix'): + # Try again + results = NotifyBase.parse_url('%s%s%s/%s' % ( + tgram.group('protocol'), + tgram.group('prefix'), + tgram.group('btoken_a'), + tgram.group('remaining')), verify_host=False) + + else: + # Try again + results = NotifyBase.parse_url('%s%s/%s' % ( + tgram.group('protocol'), + tgram.group('btoken_a'), + tgram.group('remaining')), verify_host=False) + + # The first token is stored in the hostname + bot_token_a = NotifyTelegram.unquote(results['host']) + + # Get a nice unquoted list of path entries + entries = NotifyTelegram.split_path(results['fullpath']) + + # Now fetch the remaining tokens + bot_token_b = entries.pop(0) + + bot_token = '%s:%s' % (bot_token_a, bot_token_b) + + # Store our chat ids (as these are the remaining entries) + results['targets'] = entries + + # content to be displayed 'before' or 'after' attachments + if 'content' in results['qsd'] and len(results['qsd']['content']): + results['content'] = results['qsd']['content'] + + # 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'] += \ + NotifyTelegram.parse_list(results['qsd']['to']) + + # Store our bot token + results['bot_token'] = bot_token + + # Support Thread Topic + if 'topic' in results['qsd'] and len(results['qsd']['topic']): + results['topic'] = results['qsd']['topic'] + + # Silent (Sends the message Silently); users will receive + # notification with no sound. + results['silent'] = \ + parse_bool(results['qsd'].get('silent', False)) + + # Show Web Page Preview + results['preview'] = \ + parse_bool(results['qsd'].get('preview', False)) + + # Include images with our message + results['include_image'] = \ + parse_bool(results['qsd'].get('image', False)) + + # Include images with our message + results['detect_owner'] = \ + parse_bool(results['qsd'].get('detect', True)) + + return results diff --git a/lib/apprise/plugins/NotifyTwilio.py b/lib/apprise/plugins/NotifyTwilio.py new file mode 100644 index 0000000..ab4c88e --- /dev/null +++ b/lib/apprise/plugins/NotifyTwilio.py @@ -0,0 +1,447 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# To use this service you will need a Twilio account to which you can get your +# AUTH_TOKEN and ACCOUNT SID right from your console/dashboard at: +# https://www.twilio.com/console +# +# You will also need to send the SMS From a phone number or account id name. + +# This is identified as the source (or where the SMS message will originate +# from). Activated phone numbers can be found on your dashboard here: +# - https://www.twilio.com/console/phone-numbers/incoming +# +# Alternatively, you can open your wallet and request a different Twilio +# phone # from: +# https://www.twilio.com/console/phone-numbers/search +# +# or consider purchasing a short-code from here: +# https://www.twilio.com/docs/glossary/what-is-a-short-code +# +import requests +from json import loads + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..utils import is_phone_no +from ..utils import parse_phone_no +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +class NotifyTwilio(NotifyBase): + """ + A wrapper for Twilio Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Twilio' + + # The services URL + service_url = 'https://www.twilio.com/' + + # All notification requests are secure + secure_protocol = 'twilio' + + # Allow 300 requests per minute. + # 60/300 = 0.2 + request_rate_per_sec = 0.20 + + # the number of seconds undelivered messages should linger for + # in the Twilio queue + validity_period = 14400 + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_twilio' + + # Twilio uses the http protocol with JSON requests + notify_url = 'https://api.twilio.com/2010-04-01/Accounts/' \ + '{sid}/Messages.json' + + # The maximum length of the body + body_maxlen = 160 + + # A title can not be used for SMS Messages. Setting this to zero will + # cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + + # Define object templates + templates = ( + '{schema}://{account_sid}:{auth_token}@{from_phone}', + '{schema}://{account_sid}:{auth_token}@{from_phone}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'account_sid': { + 'name': _('Account SID'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^AC[a-f0-9]+$', 'i'), + }, + 'auth_token': { + 'name': _('Auth Token'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[a-z0-9]+$', 'i'), + }, + 'from_phone': { + 'name': _('From Phone No'), + 'type': 'string', + 'required': True, + 'regex': (r'^\+?[0-9\s)(+-]+$', 'i'), + 'map_to': 'source', + }, + 'target_phone': { + 'name': _('Target Phone No'), + 'type': 'string', + 'prefix': '+', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + 'map_to': 'targets', + }, + 'short_code': { + 'name': _('Target Short Code'), + 'type': 'string', + 'regex': (r'^[0-9]{5,6}$', 'i'), + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'from': { + 'alias_of': 'from_phone', + }, + 'sid': { + 'alias_of': 'account_sid', + }, + 'token': { + 'alias_of': 'auth_token', + }, + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'private': True, + 'regex': (r'^SK[a-f0-9]+$', 'i'), + }, + }) + + def __init__(self, account_sid, auth_token, source, targets=None, + apikey=None, ** kwargs): + """ + Initialize Twilio Object + """ + super().__init__(**kwargs) + + # The Account SID associated with the account + self.account_sid = validate_regex( + account_sid, *self.template_tokens['account_sid']['regex']) + if not self.account_sid: + msg = 'An invalid Twilio Account SID ' \ + '({}) was specified.'.format(account_sid) + self.logger.warning(msg) + raise TypeError(msg) + + # The Authentication Token associated with the account + self.auth_token = validate_regex( + auth_token, *self.template_tokens['auth_token']['regex']) + if not self.auth_token: + msg = 'An invalid Twilio Authentication Token ' \ + '({}) was specified.'.format(auth_token) + self.logger.warning(msg) + raise TypeError(msg) + + # The API Key associated with the account (optional) + self.apikey = validate_regex( + apikey, *self.template_args['apikey']['regex']) + + result = is_phone_no(source, min_len=5) + if not result: + msg = 'The Account (From) Phone # or Short-code specified ' \ + '({}) is invalid.'.format(source) + self.logger.warning(msg) + raise TypeError(msg) + + # Store The Source Phone # and/or short-code + self.source = result['full'] + + if len(self.source) < 11 or len(self.source) > 14: + # https://www.twilio.com/docs/glossary/what-is-a-short-code + # A short code is a special 5 or 6 digit telephone number + # that's shorter than a full phone number. + if len(self.source) not in (5, 6): + msg = 'The Account (From) Phone # specified ' \ + '({}) is invalid.'.format(source) + self.logger.warning(msg) + raise TypeError(msg) + + # else... it as a short code so we're okay + + else: + # We're dealing with a phone number; so we need to just + # place a plus symbol at the end of it + self.source = '+{}'.format(self.source) + + # Parse our targets + self.targets = list() + + for target in parse_phone_no(targets): + # Validate targets and drop bad ones: + result = is_phone_no(target) + if not result: + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) + continue + + # store valid phone number + self.targets.append('+{}'.format(result['full'])) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Twilio Notification + """ + + if not self.targets: + if len(self.source) in (5, 6): + # Generate a warning since we're a short-code. We need + # a number to message at minimum + self.logger.warning( + 'There are no valid Twilio targets to notify.') + return False + + # error tracking (used for function return) + has_error = False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Accept': 'application/json', + } + + # Prepare our payload + payload = { + 'Body': body, + 'From': self.source, + + # The To gets populated in the loop below + 'To': None, + } + + # Prepare our Twilio URL + url = self.notify_url.format(sid=self.account_sid) + + # Create a copy of the targets list + targets = list(self.targets) + + # Set up our authentication. Prefer the API Key if provided. + auth = (self.apikey or self.account_sid, self.auth_token) + + if len(targets) == 0: + # No sources specified, use our own phone no + targets.append(self.source) + + while len(targets): + # Get our target to notify + target = targets.pop(0) + + # Prepare our user + payload['To'] = target + + # Some Debug Logging + self.logger.debug('Twilio POST URL: {} (cert_verify={})'.format( + url, self.verify_certificate)) + self.logger.debug('Twilio Payload: {}' .format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + url, + auth=auth, + data=payload, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + if r.status_code not in ( + requests.codes.created, requests.codes.ok): + # We had a problem + status_str = \ + NotifyBase.http_response_code_lookup(r.status_code) + + # set up our status code to use + status_code = r.status_code + + try: + # Update our status response if we can + json_response = loads(r.content) + status_code = json_response.get('code', status_code) + status_str = json_response.get('message', status_str) + + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + + # We could not parse JSON response. + # We will just use the status we already have. + pass + + self.logger.warning( + 'Failed to send Twilio notification to {}: ' + '{}{}error={}.'.format( + target, + status_str, + ', ' if status_str else '', + status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent Twilio notification to {}.'.format(target)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Twilio:%s ' % ( + target) + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + return not has_error + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Our URL parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + if self.apikey is not None: + # apikey specified; pass it back on the url + params['apikey'] = self.apikey + + return '{schema}://{sid}:{token}@{source}/{targets}/?{params}'.format( + schema=self.secure_protocol, + sid=self.pprint( + self.account_sid, privacy, mode=PrivacyMode.Tail, safe=''), + token=self.pprint(self.auth_token, privacy, safe=''), + source=NotifyTwilio.quote(self.source, safe=''), + targets='/'.join( + [NotifyTwilio.quote(x, safe='') for x in self.targets]), + params=NotifyTwilio.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets > 0 else 1 + + @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 + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'] = NotifyTwilio.split_path(results['fullpath']) + + # The hostname is our source number + results['source'] = NotifyTwilio.unquote(results['host']) + + # Get our account_side and auth_token from the user/pass config + results['account_sid'] = NotifyTwilio.unquote(results['user']) + results['auth_token'] = NotifyTwilio.unquote(results['password']) + + # Auth Token + if 'token' in results['qsd'] and len(results['qsd']['token']): + # Extract the account sid from an argument + results['auth_token'] = \ + NotifyTwilio.unquote(results['qsd']['token']) + + # Account SID + if 'sid' in results['qsd'] and len(results['qsd']['sid']): + # Extract the account sid from an argument + results['account_sid'] = \ + NotifyTwilio.unquote(results['qsd']['sid']) + + # API Key + if 'apikey' in results['qsd'] and len(results['qsd']['apikey']): + results['apikey'] = results['qsd']['apikey'] + + # Support the 'from' and 'source' variable so that we can support + # targets this way too. + # The 'from' makes it easier to use yaml configuration + if 'from' in results['qsd'] and len(results['qsd']['from']): + results['source'] = \ + NotifyTwilio.unquote(results['qsd']['from']) + if 'source' in results['qsd'] and len(results['qsd']['source']): + results['source'] = \ + NotifyTwilio.unquote(results['qsd']['source']) + + # Support the 'to' variable so that we can support targets this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyTwilio.parse_phone_no(results['qsd']['to']) + + return results diff --git a/lib/apprise/plugins/NotifyTwist.py b/lib/apprise/plugins/NotifyTwist.py new file mode 100644 index 0000000..36a5531 --- /dev/null +++ b/lib/apprise/plugins/NotifyTwist.py @@ -0,0 +1,844 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# +# All of the documentation needed to work with the Twist API can be found +# here: https://developer.twist.com/v3/ + +import re +import requests +from json import loads +from itertools import chain + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyFormat +from ..common import NotifyType +from ..utils import parse_list +from ..utils import is_email +from ..AppriseLocale import gettext_lazy as _ + + +# A workspace can also be interpreted as a team name too! +IS_CHANNEL = re.compile( + r'^#?(?P((?P[A-Za-z0-9_-]+):)?' + r'(?P[^\s]{1,64}))$') + +IS_CHANNEL_ID = re.compile( + r'^(?P((?P[0-9]+):)?(?P[0-9]+))$') + +# Used to break apart list of potential tags by their delimiter +# into a usable list. +LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') + + +class NotifyTwist(NotifyBase): + """ + A wrapper for Notify Twist Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Twist' + + # The services URL + service_url = 'https://twist.com' + + # The default secure protocol + secure_protocol = 'twist' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_twist' + + # The maximum size of the message + body_maxlen = 1000 + + # Default to markdown + notify_format = NotifyFormat.MARKDOWN + + # The default Notification URL to use + api_url = 'https://api.twist.com/api/v3/' + + # Allow 300 requests per minute. + # 60/300 = 0.2 + request_rate_per_sec = 0.2 + + # The default channel to notify if no targets are specified + default_notification_channel = 'general' + + # Define object templates + templates = ( + '{schema}://{password}:{email}', + '{schema}://{password}:{email}/{targets}', + ) + + # Define our template arguments + template_tokens = dict(NotifyBase.template_tokens, **{ + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'email': { + 'name': _('Email'), + 'type': 'string', + 'required': True, + }, + 'target_channel': { + 'name': _('Target Channel'), + 'type': 'string', + 'prefix': '#', + 'map_to': 'targets', + }, + 'target_channel_id': { + 'name': _('Target Channel ID'), + 'type': 'string', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + }) + + def __init__(self, email=None, targets=None, **kwargs): + """ + Initialize Notify Twist Object + """ + super().__init__(**kwargs) + + # Initialize channels list + self.channels = set() + + # Initialize Channel ID which are stored as: + # : + self.channel_ids = set() + + # The token is None if we're not logged in and False if we + # failed to log in. Otherwise it is set to the actual token + self.token = None + + # Our default workspace (associated with our token) + self.default_workspace = None + + # A set of all of the available workspaces + self._cached_workspaces = set() + + # A mapping of channel names, the layout is as follows: + # { + # : { + # : , + # : , + # ... + # }, + # : { + # : , + # : , + # ... + # }, + # } + self._cached_channels = dict() + + # Initialize our Email Object + self.email = email if email else '{}@{}'.format( + self.user, + self.host, + ) + + # Check if it is valid + result = is_email(self.email) + if not result: + # let outer exception handle this + msg = 'The Twist Auth email specified ({}) is invalid.'\ + .format(self.email) + self.logger.warning(msg) + raise TypeError(msg) + + # Re-assign email based on what was parsed + self.email = result['full_email'] + if email: + # Force user/host to be that of the defined email for + # consistency. This is very important for those initializing + # this object with the the email object would could potentially + # cause inconsistency to contents in the NotifyBase() object + self.user = result['user'] + self.host = result['domain'] + + if not self.password: + msg = 'No Twist password was specified with account: {}'\ + .format(self.email) + self.logger.warning(msg) + raise TypeError(msg) + + # Validate recipients and drop bad ones: + for recipient in parse_list(targets): + result = IS_CHANNEL_ID.match(recipient) + if result: + # store valid channel id + self.channel_ids.add(result.group('name')) + continue + + result = IS_CHANNEL.match(recipient) + if result: + # store valid device + self.channels.add(result.group('name').lower()) + continue + + self.logger.warning( + 'Dropped invalid channel/id ' + '({}) specified.'.format(recipient), + ) + + if len(self.channels) + len(self.channel_ids) == 0: + # Notify our default channel + self.channels.add(self.default_notification_channel) + self.logger.warning( + 'Added default notification channel {}'.format( + self.default_notification_channel)) + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Our URL parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + return '{schema}://{password}:{user}@{host}/{targets}/' \ + '?{params}'.format( + schema=self.secure_protocol, + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + user=self.quote(self.user, safe=''), + host=self.host, + targets='/'.join( + [NotifyTwist.quote(x, safe='') for x in chain( + # Channels are prefixed with a pound/hashtag symbol + ['#{}'.format(x) for x in self.channels], + # Channel IDs + self.channel_ids, + )]), + params=NotifyTwist.urlencode(params), + ) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.channels) + len(self.channel_ids) + + def login(self): + """ + A simple wrapper to authenticate with the Twist Server + """ + + # Prepare our payload + payload = { + 'email': self.email, + 'password': self.password, + } + + # Reset our default workspace + self.default_workspace = None + + # Reset our cached objects + self._cached_workspaces = set() + self._cached_channels = dict() + + # Send Login Information + postokay, response = self._fetch( + 'users/login', + payload=payload, + # We set this boolean so internal recursion doesn't take place. + login=True, + ) + + if not postokay or not response: + # Setting this variable to False as a way of letting us know + # we failed to authenticate on our last attempt + self.token = False + return False + + # Our response object looks like this (content has been altered for + # presentation purposes): + # { + # "contact_info": null, + # "profession": null, + # "timezone": "UTC", + # "avatar_id": null, + # "id": 123456, + # "first_name": "Jordan", + # "comet_channel": + # "124371-34be423219130343030d4ec0a3dabbbbbe565eee", + # "restricted": false, + # "default_workspace": 92020, + # "snooze_dnd_end": null, + # "email": "user@example.com", + # "comet_server": "https://comet.twist.com", + # "snooze_until": null, + # "lang": "en", + # "feature_flags": [], + # "short_name": "Jordan P.", + # "away_mode": null, + # "time_format": "12", + # "client_id": "cb01f37e-a5b2-13e9-ba2a-023a33d10dc0", + # "removed": false, + # "emails": [ + # { + # "connected": [], + # "email": "user@example.com", + # "primary": true + # } + # ], + # "scheduled_banners": [ + # "threads_3", + # "threads_1", + # "notification_permissions", + # "search_1", + # "messages_1", + # "team_1", + # "inbox_2", + # "inbox_1" + # ], + # "snooze_dnd_start": null, + # "name": "Jordan Peterson", + # "off_days": [], + # "bot": false, + # "token": "2e82c1e4e8b0091fdaa34ff3972351821406f796", + # "snoozed": false, + # "setup_pending": false, + # "date_format": "MM/DD/YYYY" + # } + + # Store our default workspace + self.default_workspace = response.get('default_workspace') + + # Acquire our token + self.token = response.get('token') + + self.logger.info('Authenticated to Twist as {}'.format(self.email)) + return True + + def logout(self): + """ + A simple wrapper to log out of the server + """ + + if not self.token: + # Nothing more to do + return True + + # Send Logout Message + postokay, response = self._fetch('users/logout') + + # reset our token + self.token = None + + # There is no need to handling failed log out attempts at this time + return True + + def get_workspaces(self): + """ + Returns all workspaces associated with this user account as a set + + This returned object is either an empty dictionary or one that + looks like this: + { + 'workspace': , + 'workspace': , + 'workspace': , + } + + All workspaces are made lowercase for comparison purposes + """ + if not self.token and not self.login(): + # Nothing more to do + return dict() + + postokay, response = self._fetch('workspaces/get') + if not postokay or not response: + # We failed to retrieve + return dict() + + # The response object looks like so: + # [ + # { + # "created_ts": 1563044447, + # "name": "apprise", + # "creator": 123571, + # "color": 1, + # "default_channel": 13245, + # "plan": "free", + # "default_conversation": 63022, + # "id": 12345 + # } + # ] + + # Knowing our response, we can iterate over each object and cache our + # object + result = {} + for entry in response: + result[entry.get('name', '').lower()] = entry.get('id', '') + + return result + + def get_channels(self, wid): + """ + Simply returns the channel objects associated with the specified + workspace id. + + This returned object is either an empty dictionary or one that + looks like this: + { + 'channel1': , + 'channel2': , + 'channel3': , + } + + All channels are made lowercase for comparison purposes + """ + if not self.token and not self.login(): + # Nothing more to do + return {} + + payload = {'workspace_id': wid} + postokay, response = self._fetch( + 'channels/get', payload=payload) + + if not postokay or not isinstance(response, list): + # We failed to retrieve + return {} + + # Response looks like this: + # [ + # { + # "id": 123, + # "name": "General" + # "workspace_id": 12345, + # "color": 1, + # "description": "", + # "archived": false, + # "public": true, + # "user_ids": [ + # 8754 + # ], + # "created_ts": 1563044447, + # "creator": 123571, + # } + # ] + # + # Knowing our response, we can iterate over each object and cache our + # object + result = {} + for entry in response: + result[entry.get('name', '').lower()] = entry.get('id', '') + + return result + + def _channel_migration(self): + """ + A simple wrapper to get all of the current workspaces including + the default one. This plays a role in what channel(s) get notified + and where. + + A cache lookup has overhead, and is only required to be preformed + if the user specified channels by their string value + """ + + if not self.token and not self.login(): + # Nothing more to do + return False + + if not len(self.channels): + # Nothing to do; take an early exit + return True + + if self.default_workspace \ + and self.default_workspace not in self._cached_channels: + # Get our default workspace entries + self._cached_channels[self.default_workspace] = \ + self.get_channels(self.default_workspace) + + # initialize our error tracking + has_error = False + + while len(self.channels): + # Pop our channel off of the stack + result = IS_CHANNEL.match(self.channels.pop()) + + # Populate our key variables + workspace = result.group('workspace') + channel = result.group('channel').lower() + + # Acquire our workspace_id if we can + if workspace: + # We always work with the workspace in it's lowercase form + workspace = workspace.lower() + + # A workspace was defined + if not len(self._cached_workspaces): + # cache our workspaces; this only needs to be done once + self._cached_workspaces = self.get_workspaces() + + if workspace not in self._cached_workspaces: + # not found + self.logger.warning( + 'The Twist User {} is not associated with the ' + 'Team {}'.format(self.email, workspace)) + + # Toggle our return flag + has_error = True + continue + + # Store the workspace id + workspace_id = self._cached_workspaces[workspace] + + else: + # use default workspace + workspace_id = self.default_workspace + + # Check to see if our channel exists in our default workspace + if workspace_id in self._cached_channels \ + and channel in self._cached_channels[workspace_id]: + # Store our channel ID + self.channel_ids.add('{}:{}'.format( + workspace_id, + self._cached_channels[workspace_id][channel], + )) + continue + + # if we reach here, we failed to add our channel + self.logger.warning( + 'The Channel #{} was not found{}.'.format( + channel, + '' if not workspace + else ' with Team {}'.format(workspace), + )) + + # Toggle our return flag + has_error = True + continue + + # There is no need to handling failed log out attempts at this time + return not has_error + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Twist Notification + """ + + # error tracking (used for function return) + has_error = False + + if not self.token and not self.login(): + # We failed to authenticate - we're done + return False + + if len(self.channels) > 0: + # Converts channels to their maped IDs if found; this is the only + # way to send notifications to Twist + self._channel_migration() + + if not len(self.channel_ids): + # We have nothing to notify + self.logger.warning('There are no Twist targets to notify') + return False + + # Notify all of our identified channels + ids = list(self.channel_ids) + while len(ids) > 0: + # Retrieve our Channel Object + result = IS_CHANNEL_ID.match(ids.pop()) + + # We need both the workspace/team id and channel id + channel_id = int(result.group('channel')) + + # Prepare our payload + payload = { + 'channel_id': channel_id, + 'title': title, + 'content': body, + } + + postokay, response = self._fetch( + 'threads/add', + payload=payload, + ) + + # only toggle has_error flag if we had an error + if not postokay: + # Mark our failure + has_error = True + continue + + # If we reach here, we were successful + self.logger.info( + 'Sent Twist notification to {}.'.format( + result.group('name'))) + + return not has_error + + def _fetch(self, url, payload=None, method='POST', login=False): + """ + Wrapper to Twist API requests object + """ + + # use what was specified, otherwise build headers dynamically + headers = { + 'User-Agent': self.app_id, + } + + headers['Content-Type'] = \ + 'application/x-www-form-urlencoded; charset=utf-8' + + if self.token: + # Set our token + headers['Authorization'] = 'Bearer {}'.format(self.token) + + # Prepare our api url + api_url = '{}{}'.format(self.api_url, url) + + # Some Debug Logging + self.logger.debug('Twist {} URL: {} (cert_verify={})'.format( + method, api_url, self.verify_certificate)) + self.logger.debug('Twist Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made; + self.throttle() + + # Initialize a default value for our content value + content = {} + + # acquire our request mode + fn = requests.post if method == 'POST' else requests.get + try: + r = fn( + api_url, + data=payload, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + # Get our JSON content if it's possible + try: + content = loads(r.content) + + except (TypeError, ValueError, AttributeError): + # TypeError = r.content is not a String + # ValueError = r.content is Unparsable + # AttributeError = r.content is None + content = {} + + # handle authentication errors where our token has just simply + # expired. The error response content looks like this: + # { + # "error_code": 200, + # "error_uuid": "af80bd0715434231a649f2258d7fb946", + # "error_extra": {}, + # "error_string": "Invalid token" + # } + # + # Authentication related codes: + # 120 = You are not logged in + # 200 = Invalid Token + # + # Source: https://developer.twist.com/v3/#errors + # + # We attempt to login again and retry the original request + # if we aren't in the process of handling a login already + if r.status_code != requests.codes.ok and login is False \ + and isinstance(content, dict) and \ + content.get('error_code') in (120, 200): + # We failed to authenticate with our token; login one more + # time and retry this original request + if self.login(): + r = fn( + api_url, + data=payload, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout + ) + + # Get our JSON content if it's possible + try: + content = loads(r.content) + + except (TypeError, ValueError, AttributeError): + # TypeError = r.content is not a String + # ValueError = r.content is Unparsable + # AttributeError = r.content is None + content = {} + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyTwist.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send Twist {} to {}: ' + '{}error={}.'.format( + method, + api_url, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + return (False, content) + + except requests.RequestException as e: + self.logger.warning( + 'Exception received when sending Twist {} to {}: '. + format(method, api_url)) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + return (False, content) + + return (True, content) + + @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 + + if not results.get('user'): + # A username is required + return None + + # Acquire our targets + results['targets'] = NotifyTwist.split_path(results['fullpath']) + + if not results.get('password'): + # Password is required; we will accept the very first entry on the + # path as a password instead + if len(results['targets']) == 0: + # No targets to get our password from + return None + + # We need to requote contents since this variable will get + # unquoted later on in the process. This step appears a bit + # hacky, but it allows us to support the password in this location + # - twist://user@example.com/password + results['password'] = NotifyTwist.quote( + results['targets'].pop(0), safe='') + + else: + # Now we handle our format: + # twist://password:email + # + # since URL logic expects + # schema://user:password@host + # + # you can see how this breaks. The colon at the front delmits + # passwords and you can see the twist:// url inverts what we + # expect: + # twist://password:user@example.com + # + # twist://abc123:bob@example.com using normal conventions would + # have interpreted 'bob' as the password and 'abc123' as the user. + # For the purpose of apprise simplifying this for us, we need to + # swap these arguments when we prepare the email. + + _password = results['user'] + results['user'] = results['password'] + results['password'] = _password + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyTwist.parse_list(results['qsd']['to']) + + return results + + def __del__(self): + """ + Destructor + """ + try: + self.logout() + + except LookupError: # pragma: no cover + # Python v3.5 call to requests can sometimes throw the exception + # "/usr/lib64/python3.7/socket.py", line 748, in getaddrinfo + # LookupError: unknown encoding: idna + # + # This occurs every time when running unit-tests against Apprise: + # LANG=C.UTF-8 PYTHONPATH=$(pwd) py.test-3.7 + # + # There has been an open issue on this since Jan 2017. + # - https://bugs.python.org/issue29288 + # + # A ~similar~ issue can be identified here in the requests + # ticket system as unresolved and has provided work-arounds + # - https://github.com/kennethreitz/requests/issues/3578 + pass + + except ImportError: # pragma: no cover + # The actual exception is `ModuleNotFoundError` however ImportError + # grants us backwards compatibility with versions of Python older + # than v3.6 + + # Python code that makes early calls to sys.exit() can cause + # the __del__() code to run. However, in some newer versions of + # Python, this causes the `sys` library to no longer be + # available. The stack overflow also goes on to suggest that + # it's not wise to use the __del__() as a destructor + # which is the case here. + + # https://stackoverflow.com/questions/67218341/\ + # modulenotfounderror-import-of-time-halted-none-in-sys-\ + # modules-occured-when-obj?noredirect=1&lq=1 + # + # + # Also see: https://stackoverflow.com/questions\ + # /1481488/what-is-the-del-method-and-how-do-i-call-it + + # At this time it seems clean to try to log out (if we can) + # but not throw any unnecessary exceptions (like this one) to + # the end user if we don't have to. + pass diff --git a/lib/apprise/plugins/NotifyTwitter.py b/lib/apprise/plugins/NotifyTwitter.py new file mode 100644 index 0000000..3647c8b --- /dev/null +++ b/lib/apprise/plugins/NotifyTwitter.py @@ -0,0 +1,880 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# See https://developer.twitter.com/en/docs/direct-messages/\ +# sending-and-receiving/api-reference/new-event.html +import re +import requests +from copy import deepcopy +from datetime import datetime +from datetime import timezone +from requests_oauthlib import OAuth1 +from json import dumps +from json import loads +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..utils import parse_list +from ..utils import parse_bool +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ +from ..attachment.AttachBase import AttachBase + +IS_USER = re.compile(r'^\s*@?(?P[A-Z0-9_]+)$', re.I) + + +class TwitterMessageMode: + """ + Twitter Message Mode + """ + # DM (a Direct Message) + DM = 'dm' + + # A Public Tweet + TWEET = 'tweet' + + +# Define the types in a list for validation purposes +TWITTER_MESSAGE_MODES = ( + TwitterMessageMode.DM, + TwitterMessageMode.TWEET, +) + + +class NotifyTwitter(NotifyBase): + """ + A wrapper to Twitter Notifications + + """ + + # The default descriptive name associated with the Notification + service_name = 'Twitter' + + # The services URL + service_url = 'https://twitter.com/' + + # The default secure protocol is twitter. + secure_protocol = ('x', 'twitter', 'tweet') + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_twitter' + + # Support attachments + attachment_support = True + + # Do not set body_maxlen as it is set in a property value below + # since the length varies depending if we are doing a direct message + # or a tweet + # body_maxlen = see below @propery defined + + # Twitter does have titles when creating a message + title_maxlen = 0 + + # Twitter API Reference To Acquire Someone's Twitter ID + twitter_lookup = 'https://api.twitter.com/1.1/users/lookup.json' + + # Twitter API Reference To Acquire Current Users Information + twitter_whoami = \ + 'https://api.twitter.com/1.1/account/verify_credentials.json' + + # Twitter API Reference To Send A Private DM + twitter_dm = 'https://api.twitter.com/1.1/direct_messages/events/new.json' + + # Twitter API Reference To Send A Public Tweet + twitter_tweet = 'https://api.twitter.com/1.1/statuses/update.json' + + # it is documented on the site that the maximum images per tweet + # is 4 (unless it's a GIF, then it's only 1) + __tweet_non_gif_images_batch = 4 + + # Twitter Media (Attachment) Upload Location + twitter_media = 'https://upload.twitter.com/1.1/media/upload.json' + + # Twitter is kind enough to return how many more requests we're allowed to + # continue to make within it's header response as: + # X-Rate-Limit-Reset: The epoc time (in seconds) we can expect our + # rate-limit to be reset. + # X-Rate-Limit-Remaining: an integer identifying how many requests we're + # still allow to make. + request_rate_per_sec = 0 + + # For Tracking Purposes + ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None) + + # Default to 1000; users can send up to 1000 DM's and 2400 tweets a day + # This value only get's adjusted if the server sets it that way + ratelimit_remaining = 1 + + templates = ( + '{schema}://{ckey}/{csecret}/{akey}/{asecret}', + '{schema}://{ckey}/{csecret}/{akey}/{asecret}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'ckey': { + 'name': _('Consumer Key'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'csecret': { + 'name': _('Consumer Secret'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'akey': { + 'name': _('Access Key'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'asecret': { + 'name': _('Access Secret'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'target_user': { + 'name': _('Target User'), + 'type': 'string', + 'prefix': '@', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'mode': { + 'name': _('Message Mode'), + 'type': 'choice:string', + 'values': TWITTER_MESSAGE_MODES, + 'default': TwitterMessageMode.DM, + }, + 'cache': { + 'name': _('Cache Results'), + 'type': 'bool', + 'default': True, + }, + 'to': { + 'alias_of': 'targets', + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': True, + }, + }) + + def __init__(self, ckey, csecret, akey, asecret, targets=None, + mode=TwitterMessageMode.DM, cache=True, batch=True, **kwargs): + """ + Initialize Twitter Object + + """ + super().__init__(**kwargs) + + self.ckey = validate_regex(ckey) + if not self.ckey: + msg = 'An invalid Twitter Consumer Key was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + self.csecret = validate_regex(csecret) + if not self.csecret: + msg = 'An invalid Twitter Consumer Secret was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + self.akey = validate_regex(akey) + if not self.akey: + msg = 'An invalid Twitter Access Key was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + self.asecret = validate_regex(asecret) + if not self.asecret: + msg = 'An invalid Access Secret was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + # Store our webhook mode + self.mode = self.template_args['mode']['default'] \ + if not isinstance(mode, str) else mode.lower() + + if self.mode not in TWITTER_MESSAGE_MODES: + msg = 'The Twitter message mode specified ({}) is invalid.' \ + .format(mode) + self.logger.warning(msg) + raise TypeError(msg) + + # Set Cache Flag + self.cache = cache + + # Prepare Image Batch Mode Flag + self.batch = batch + + # Track any errors + has_error = False + + # Identify our targets + self.targets = [] + for target in parse_list(targets): + match = IS_USER.match(target) + if match and match.group('user'): + self.targets.append(match.group('user')) + continue + + has_error = True + self.logger.warning( + 'Dropped invalid Twitter user ({}) specified.'.format(target), + ) + + if has_error and not self.targets: + # We have specified that we want to notify one or more individual + # and we failed to load any of them. Since it's also valid to + # notify no one at all (which means we notify ourselves), it's + # important we don't switch from the users original intentions + msg = 'No Twitter targets to notify.' + self.logger.warning(msg) + raise TypeError(msg) + + # Initialize our cache values + self._whoami_cache = None + self._user_cache = {} + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): + """ + Perform Twitter Notification + """ + + # Build a list of our attachments + attachments = [] + + if attach and self.attachment_support: + # We need to upload our payload first so that we can source it + # in remaining messages + 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 + + if not re.match(r'^image/.*', attachment.mimetype, re.I): + # Only support images at this time + self.logger.warning( + 'Ignoring unsupported Twitter attachment {}.'.format( + attachment.url(privacy=True))) + continue + + self.logger.debug( + 'Preparing Twitter attachment {}'.format( + attachment.url(privacy=True))) + + # Upload our image and get our id associated with it + # see: https://developer.twitter.com/en/docs/twitter-api/v1/\ + # media/upload-media/api-reference/post-media-upload + postokay, response = self._fetch( + self.twitter_media, + payload=attachment, + ) + + if not postokay: + # We can't post our attachment + return False + + if not (isinstance(response, dict) + and response.get('media_id')): + self.logger.debug( + 'Could not attach the file to Twitter: %s (mime=%s)', + attachment.name, attachment.mimetype) + continue + + # If we get here, our output will look something like this: + # { + # "media_id": 710511363345354753, + # "media_id_string": "710511363345354753", + # "media_key": "3_710511363345354753", + # "size": 11065, + # "expires_after_secs": 86400, + # "image": { + # "image_type": "image/jpeg", + # "w": 800, + # "h": 320 + # } + # } + + response.update({ + # Update our response to additionally include the + # attachment details + 'file_name': attachment.name, + 'file_mime': attachment.mimetype, + 'file_path': attachment.path, + }) + + # Save our pre-prepared payload for attachment posting + attachments.append(response) + + # - calls _send_tweet if the mode is set so + # - calls _send_dm (direct message) otherwise + return getattr(self, '_send_{}'.format(self.mode))( + body=body, title=title, notify_type=notify_type, + attachments=attachments, **kwargs) + + def _send_tweet(self, body, title='', notify_type=NotifyType.INFO, + attachments=None, **kwargs): + """ + Twitter Public Tweet + """ + + # Error Tracking + has_error = False + + payload = { + 'status': body, + } + + payloads = [] + if not attachments: + payloads.append(payload) + + else: + # Group our images if batch is set to do so + batch_size = 1 if not self.batch \ + else self.__tweet_non_gif_images_batch + + # Track our batch control in our message generation + batches = [] + batch = [] + for attachment in attachments: + batch.append(str(attachment['media_id'])) + + # Twitter supports batching images together. This allows + # the batching of multiple images together. Twitter also + # makes it clear that you can't batch `gif` files; they need + # to be separate. So the below preserves the ordering that + # a user passed their attachments in. if 4-non-gif images + # are passed, they are all part of a single message. + # + # however, if they pass in image, gif, image, gif. The + # gif's inbetween break apart the batches so this would + # produce 4 separate tweets. + # + # If you passed in, image, image, gif, image. <- This would + # produce 3 images (as the first 2 images could be lumped + # together as a batch) + if not re.match( + r'^image/(png|jpe?g)', attachment['file_mime'], re.I) \ + or len(batch) >= batch_size: + batches.append(','.join(batch)) + batch = [] + + if batch: + batches.append(','.join(batch)) + + for no, media_ids in enumerate(batches): + _payload = deepcopy(payload) + _payload['media_ids'] = media_ids + + if no or not body: + # strip text and replace it with the image representation + _payload['status'] = \ + '{:02d}/{:02d}'.format(no + 1, len(batches)) + payloads.append(_payload) + + for no, payload in enumerate(payloads, start=1): + # Send Tweet + postokay, response = self._fetch( + self.twitter_tweet, + payload=payload, + json=False, + ) + + if not postokay: + # Track our error + has_error = True + + errors = [] + try: + errors = ['Error Code {}: {}'.format( + e.get('code', 'unk'), e.get('message')) + for e in response['errors']] + + except (KeyError, TypeError): + pass + + for error in errors: + self.logger.debug( + 'Tweet [%.2d/%.2d] Details: %s', + no, len(payloads), error) + continue + + try: + url = 'https://twitter.com/{}/status/{}'.format( + response['user']['screen_name'], + response['id_str']) + + except (KeyError, TypeError): + url = 'unknown' + + self.logger.debug( + 'Tweet [%.2d/%.2d] Details: %s', no, len(payloads), url) + + self.logger.info( + 'Sent [%.2d/%.2d] Twitter notification as public tweet.', + no, len(payloads)) + + return not has_error + + def _send_dm(self, body, title='', notify_type=NotifyType.INFO, + attachments=None, **kwargs): + """ + Twitter Direct Message + """ + + # Error Tracking + has_error = False + + payload = { + 'event': { + 'type': 'message_create', + 'message_create': { + 'target': { + # This gets assigned + 'recipient_id': None, + }, + 'message_data': { + 'text': body, + } + } + } + } + + # Lookup our users (otherwise we look up ourselves) + targets = self._whoami(lazy=self.cache) if not len(self.targets) \ + else self._user_lookup(self.targets, lazy=self.cache) + + if not targets: + # We failed to lookup any users + self.logger.warning( + 'Failed to acquire user(s) to Direct Message via Twitter') + return False + + payloads = [] + if not attachments: + payloads.append(payload) + + else: + for no, attachment in enumerate(attachments): + _payload = deepcopy(payload) + _data = _payload['event']['message_create']['message_data'] + _data['attachment'] = { + 'type': 'media', + 'media': { + 'id': attachment['media_id'] + }, + 'additional_owners': + ','.join([str(x) for x in targets.values()]) + } + if no or not body: + # strip text and replace it with the image representation + _data['text'] = \ + '{:02d}/{:02d}'.format(no + 1, len(attachments)) + payloads.append(_payload) + + for no, payload in enumerate(payloads, start=1): + for screen_name, user_id in targets.items(): + # Assign our user + target = payload['event']['message_create']['target'] + target['recipient_id'] = user_id + + # Send Twitter DM + postokay, response = self._fetch( + self.twitter_dm, + payload=payload, + ) + + if not postokay: + # Track our error + has_error = True + continue + + self.logger.info( + 'Sent [{:02d}/{:02d}] Twitter DM notification to @{}.' + .format(no, len(payloads), screen_name)) + + return not has_error + + def _whoami(self, lazy=True): + """ + Looks details of current authenticated user + + """ + + if lazy and self._whoami_cache is not None: + # Use cached response + return self._whoami_cache + + # Contains a mapping of screen_name to id + results = {} + + # Send Twitter DM + postokay, response = self._fetch( + self.twitter_whoami, + method='GET', + json=False, + ) + + if postokay: + try: + results[response['screen_name']] = response['id'] + self._whoami_cache = { + response['screen_name']: response['id'], + } + + self._user_cache.update(results) + + except (TypeError, KeyError): + pass + + return results + + def _user_lookup(self, screen_name, lazy=True): + """ + Looks up a screen name and returns the user id + + the screen_name can be a list/set/tuple as well + """ + + # Contains a mapping of screen_name to id + results = {} + + # Build a unique set of names + names = parse_list(screen_name) + + if lazy and self._user_cache: + # Use cached response + results = { + k: v for k, v in self._user_cache.items() if k in names} + + # limit our names if they already exist in our cache + names = [name for name in names if name not in results] + + if not len(names): + # They're is nothing further to do + return results + + # Twitters API documents that it can lookup to 100 + # results at a time. + # https://developer.twitter.com/en/docs/accounts-and-users/\ + # follow-search-get-users/api-reference/get-users-lookup + for i in range(0, len(names), 100): + # Look up our names by their screen_name + postokay, response = self._fetch( + self.twitter_lookup, + payload={ + 'screen_name': names[i:i + 100], + }, + json=False, + ) + + if not postokay or not isinstance(response, list): + # Track our error + continue + + # Update our user index + for entry in response: + try: + results[entry['screen_name']] = entry['id'] + + except (TypeError, KeyError): + pass + + # Cache our response for future use; this saves on un-nessisary extra + # hits against the Twitter API when we already know the answer + self._user_cache.update(results) + + return results + + def _fetch(self, url, payload=None, method='POST', json=True): + """ + Wrapper to Twitter API requests object + """ + + headers = { + 'User-Agent': self.app_id, + } + + data = None + files = None + + # Open our attachment path if required: + if isinstance(payload, AttachBase): + # prepare payload + files = {'media': (payload.name, open(payload.path, 'rb'))} + + elif json: + headers['Content-Type'] = 'application/json' + data = dumps(payload) + + else: + data = payload + + auth = OAuth1( + self.ckey, + client_secret=self.csecret, + resource_owner_key=self.akey, + resource_owner_secret=self.asecret, + ) + + # Some Debug Logging + self.logger.debug('Twitter {} URL: {} (cert_verify={})'.format( + method, url, self.verify_certificate)) + self.logger.debug('Twitter Payload: %s' % str(payload)) + + # By default set wait to None + wait = None + + if self.ratelimit_remaining == 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 + # Twitter server. One would hope we're on NTP and our clocks are + # the same allowing this to role smoothly: + + now = datetime.now(timezone.utc).replace(tzinfo=None) + if now < self.ratelimit_reset: + # We need to throttle for the difference in seconds + # We add 0.5 seconds to the end just to allow a grace + # period. + wait = (self.ratelimit_reset - now).total_seconds() + 0.5 + + # Default content response object + content = {} + + # Always call throttle before any remote server i/o is made; + self.throttle(wait=wait) + + # acquire our request mode + fn = requests.post if method == 'POST' else requests.get + try: + r = fn( + url, + data=data, + files=files, + headers=headers, + auth=auth, + 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 = {} + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyTwitter.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send Twitter {} to {}: ' + '{}error={}.'.format( + method, + url, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + return (False, content) + + try: + # Capture rate limiting if possible + self.ratelimit_remaining = \ + int(r.headers.get('x-rate-limit-remaining')) + self.ratelimit_reset = datetime.fromtimestamp( + int(r.headers.get('x-rate-limit-reset')), timezone.utc + ).replace(tzinfo=None) + + except (TypeError, ValueError): + # This is returned if we could not retrieve this information + # gracefully accept this state and move on + pass + + except requests.RequestException as e: + self.logger.warning( + 'Exception received when sending Twitter {} to {}: '. + format(method, url)) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + return (False, content) + + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while handling {}.'.format( + payload.name if isinstance(payload, AttachBase) + else payload)) + self.logger.debug('I/O Exception: %s' % str(e)) + return (False, content) + + finally: + # Close our file (if it's open) stored in the second element + # of our files tuple (index 1) + if files: + files['media'][1].close() + + return (True, content) + + @property + def body_maxlen(self): + """ + The maximum allowable characters allowed in the body per message + This is used during a Private DM Message Size (not Public Tweets + which are limited to 280 characters) + """ + return 10000 if self.mode == TwitterMessageMode.DM else 280 + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'mode': self.mode, + 'batch': 'yes' if self.batch else 'no', + 'cache': 'yes' if self.cache else 'no', + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{ckey}/{csecret}/{akey}/{asecret}' \ + '/{targets}/?{params}'.format( + schema=self.secure_protocol[0], + ckey=self.pprint(self.ckey, privacy, safe=''), + csecret=self.pprint( + self.csecret, privacy, mode=PrivacyMode.Secret, safe=''), + akey=self.pprint(self.akey, privacy, safe=''), + asecret=self.pprint( + self.asecret, privacy, mode=PrivacyMode.Secret, safe=''), + targets='/'.join( + [NotifyTwitter.quote('@{}'.format(target), safe='@') + for target in self.targets]), + params=NotifyTwitter.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets > 0 else 1 + + @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 + + # Acquire remaining tokens + tokens = NotifyTwitter.split_path(results['fullpath']) + + # The consumer token is stored in the hostname + results['ckey'] = NotifyTwitter.unquote(results['host']) + + # + # Now fetch the remaining tokens + # + + # Consumer Secret + results['csecret'] = tokens.pop(0) if tokens else None + # Access Token Key + results['akey'] = tokens.pop(0) if tokens else None + # Access Token Secret + results['asecret'] = tokens.pop(0) if tokens else None + + # The defined twitter mode + if 'mode' in results['qsd'] and len(results['qsd']['mode']): + results['mode'] = \ + NotifyTwitter.unquote(results['qsd']['mode']) + + elif results['schema'].startswith('tweet'): + results['mode'] = TwitterMessageMode.TWEET + + results['targets'] = [] + + # if a user has been defined, add it to the list of targets + if results.get('user'): + results['targets'].append(results.get('user')) + + # Store any remaining items as potential targets + results['targets'].extend(tokens) + + # Get Cache Flag (reduces lookup hits) + if 'cache' in results['qsd'] and len(results['qsd']['cache']): + results['cache'] = \ + parse_bool(results['qsd']['cache'], True) + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get( + 'batch', NotifyTwitter.template_args['batch']['default'])) + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyTwitter.parse_list(results['qsd']['to']) + + return results diff --git a/lib/apprise/plugins/NotifyVoipms.py b/lib/apprise/plugins/NotifyVoipms.py new file mode 100644 index 0000000..c39da4d --- /dev/null +++ b/lib/apprise/plugins/NotifyVoipms.py @@ -0,0 +1,375 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# Create an account https://voip.ms/ if you don't already have one +# +# Enable API and set an API password here: +# - https://voip.ms/m/api.php +# +# Read more about VoIP.ms API here: +# - https://voip.ms/m/apidocs.php + +import requests +from json import loads + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import is_phone_no +from ..utils import is_email +from ..utils import parse_phone_no +from ..AppriseLocale import gettext_lazy as _ + + +class NotifyVoipms(NotifyBase): + """ + A wrapper for Voipms Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'VoIPms' + + # The services URL + service_url = 'https://voip.ms' + + # The default protocol + secure_protocol = 'voipms' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_voipms' + + # Voipms uses the http protocol with JSON requests + notify_url = 'https://voip.ms/api/v1/rest.php' + + # The maximum length of the body + body_maxlen = 160 + + # A title can not be used for SMS Messages. Setting this to zero will + # cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + + # Define object templates + templates = ( + '{schema}://{password}:{email}/{from_phone}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'email': { + 'name': _('User Email'), + 'type': 'string', + 'required': True, + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'from_phone': { + 'name': _('From Phone No'), + 'type': 'string', + 'regex': (r'^\+?[0-9\s)(+-]+$', 'i'), + 'map_to': 'source', + }, + 'target_phone': { + 'name': _('Target Phone No'), + 'type': 'string', + 'prefix': '+', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'from': { + 'alias_of': 'from_phone', + }, + }) + + def __init__(self, email, source=None, targets=None, **kwargs): + """ + Initialize Voipms Object + """ + super().__init__(**kwargs) + + # Validate our params here. + + if self.password is None: + msg = 'Password has to be specified.' + self.logger.warning(msg) + raise TypeError(msg) + + # User is the email associated with the account + result = is_email(email) + if not result: + msg = 'An invalid Voipms user email: ' \ + '({}) was specified.'.format(email) + self.logger.warning(msg) + raise TypeError(msg) + self.email = result['full_email'] + + # Validate our source Phone # + result = is_phone_no(source) + if not result: + msg = 'An invalid Voipms source phone # ' \ + '({}) was specified.'.format(source) + self.logger.warning(msg) + raise TypeError(msg) + + # Source Phone # only supports +1 country code + # Allow 7 digit phones (presume they're local with +1 country code) + if result['country'] and result['country'] != '1': + msg = 'Voipms only supports +1 country code ' \ + '({}) was specified.'.format(source) + self.logger.warning(msg) + raise TypeError(msg) + + # Store our source phone number (without country code) + self.source = result['area'] + result['line'] + + # Parse our targets + self.targets = list() + + if targets: + for target in parse_phone_no(targets): + # Validate targets and drop bad ones: + result = is_phone_no(target) + + # Target Phone # only supports +1 country code + if result['country'] != '1': + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) + continue + + # store valid phone number + self.targets.append(result['area'] + result['line']) + + else: + # Send a message to ourselves + self.targets.append(self.source) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Voipms Notification + """ + + if len(self.targets) == 0: + # There were no services to notify + self.logger.warning('There were no Voipms targets to notify.') + return False + + # error tracking (used for function return) + has_error = False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/x-www-form-urlencoded', + } + + # Prepare our payload + payload = { + 'api_username': self.email, + 'api_password': self.password, + 'did': self.source, + 'message': body, + 'method': 'sendSMS', + + # Gets filled in the loop below + 'dst': None + } + + # Create a copy of the targets list + targets = list(self.targets) + + while len(targets): + # Get our target to notify + target = targets.pop(0) + + # Add target Phone # + payload['dst'] = target + + # Some Debug Logging + self.logger.debug('Voipms GET URL: {} (cert_verify={})'.format( + self.notify_url, self.verify_certificate)) + self.logger.debug('Voipms Payload: {}' .format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + response = {'status': 'unknown', 'message': ''} + + try: + r = requests.get( + self.notify_url, + params=payload, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + try: + response = loads(r.content) + + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + pass + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyVoipms.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send Voipms notification to {}: ' + '{}{}error={}.'.format( + target, + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + # Voipms sends 200 OK even if there is an error + # check if status in response and if it is not success + + if response is not None and response['status'] != 'success': + self.logger.warning( + 'Failed to send Voipms notification to {}: ' + 'status: {}, message: {}'.format( + target, response['status'], response['message']) + ) + + # Mark our failure + has_error = True + continue + else: + self.logger.info( + 'Sent Voipms notification to %s' % target) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Voipms:%s ' + 'notification.' % target + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + 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 = self.url_parameters(privacy=privacy, *args, **kwargs) + + schemaStr = \ + '{schema}://{password}:{email}/{from_phone}/{targets}/?{params}' + return schemaStr.format( + schema=self.secure_protocol, + email=self.email, + password=self.pprint(self.password, privacy, safe=''), + from_phone='1' + self.pprint(self.source, privacy, safe=''), + targets='/'.join( + ['1' + NotifyVoipms.quote(x, safe='') for x in self.targets]), + params=NotifyVoipms.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets > 0 else 1 + + @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 + + results['targets'] = \ + NotifyVoipms.split_path(results['fullpath']) + + if 'from' in results['qsd'] and len(results['qsd']['from']): + results['source'] = \ + NotifyVoipms.unquote(results['qsd']['from']) + + elif results['targets']: + # The from phone no is the first entry in the list otherwise + results['source'] = results['targets'].pop(0) + + # Swap user for pass since our input is: password:email + # where email is user@hostname (or user@domain) + user = results['password'] + password = results['user'] + results['password'] = password + results['user'] = user + + results['email'] = '{}@{}'.format( + NotifyVoipms.unquote(user), + NotifyVoipms.unquote(results['host']), + ) + + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyVoipms.parse_phone_no(results['qsd']['to']) + + return results diff --git a/lib/apprise/plugins/NotifyVonage.py b/lib/apprise/plugins/NotifyVonage.py new file mode 100644 index 0000000..48d8231 --- /dev/null +++ b/lib/apprise/plugins/NotifyVonage.py @@ -0,0 +1,396 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# Sign-up with https://dashboard.nexmo.com/ +# +# Get your (api) key and secret here: +# - https://dashboard.nexmo.com/getting-started-guide +# +import requests + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..utils import is_phone_no +from ..utils import parse_phone_no +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +class NotifyVonage(NotifyBase): + """ + A wrapper for Vonage Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Vonage' + + # The services URL + service_url = 'https://dashboard.nexmo.com/' + + # The default protocol (nexmo kept for backwards compatibility) + secure_protocol = ('vonage', 'nexmo') + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_nexmo' + + # Vonage uses the http protocol with JSON requests + notify_url = 'https://rest.nexmo.com/sms/json' + + # The maximum length of the body + body_maxlen = 160 + + # A title can not be used for SMS Messages. Setting this to zero will + # cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + + # Define object templates + templates = ( + '{schema}://{apikey}:{secret}@{from_phone}', + '{schema}://{apikey}:{secret}@{from_phone}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'required': True, + 'regex': (r'^[a-z0-9]+$', 'i'), + 'private': True, + }, + 'secret': { + 'name': _('API Secret'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[a-z0-9]+$', 'i'), + }, + 'from_phone': { + 'name': _('From Phone No'), + 'type': 'string', + 'required': True, + 'regex': (r'^\+?[0-9\s)(+-]+$', 'i'), + 'map_to': 'source', + }, + 'target_phone': { + 'name': _('Target Phone No'), + 'type': 'string', + 'prefix': '+', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'from': { + 'alias_of': 'from_phone', + }, + 'key': { + 'alias_of': 'apikey', + }, + 'secret': { + 'alias_of': 'secret', + }, + + # Default Time To Live + # By default Vonage attempt delivery for 72 hours, however the maximum + # effective value depends on the operator and is typically 24 - 48 + # hours. We recommend this value should be kept at its default or at + # least 30 minutes. + 'ttl': { + 'name': _('ttl'), + 'type': 'int', + 'default': 900000, + 'min': 20000, + 'max': 604800000, + }, + }) + + def __init__(self, apikey, secret, source, targets=None, ttl=None, + **kwargs): + """ + Initialize Vonage Object + """ + super().__init__(**kwargs) + + # API Key (associated with project) + self.apikey = validate_regex( + apikey, *self.template_tokens['apikey']['regex']) + if not self.apikey: + msg = 'An invalid Vonage API Key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # API Secret (associated with project) + self.secret = validate_regex( + secret, *self.template_tokens['secret']['regex']) + if not self.secret: + msg = 'An invalid Vonage API Secret ' \ + '({}) was specified.'.format(secret) + self.logger.warning(msg) + raise TypeError(msg) + + # Set our Time to Live Flag + self.ttl = self.template_args['ttl']['default'] + try: + self.ttl = int(ttl) + + except (ValueError, TypeError): + # Do nothing + pass + + if self.ttl < self.template_args['ttl']['min'] or \ + self.ttl > self.template_args['ttl']['max']: + msg = 'The Vonage TTL specified ({}) is out of range.'\ + .format(self.ttl) + self.logger.warning(msg) + raise TypeError(msg) + + # The Source Phone # + self.source = source + + result = is_phone_no(source) + if not result: + msg = 'The Account (From) Phone # specified ' \ + '({}) is invalid.'.format(source) + self.logger.warning(msg) + raise TypeError(msg) + + # Store our parsed value + self.source = result['full'] + + # Parse our targets + self.targets = list() + + for target in parse_phone_no(targets): + # Validate targets and drop bad ones: + result = is_phone_no(target) + if not result: + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) + continue + + # store valid phone number + self.targets.append(result['full']) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Vonage Notification + """ + + # error tracking (used for function return) + has_error = False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/x-www-form-urlencoded', + } + + # Prepare our payload + payload = { + 'api_key': self.apikey, + 'api_secret': self.secret, + 'ttl': self.ttl, + 'from': self.source, + 'text': body, + + # The to gets populated in the loop below + 'to': None, + } + + # Create a copy of the targets list + targets = list(self.targets) + + if len(targets) == 0: + # No sources specified, use our own phone no + targets.append(self.source) + + while len(targets): + # Get our target to notify + target = targets.pop(0) + + # Prepare our user + payload['to'] = target + + # Some Debug Logging + self.logger.debug('Vonage POST URL: {} (cert_verify={})'.format( + self.notify_url, self.verify_certificate)) + self.logger.debug('Vonage Payload: {}' .format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + self.notify_url, + data=payload, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyVonage.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send Vonage notification to {}: ' + '{}{}error={}.'.format( + target, + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent Vonage notification to %s.' % target) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Vonage:%s ' + 'notification.' % target + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + 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 = { + 'ttl': str(self.ttl), + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{key}:{secret}@{source}/{targets}/?{params}'.format( + schema=self.secure_protocol[0], + key=self.pprint(self.apikey, privacy, safe=''), + secret=self.pprint( + self.secret, privacy, mode=PrivacyMode.Secret, safe=''), + source=NotifyVonage.quote(self.source, safe=''), + targets='/'.join( + [NotifyVonage.quote(x, safe='') for x in self.targets]), + params=NotifyVonage.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets > 0 else 1 + + @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 + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'] = NotifyVonage.split_path(results['fullpath']) + + # The hostname is our source number + results['source'] = NotifyVonage.unquote(results['host']) + + # Get our account_side and auth_token from the user/pass config + results['apikey'] = NotifyVonage.unquote(results['user']) + results['secret'] = NotifyVonage.unquote(results['password']) + + # API Key + if 'key' in results['qsd'] and len(results['qsd']['key']): + # Extract the API Key from an argument + results['apikey'] = \ + NotifyVonage.unquote(results['qsd']['key']) + + # API Secret + if 'secret' in results['qsd'] and len(results['qsd']['secret']): + # Extract the API Secret from an argument + results['secret'] = \ + NotifyVonage.unquote(results['qsd']['secret']) + + # Support the 'from' and 'source' variable so that we can support + # targets this way too. + # The 'from' makes it easier to use yaml configuration + if 'from' in results['qsd'] and len(results['qsd']['from']): + results['source'] = \ + NotifyVonage.unquote(results['qsd']['from']) + if 'source' in results['qsd'] and len(results['qsd']['source']): + results['source'] = \ + NotifyVonage.unquote(results['qsd']['source']) + + # Support the 'ttl' variable + if 'ttl' in results['qsd'] and len(results['qsd']['ttl']): + results['ttl'] = \ + NotifyVonage.unquote(results['qsd']['ttl']) + + # 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'] += \ + NotifyVonage.parse_phone_no(results['qsd']['to']) + + return results diff --git a/lib/apprise/plugins/NotifyWebexTeams.py b/lib/apprise/plugins/NotifyWebexTeams.py new file mode 100644 index 0000000..67ed4e4 --- /dev/null +++ b/lib/apprise/plugins/NotifyWebexTeams.py @@ -0,0 +1,261 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# At the time I created this plugin, their website had lots of issues with the +# Firefox Browser. I fell back to Chrome and had no problems. + +# To use this plugin, you need to first access https://teams.webex.com and +# make yourself an account if you don't already have one. You'll want to +# create at least one 'space' before getting the 'incoming webhook'. +# +# Next you'll need to install the 'Incoming webhook' plugin found under +# the 'other' category here: https://apphub.webex.com/integrations/ + +# These links may not always work as time goes by and websites always +# change, but at the time of creating this plugin this was a direct link +# to it: https://apphub.webex.com/integrations/incoming-webhooks-cisco-systems + +# If you're logged in, you'll be able to click on the 'Connect' button. From +# there you'll need to accept the permissions it will ask of you. Give the +# webhook a name such as 'apprise'. +# When you're complete, you will recieve a URL that looks something like this: +# https://api.ciscospark.com/v1/webhooks/incoming/\ +# Y3lzY29zcGkyazovL3VzL1dFQkhPT0sajkkzYWU4fTMtMGE4Yy00 +# +# The last part of the URL is all you need to be interested in. Think of this +# url as: +# https://api.ciscospark.com/v1/webhooks/incoming/{token} +# +# You will need to assemble all of your URLs for this plugin to work as: +# wxteams://{token} +# +# Resources +# - https://developer.webex.com/docs/api/basics - markdown/post syntax +# - https://developer.cisco.com/ecosystem/webex/apps/\ +# incoming-webhooks-cisco-systems/ - Simple webhook example + +import re +import requests +from json import dumps + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..common import NotifyFormat +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + +# Extend HTTP Error Messages +# Based on: https://developer.webex.com/docs/api/basics/rate-limiting +WEBEX_HTTP_ERROR_MAP = { + 401: 'Unauthorized - Invalid Token.', + 415: 'Unsuported media specified', + 429: 'To many consecutive requests were made.', + 503: 'Service is overloaded, try again later', +} + + +class NotifyWebexTeams(NotifyBase): + """ + A wrapper for Webex Teams Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Cisco Webex Teams' + + # The services URL + service_url = 'https://webex.teams.com/' + + # The default secure protocol + secure_protocol = ('wxteams', 'webex') + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_wxteams' + + # Webex Teams uses the http protocol with JSON requests + notify_url = 'https://api.ciscospark.com/v1/webhooks/incoming/' + + # The maximum allowable characters allowed in the body per message + body_maxlen = 1000 + + # We don't support titles for Webex notifications + title_maxlen = 0 + + # Default to markdown; fall back to text + notify_format = NotifyFormat.MARKDOWN + + # Define object templates + templates = ( + '{schema}://{token}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'token': { + 'name': _('Token'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[a-z0-9]{80,160}$', 'i'), + }, + }) + + def __init__(self, token, **kwargs): + """ + Initialize Webex Teams Object + """ + super().__init__(**kwargs) + + # The token associated with the account + self.token = validate_regex( + token, *self.template_tokens['token']['regex']) + if not self.token: + msg = 'The Webex Teams token specified ({}) is invalid.'\ + .format(token) + self.logger.warning(msg) + raise TypeError(msg) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Webex Teams Notification + """ + + # Setup our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + } + + # Prepare our URL + url = '{}/{}'.format(self.notify_url, self.token) + + payload = { + 'markdown' if (self.notify_format == NotifyFormat.MARKDOWN) + else 'text': body, + } + + self.logger.debug('Webex Teams POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('Webex Teams 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 not in ( + requests.codes.ok, requests.codes.no_content): + # We had a problem + status_str = \ + NotifyWebexTeams.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send Webex Teams notification: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + return False + + else: + self.logger.info( + 'Sent Webex Teams notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Webex Teams ' + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + return False + + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Our URL parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + return '{schema}://{token}/?{params}'.format( + schema=self.secure_protocol[0], + token=self.pprint(self.token, privacy, safe=''), + params=NotifyWebexTeams.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, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + # The first token is stored in the hostname + results['token'] = NotifyWebexTeams.unquote(results['host']) + + return results + + @staticmethod + def parse_native_url(url): + """ + Support https://api.ciscospark.com/v1/webhooks/incoming/WEBHOOK_TOKEN + """ + + result = re.match( + r'^https?://(api\.ciscospark\.com|webexapis\.com)' + r'/v[1-9][0-9]*/webhooks/incoming/' + r'(?P[A-Z0-9_-]+)/?' + r'(?P\?.+)?$', url, re.I) + + if result: + return NotifyWebexTeams.parse_url( + '{schema}://{webhook_token}/{params}'.format( + schema=NotifyWebexTeams.secure_protocol[0], + webhook_token=result.group('webhook_token'), + params='' if not result.group('params') + else result.group('params'))) + + return None diff --git a/lib/apprise/plugins/NotifyWhatsApp.py b/lib/apprise/plugins/NotifyWhatsApp.py new file mode 100644 index 0000000..efa90f8 --- /dev/null +++ b/lib/apprise/plugins/NotifyWhatsApp.py @@ -0,0 +1,559 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. +# +# API Source: +# https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages +# +# 1. Register a developer account with Meta: +# https://developers.facebook.com/docs/whatsapp/cloud-api/get-started +# 2. Enable 2 Factor Authentication (2FA) with your account (if not done +# already) +# 3. Create a App using WhatsApp Product. There are 2 to create an app from +# Do NOT chose the WhatsApp Webhook one (choose the other) +# +# When you click on the API Setup section of your new app you need to record +# both the access token and the From Phone Number ID. Note that this not the +# from phone number itself, but it's ID. It's displayed below and contains +# way more numbers then your typical phone number + +import re +import requests +from json import loads, dumps +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import is_phone_no +from ..utils import parse_phone_no +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +class NotifyWhatsApp(NotifyBase): + """ + A wrapper for WhatsApp Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'WhatsApp' + + # The services URL + service_url = \ + 'https://developers.facebook.com/docs/whatsapp/cloud-api/get-started' + + # All notification requests are secure + secure_protocol = 'whatsapp' + + # Allow 300 requests per minute. + # 60/300 = 0.2 + request_rate_per_sec = 0.20 + + # Facebook Graph version + fb_graph_version = 'v17.0' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_whatsapp' + + # WhatsApp Message Notification URL + notify_url = 'https://graph.facebook.com/{fb_ver}/{phone_id}/messages' + + # The maximum length of the body + body_maxlen = 1024 + + # A title can not be used for SMS Messages. Setting this to zero will + # cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + + # Define object templates + templates = ( + '{schema}://{token}@{from_phone_id}/{targets}', + '{schema}://{template}:{token}@{from_phone_id}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'token': { + 'name': _('Access Token'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[a-z0-9]+$', 'i'), + }, + 'template': { + 'name': _('Template Name'), + 'type': 'string', + 'required': False, + 'regex': (r'^[^\s]+$', 'i'), + }, + 'from_phone_id': { + 'name': _('From Phone ID'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[0-9]+$', 'i'), + }, + 'target_phone': { + 'name': _('Target Phone No'), + 'type': 'string', + 'prefix': '+', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + 'language': { + 'name': _('Language'), + 'type': 'string', + 'default': 'en_US', + 'regex': (r'^[^0-9\s]+$', 'i'), + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'from': { + 'alias_of': 'from_phone_id', + }, + 'token': { + 'alias_of': 'token', + }, + 'template': { + 'alias_of': 'template', + }, + 'lang': { + 'alias_of': 'language', + }, + }) + + # Our supported mappings and component keys + component_key_re = re.compile( + r'(?P((?P[1-9][0-9]*)|(?Pbody|type)))', re.IGNORECASE) + + # Define any kwargs we're using + template_kwargs = { + 'template_mapping': { + 'name': _('Template Mapping'), + 'prefix': ':', + }, + } + + def __init__(self, token, from_phone_id, template=None, targets=None, + language=None, template_mapping=None, **kwargs): + """ + Initialize WhatsApp Object + """ + super().__init__(**kwargs) + + # The Access Token associated with the account + self.token = validate_regex( + token, *self.template_tokens['token']['regex']) + if not self.token: + msg = 'An invalid WhatsApp Access Token ' \ + '({}) was specified.'.format(token) + self.logger.warning(msg) + raise TypeError(msg) + + # The From Phone ID associated with the account + self.from_phone_id = validate_regex( + from_phone_id, *self.template_tokens['from_phone_id']['regex']) + if not self.from_phone_id: + msg = 'An invalid WhatsApp From Phone ID ' \ + '({}) was specified.'.format(from_phone_id) + self.logger.warning(msg) + raise TypeError(msg) + + # The template to associate with the message + if template: + self.template = validate_regex( + template, *self.template_tokens['template']['regex']) + if not self.template: + msg = 'An invalid WhatsApp Template Name ' \ + '({}) was specified.'.format(template) + self.logger.warning(msg) + raise TypeError(msg) + + # The Template language Code to use + if language: + self.language = validate_regex( + language, *self.template_tokens['language']['regex']) + if not self.language: + msg = 'An invalid WhatsApp Template Language Code ' \ + '({}) was specified.'.format(language) + self.logger.warning(msg) + raise TypeError(msg) + else: + self.language = self.template_tokens['language']['default'] + else: + # + # Message Mode + # + self.template = None + + # Parse our targets + self.targets = list() + + for target in parse_phone_no(targets): + # Validate targets and drop bad ones: + result = is_phone_no(target) + if not result: + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) + continue + + # store valid phone number + self.targets.append('+{}'.format(result['full'])) + + self.template_mapping = {} + if template_mapping: + # Store our extra payload entries + self.template_mapping.update(template_mapping) + + # Validate Mapping and prepare Components + self.components = dict() + self.component_keys = list() + for key, val in self.template_mapping.items(): + matched = self.component_key_re.match(key) + if not matched: + msg = 'An invalid Template Component ID ' \ + '({}) was specified.'.format(key) + self.logger.warning(msg) + raise TypeError(msg) + + if matched.group('id'): + # + # Manual Component Assigment (by id) + # + index = matched.group('id') + map_to = { + "type": "text", + "text": val, + } + + else: # matched.group('map') + map_to = matched.group('map').lower() + matched = self.component_key_re.match(val) + if not (matched and matched.group('id')): + msg = 'An invalid Template Component Mapping ' \ + '(:{}={}) was specified.'.format(key, val) + self.logger.warning(msg) + raise TypeError(msg) + index = matched.group('id') + + if index in self.components: + msg = 'The Template Component index ' \ + '({}) was already assigned.'.format(key) + self.logger.warning(msg) + raise TypeError(msg) + + self.components[index] = map_to + self.component_keys = self.components.keys() + # Adjust sorting and assume that the user put the order correctly; + # if not Facebook just won't be very happy and will reject the + # message + sorted(self.component_keys) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform WhatsApp Notification + """ + + if not self.targets: + self.logger.warning( + 'There are no valid WhatsApp targets to notify.') + return False + + # error tracking (used for function return) + has_error = False + + # Prepare our URL + url = self.notify_url.format( + fb_ver=self.fb_graph_version, + phone_id=self.from_phone_id, + ) + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.token}', + } + + payload = { + 'messaging_product': 'whatsapp', + # The To gets populated in the loop below + 'to': None, + } + + if not self.template: + # + # Send Message + # + payload.update({ + 'recipient_type': "individual", + 'type': 'text', + 'text': {"body": body}, + }) + + else: + # + # Send Template + # + payload.update({ + 'type': 'template', + "template": { + "name": self.template, + "language": {"code": self.language}, + }, + }) + + if self.components: + payload['template']['components'] = [ + { + "type": "body", + "parameters": [], + } + ] + for key in self.component_keys: + if isinstance(self.components[key], dict): + # Manual Assignment + payload['template']['components'][0]["parameters"]\ + .append(self.components[key]) + continue + + # Mapping of body and/or notify type + payload['template']['components'][0]["parameters"].append({ + "type": "text", + "text": body if self.components[key] == 'body' + else notify_type, + }) + + # Create a copy of the targets list + targets = list(self.targets) + + while len(targets): + # Get our target to notify + target = targets.pop(0) + + # Prepare our user + payload['to'] = target + + # Some Debug Logging + self.logger.debug('WhatsApp POST URL: {} (cert_verify={})'.format( + url, self.verify_certificate)) + self.logger.debug('WhatsApp Payload: {}' .format(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 not in ( + requests.codes.created, requests.codes.ok): + # We had a problem + status_str = \ + NotifyBase.http_response_code_lookup(r.status_code) + + # set up our status code to use + status_code = r.status_code + + try: + # Update our status response if we can + json_response = loads(r.content) + status_code = \ + json_response['error'].get('code', status_code) + status_str = \ + json_response['error'].get('message', status_str) + + except (AttributeError, TypeError, ValueError, KeyError): + # KeyError = r.content is parseable but does not + # contain 'error' + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + + # We could not parse JSON response. + # We will just use the status we already have. + pass + + self.logger.warning( + 'Failed to send WhatsApp notification to {}: ' + '{}{}error={}.'.format( + target, + status_str, + ', ' if status_str else '', + status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent WhatsApp notification to {}.'.format(target)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending WhatsApp:%s ' % ( + target) + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + 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 = {} + if self.template: + # Add language to our URL + params['lang'] = self.language + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Payload body extras prefixed with a ':' sign + # Append our payload extras into our parameters + params.update( + {':{}'.format(k): v for k, v in self.template_mapping.items()}) + + return '{schema}://{template}{token}@{from_id}/{targets}/?{params}'\ + .format( + schema=self.secure_protocol, + from_id=self.pprint( + self.from_phone_id, privacy, safe=''), + token=self.pprint(self.token, privacy, safe=''), + template='' if not self.template + else '{}:'.format( + NotifyWhatsApp.quote(self.template, safe='')), + targets='/'.join( + [NotifyWhatsApp.quote(x, safe='') for x in self.targets]), + params=NotifyWhatsApp.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets > 0 else 1 + + @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 + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'] = NotifyWhatsApp.split_path(results['fullpath']) + + # The hostname is our From Phone ID + results['from_phone_id'] = NotifyWhatsApp.unquote(results['host']) + + # Determine if we have a Template, otherwise load our token + if results['password']: + # + # Template Mode + # + results['template'] = NotifyWhatsApp.unquote(results['user']) + results['token'] = NotifyWhatsApp.unquote(results['password']) + + else: + # + # Message Mode + # + results['token'] = NotifyWhatsApp.unquote(results['user']) + + # Access token + if 'token' in results['qsd'] and len(results['qsd']['token']): + # Extract the account sid from an argument + results['token'] = \ + NotifyWhatsApp.unquote(results['qsd']['token']) + + # Template + if 'template' in results['qsd'] and len(results['qsd']['template']): + results['template'] = results['qsd']['template'] + + # Template Language + if 'lang' in results['qsd'] and len(results['qsd']['lang']): + results['language'] = results['qsd']['lang'] + + # Support the 'from' and 'source' variable so that we can support + # targets this way too. + # The 'from' makes it easier to use yaml configuration + if 'from' in results['qsd'] and len(results['qsd']['from']): + results['from_phone_id'] = \ + NotifyWhatsApp.unquote(results['qsd']['from']) + if 'source' in results['qsd'] and \ + len(results['qsd']['source']): + results['from_phone_id'] = \ + NotifyWhatsApp.unquote(results['qsd']['source']) + + # Support the 'to' variable so that we can support targets this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyWhatsApp.parse_phone_no(results['qsd']['to']) + + # store any additional payload extra's defined + results['template_mapping'] = { + NotifyWhatsApp.unquote(x): NotifyWhatsApp.unquote(y) + for x, y in results['qsd:'].items() + } + + return results diff --git a/lib/apprise/plugins/NotifyWindows.py b/lib/apprise/plugins/NotifyWindows.py new file mode 100644 index 0000000..226cf92 --- /dev/null +++ b/lib/apprise/plugins/NotifyWindows.py @@ -0,0 +1,259 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +from __future__ import absolute_import +from __future__ import print_function + +from time import sleep + +from .NotifyBase import NotifyBase +from ..common import NotifyImageSize +from ..common import NotifyType +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + +# Default our global support flag +NOTIFY_WINDOWS_SUPPORT_ENABLED = False + +try: + # 3rd party modules (Windows Only) + import win32api + import win32con + import win32gui + + # We're good to go! + NOTIFY_WINDOWS_SUPPORT_ENABLED = True + +except ImportError: + # No problem; we just simply can't support this plugin because we're + # either using Linux, or simply do not have pywin32 installed. + pass + + +class NotifyWindows(NotifyBase): + """ + A wrapper for local Windows Notifications + """ + # Set our global enabled flag + enabled = NOTIFY_WINDOWS_SUPPORT_ENABLED + + requirements = { + # Define our required packaging in order to work + 'details': _('A local Microsoft Windows environment is required.') + } + + # The default descriptive name associated with the Notification + service_name = 'Windows Notification' + + # The default protocol + protocol = 'windows' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_windows' + + # Disable throttle rate for Windows requests since they are normally + # local anyway + request_rate_per_sec = 0 + + # Allows the user to specify the NotifyImageSize object + image_size = NotifyImageSize.XY_128 + + # Limit results to just the first 2 line otherwise there is just to much + # content to display + body_max_line_count = 2 + + # The number of seconds to display the popup for + default_popup_duration_sec = 12 + + # Define object templates + templates = ( + '{schema}://', + ) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'duration': { + 'name': _('Duration'), + 'type': 'int', + 'min': 1, + 'default': 12, + }, + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': True, + 'map_to': 'include_image', + }, + }) + + def __init__(self, include_image=True, duration=None, **kwargs): + """ + Initialize Windows Object + """ + + super().__init__(**kwargs) + + # Number of seconds to display notification for + self.duration = self.default_popup_duration_sec \ + if not (isinstance(duration, int) and duration > 0) else duration + + # Define our handler + self.hwnd = None + + # Track whether or not we want to send an image with our notification + # or not. + self.include_image = include_image + + def _on_destroy(self, hwnd, msg, wparam, lparam): + """ + Destroy callback function + """ + + nid = (self.hwnd, 0) + win32gui.Shell_NotifyIcon(win32gui.NIM_DELETE, nid) + win32api.PostQuitMessage(0) + + return 0 + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Windows Notification + """ + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + # Register destruction callback + message_map = {win32con.WM_DESTROY: self._on_destroy, } + + # Register the window class. + self.wc = win32gui.WNDCLASS() + self.hinst = self.wc.hInstance = win32api.GetModuleHandle(None) + self.wc.lpszClassName = str("PythonTaskbar") + self.wc.lpfnWndProc = message_map + self.classAtom = win32gui.RegisterClass(self.wc) + + # Styling and window type + style = win32con.WS_OVERLAPPED | win32con.WS_SYSMENU + self.hwnd = win32gui.CreateWindow( + self.classAtom, "Taskbar", style, 0, 0, + win32con.CW_USEDEFAULT, win32con.CW_USEDEFAULT, 0, 0, + self.hinst, None) + win32gui.UpdateWindow(self.hwnd) + + # image path (if configured to acquire) + icon_path = None if not self.include_image \ + else self.image_path(notify_type, extension='.ico') + + if icon_path: + icon_flags = win32con.LR_LOADFROMFILE | win32con.LR_DEFAULTSIZE + + try: + hicon = win32gui.LoadImage( + self.hinst, icon_path, win32con.IMAGE_ICON, 0, 0, + icon_flags) + + except Exception as e: + self.logger.warning( + "Could not load windows notification icon ({}): {}" + .format(icon_path, e)) + + # disable icon + hicon = win32gui.LoadIcon(0, win32con.IDI_APPLICATION) + else: + # disable icon + hicon = win32gui.LoadIcon(0, win32con.IDI_APPLICATION) + + # Taskbar icon + flags = win32gui.NIF_ICON | win32gui.NIF_MESSAGE | win32gui.NIF_TIP + nid = (self.hwnd, 0, flags, win32con.WM_USER + 20, hicon, + "Tooltip") + win32gui.Shell_NotifyIcon(win32gui.NIM_ADD, nid) + win32gui.Shell_NotifyIcon(win32gui.NIM_MODIFY, ( + self.hwnd, 0, win32gui.NIF_INFO, win32con.WM_USER + 20, hicon, + "Balloon Tooltip", body, 200, title)) + + # take a rest then destroy + sleep(self.duration) + win32gui.DestroyWindow(self.hwnd) + win32gui.UnregisterClass(self.wc.lpszClassName, None) + + self.logger.info('Sent Windows notification.') + + except Exception as e: + self.logger.warning('Failed to send Windows notification.') + self.logger.debug('Windows Exception: {}', str(e)) + return False + + return True + + 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', + 'duration': str(self.duration), + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://?{params}'.format( + schema=self.protocol, + params=NotifyWindows.urlencode(params), + ) + + @staticmethod + def parse_url(url): + """ + There are no parameters nessisary for this protocol; simply having + windows:// is all you need. This function just makes sure that + is in place. + + """ + + results = NotifyBase.parse_url(url, verify_host=False) + + # Include images with our message + results['include_image'] = \ + parse_bool(results['qsd'].get('image', True)) + + # Set duration + try: + results['duration'] = int(results['qsd'].get('duration')) + + except (TypeError, ValueError): + # Not a valid integer; ignore entry + pass + + # return results + return results diff --git a/lib/apprise/plugins/NotifyXBMC.py b/lib/apprise/plugins/NotifyXBMC.py new file mode 100644 index 0000000..a973989 --- /dev/null +++ b/lib/apprise/plugins/NotifyXBMC.py @@ -0,0 +1,384 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 json import dumps + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..common import NotifyImageSize +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + + +class NotifyXBMC(NotifyBase): + """ + A wrapper for XBMC/KODI Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Kodi/XBMC' + + # The services URL + service_url = 'http://kodi.tv/' + + xbmc_protocol = 'xbmc' + xbmc_secure_protocol = 'xbmcs' + kodi_protocol = 'kodi' + kodi_secure_protocol = 'kodis' + + # The default protocols + protocol = (xbmc_protocol, kodi_protocol) + + # The default secure protocols + secure_protocol = (xbmc_secure_protocol, kodi_secure_protocol) + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_kodi' + + # Disable throttle rate for XBMC/KODI requests since they are normally + # local anyway + request_rate_per_sec = 0 + + # Limit results to just the first 2 line otherwise there is just to much + # content to display + body_max_line_count = 2 + + # XBMC uses the http protocol with JSON requests + xbmc_default_port = 8080 + + # Allows the user to specify the NotifyImageSize object + image_size = NotifyImageSize.XY_128 + + # XBMC default protocol version (v2) + xbmc_remote_protocol = 2 + + # KODI default protocol version (v6) + kodi_remote_protocol = 6 + + # Define object templates + templates = ( + '{schema}://{host}', + '{schema}://{host}:{port}', + '{schema}://{user}:{password}@{host}', + '{schema}://{user}:{password}@{host}:{port}', + ) + + # Define our tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'user': { + 'name': _('Username'), + 'type': 'string', + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'duration': { + 'name': _('Duration'), + 'type': 'int', + 'min': 1, + 'default': 12, + }, + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': True, + 'map_to': 'include_image', + }, + }) + + def __init__(self, include_image=True, duration=None, **kwargs): + """ + Initialize XBMC/KODI Object + """ + super().__init__(**kwargs) + + # Number of seconds to display notification for + self.duration = self.template_args['duration']['default'] \ + if not (isinstance(duration, int) and + self.template_args['duration']['min'] > 0) else duration + + # Build our schema + self.schema = 'https' if self.secure else 'http' + + # Prepare the default header + self.headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json' + } + + # Default protocol + self.protocol = kwargs.get('protocol', self.xbmc_remote_protocol) + + # Track whether or not we want to send an image with our notification + # or not. + self.include_image = include_image + + def _payload_60(self, title, body, notify_type, **kwargs): + """ + Builds payload for KODI API v6.0 + + Returns (headers, payload) + """ + + # prepare JSON Object + payload = { + 'jsonrpc': '2.0', + 'method': 'GUI.ShowNotification', + 'params': { + 'title': title, + 'message': body, + # displaytime is defined in microseconds so we need to just + # do some simple math + 'displaytime': int(self.duration * 1000), + }, + 'id': 1, + } + + # 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: + payload['params']['image'] = image_url + if notify_type is NotifyType.FAILURE: + payload['type'] = 'error' + + elif notify_type is NotifyType.WARNING: + payload['type'] = 'warning' + + else: + payload['type'] = 'info' + + return (self.headers, dumps(payload)) + + def _payload_20(self, title, body, notify_type, **kwargs): + """ + Builds payload for XBMC API v2.0 + + Returns (headers, payload) + """ + + # prepare JSON Object + payload = { + 'jsonrpc': '2.0', + 'method': 'GUI.ShowNotification', + 'params': { + 'title': title, + 'message': body, + # displaytime is defined in microseconds so we need to just + # do some simple math + 'displaytime': int(self.duration * 1000), + }, + 'id': 1, + } + + # Include our logo if configured to do so + image_url = None if not self.include_image \ + else self.image_url(notify_type) + + if image_url: + payload['params']['image'] = image_url + + return (self.headers, dumps(payload)) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform XBMC/KODI Notification + """ + + if self.protocol == self.xbmc_remote_protocol: + # XBMC v2.0 + (headers, payload) = self._payload_20( + title, body, notify_type, **kwargs) + + else: + # KODI v6.0 + (headers, payload) = self._payload_60( + title, body, notify_type, **kwargs) + + auth = None + if self.user: + auth = (self.user, self.password) + + url = '%s://%s' % (self.schema, self.host) + if self.port: + url += ':%d' % self.port + + url += '/jsonrpc' + + self.logger.debug('XBMC/KODI POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('XBMC/KODI Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + url, + data=payload, + headers=headers, + auth=auth, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyXBMC.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send XBMC/KODI notification: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info('Sent XBMC/KODI notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending XBMC/KODI ' + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + 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', + 'duration': str(self.duration), + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=NotifyXBMC.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=NotifyXBMC.quote(self.user, safe=''), + ) + + default_schema = self.xbmc_protocol if ( + self.protocol <= self.xbmc_remote_protocol) else self.kodi_protocol + default_port = 443 if self.secure else self.xbmc_default_port + if self.secure: + # Append 's' to schema + default_schema += 's' + + return '{schema}://{auth}{hostname}{port}/?{params}'.format( + schema=default_schema, + auth=auth, + # 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), + params=NotifyXBMC.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 + return results + + # We want to set our protocol depending on whether we're using XBMC + # or KODI + if results.get('schema', '').startswith('xbmc'): + # XBMC Support + results['protocol'] = NotifyXBMC.xbmc_remote_protocol + + # Assign Default XBMC Port + if not results['port']: + results['port'] = NotifyXBMC.xbmc_default_port + + else: + # KODI Support + results['protocol'] = NotifyXBMC.kodi_remote_protocol + + # Include images with our message + results['include_image'] = \ + parse_bool(results['qsd'].get('image', True)) + + # Set duration + try: + results['duration'] = abs(int(results['qsd'].get('duration'))) + + except (TypeError, ValueError): + # Not a valid integer; ignore entry + pass + + return results diff --git a/lib/apprise/plugins/NotifyXML.py b/lib/apprise/plugins/NotifyXML.py new file mode 100644 index 0000000..20eeb11 --- /dev/null +++ b/lib/apprise/plugins/NotifyXML.py @@ -0,0 +1,499 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 re +import requests +import base64 + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyImageSize +from ..common import NotifyType +from ..AppriseLocale import gettext_lazy as _ + + +class XMLPayloadField: + """ + Identifies the fields available in the JSON Payload + """ + VERSION = 'Version' + TITLE = 'Subject' + MESSAGE = 'Message' + MESSAGETYPE = 'MessageType' + + +# Defines the method to send the notification +METHODS = ( + 'POST', + 'GET', + 'DELETE', + 'PUT', + 'HEAD', + 'PATCH' +) + + +class NotifyXML(NotifyBase): + """ + A wrapper for XML Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'XML' + + # The default protocol + protocol = 'xml' + + # The default secure protocol + secure_protocol = 'xmls' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_Custom_XML' + + # Support attachments + attachment_support = True + + # Allows the user to specify the NotifyImageSize object + image_size = NotifyImageSize.XY_128 + + # Disable throttle rate for JSON requests since they are normally + # local anyway + request_rate_per_sec = 0 + + # XSD Information + xsd_ver = '1.1' + xsd_default_url = \ + 'https://raw.githubusercontent.com/caronc/apprise/master' \ + '/apprise/assets/NotifyXML-{version}.xsd' + + # Define object templates + templates = ( + '{schema}://{host}', + '{schema}://{host}:{port}', + '{schema}://{user}@{host}', + '{schema}://{user}@{host}:{port}', + '{schema}://{user}:{password}@{host}', + '{schema}://{user}:{password}@{host}:{port}', + ) + + # Define our tokens; these are the minimum tokens required required to + # be passed into this function (as arguments). The syntax appends any + # previously defined in the base package and builds onto them + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'user': { + 'name': _('Username'), + 'type': 'string', + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + }, + + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'method': { + 'name': _('Fetch Method'), + 'type': 'choice:string', + 'values': METHODS, + 'default': METHODS[0], + }, + }) + + # Define any kwargs we're using + template_kwargs = { + 'headers': { + 'name': _('HTTP Header'), + 'prefix': '+', + }, + 'payload': { + 'name': _('Payload Extras'), + 'prefix': ':', + }, + 'params': { + 'name': _('GET Params'), + 'prefix': '-', + }, + } + + def __init__(self, headers=None, method=None, payload=None, params=None, + **kwargs): + """ + Initialize XML Object + + headers can be a dictionary of key/value pairs that you want to + additionally include as part of the server headers to post with + + """ + super().__init__(**kwargs) + + self.payload = """ + + + + {{CORE}} + {{ATTACHMENTS}} + + +""" + + self.fullpath = kwargs.get('fullpath') + if not isinstance(self.fullpath, str): + self.fullpath = '' + + self.method = self.template_args['method']['default'] \ + if not isinstance(method, str) else method.upper() + + if self.method not in METHODS: + msg = 'The method specified ({}) is invalid.'.format(method) + self.logger.warning(msg) + raise TypeError(msg) + + # A payload map allows users to over-ride the default mapping if + # they're detected with the :overide=value. Normally this would + # create a new key and assign it the value specified. However + # if the key you specify is actually an internally mapped one, + # then a re-mapping takes place using the value + self.payload_map = { + XMLPayloadField.VERSION: XMLPayloadField.VERSION, + XMLPayloadField.TITLE: XMLPayloadField.TITLE, + XMLPayloadField.MESSAGE: XMLPayloadField.MESSAGE, + XMLPayloadField.MESSAGETYPE: XMLPayloadField.MESSAGETYPE, + } + + self.params = {} + if params: + # Store our extra headers + self.params.update(params) + + self.headers = {} + if headers: + # Store our extra headers + self.headers.update(headers) + + self.payload_overrides = {} + self.payload_extras = {} + if payload: + # Store our extra payload entries (but tidy them up since they will + # become XML Keys (they can't contain certain characters + for k, v in payload.items(): + key = re.sub(r'[^A-Za-z0-9_-]*', '', k) + if not key: + self.logger.warning( + 'Ignoring invalid XML Stanza element name({})' + .format(k)) + continue + + # Any values set in the payload to alter a system related one + # alters the system key. Hence :message=msg maps the 'message' + # variable that otherwise already contains the payload to be + # 'msg' instead (containing the payload) + if key in self.payload_map: + self.payload_map[key] = v + self.payload_overrides[key] = v + + else: + self.payload_extras[key] = v + + # Set our xsd url + self.xsd_url = None if self.payload_overrides or self.payload_extras \ + else self.xsd_default_url.format(version=self.xsd_ver) + + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'method': self.method, + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Append our headers into our parameters + params.update({'+{}'.format(k): v for k, v in self.headers.items()}) + + # Append our GET params into our parameters + params.update({'-{}'.format(k): v for k, v in self.params.items()}) + + # Append our payload extra's into our parameters + params.update( + {':{}'.format(k): v for k, v in self.payload_extras.items()}) + params.update( + {':{}'.format(k): v for k, v in self.payload_overrides.items()}) + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=NotifyXML.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=NotifyXML.quote(self.user, safe=''), + ) + + default_port = 443 if self.secure else 80 + + return '{schema}://{auth}{hostname}{port}{fullpath}?{params}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + auth=auth, + # never encode hostname since we're expecting it to be a valid one + hostname=self.host, + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + fullpath=NotifyXML.quote(self.fullpath, safe='/') + if self.fullpath else '/', + params=NotifyXML.urlencode(params), + ) + + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): + """ + Perform XML Notification + """ + + # Prepare HTTP Headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/xml' + } + + # Apply any/all header over-rides defined + headers.update(self.headers) + + # Our XML Attachmement subsitution + xml_attachments = '' + + payload_base = {} + + for key, value in ( + (XMLPayloadField.VERSION, self.xsd_ver), + (XMLPayloadField.TITLE, NotifyXML.escape_html( + title, whitespace=False)), + (XMLPayloadField.MESSAGE, NotifyXML.escape_html( + body, whitespace=False)), + (XMLPayloadField.MESSAGETYPE, NotifyXML.escape_html( + notify_type, whitespace=False))): + + if not self.payload_map[key]: + # Do not store element in payload response + continue + payload_base[self.payload_map[key]] = value + + # Apply our payload extras + payload_base.update( + {k: NotifyXML.escape_html(v, whitespace=False) + for k, v in self.payload_extras.items()}) + + # Base Entres + xml_base = ''.join( + ['<{}>{}'.format(k, v, k) for k, v in payload_base.items()]) + + attachments = [] + if attach and self.attachment_support: + 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 + entry = \ + ''.format( + NotifyXML.escape_html( + attachment.name, whitespace=False), + NotifyXML.escape_html( + attachment.mimetype, whitespace=False)) + entry += base64.b64encode(f.read()).decode('utf-8') + entry += '' + attachments.append(entry) + + 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 + + # Update our xml_attachments record: + xml_attachments = \ + '' + \ + ''.join(attachments) + '' + + re_map = { + '{{XSD_URL}}': + f' xmlns:xsi="{self.xsd_url}"' if self.xsd_url else '', + '{{ATTACHMENTS}}': xml_attachments, + '{{CORE}}': xml_base, + } + + # Iterate over above list and store content accordingly + re_table = re.compile( + r'(' + '|'.join(re_map.keys()) + r')', + re.IGNORECASE, + ) + + auth = None + if self.user: + auth = (self.user, self.password) + + # Set our schema + schema = 'https' if self.secure else 'http' + + url = '%s://%s' % (schema, self.host) + if isinstance(self.port, int): + url += ':%d' % self.port + + url += self.fullpath + payload = re_table.sub(lambda x: re_map[x.group()], self.payload) + + self.logger.debug('XML POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + + self.logger.debug('XML Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + if self.method == 'GET': + method = requests.get + + elif self.method == 'PUT': + method = requests.put + + elif self.method == 'PATCH': + method = requests.patch + + elif self.method == 'DELETE': + method = requests.delete + + elif self.method == 'HEAD': + method = requests.head + + else: # POST + method = requests.post + + try: + r = method( + url, + data=payload, + headers=headers, + auth=auth, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code < 200 or r.status_code >= 300: + # We had a problem + status_str = \ + NotifyXML.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send JSON %s notification: %s%serror=%s.', + self.method, + status_str, + ', ' if status_str else '', + str(r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info('Sent XML %s notification.', self.method) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending XML ' + 'notification to %s.' % self.host) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + @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 + + # store any additional payload extra's defined + results['payload'] = {NotifyXML.unquote(x): NotifyXML.unquote(y) + for x, y in results['qsd:'].items()} + + # Add our headers that the user can potentially over-ride if they wish + # to to our returned result set and tidy entries by unquoting them + results['headers'] = {NotifyXML.unquote(x): NotifyXML.unquote(y) + for x, y in results['qsd+'].items()} + + # Add our GET paramters in the event the user wants to pass these along + results['params'] = {NotifyXML.unquote(x): NotifyXML.unquote(y) + for x, y in results['qsd-'].items()} + + # Set method if not otherwise set + if 'method' in results['qsd'] and len(results['qsd']['method']): + results['method'] = NotifyXML.unquote(results['qsd']['method']) + + return results diff --git a/lib/apprise/plugins/NotifyZulip.py b/lib/apprise/plugins/NotifyZulip.py new file mode 100644 index 0000000..f0d0cd8 --- /dev/null +++ b/lib/apprise/plugins/NotifyZulip.py @@ -0,0 +1,402 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. + +# To use this plugin, you must have a ZulipChat bot defined; See here: +# https://zulipchat.com/help/add-a-bot-or-integration +# +# At the time of writing this plugin the instructions were: +# 1. From your desktop, click on the gear icon in the upper right corner. +# 2. Select Settings. +# 3. On the left, click Your bots. +# 4. Click Add a new bot. +# 5. Fill out the fields, and click Create bot. + +# If you know your organization {ID} (as it's part of the zulipchat.com url +# after you signup, then you can also access your bot information by visting: +# https://ID.zulipchat.com/#settings/your-bots + +# For example, I create an organization called apprise. Thus my URL would be +# https://apprise.zulipchat.com/#settings/your-bots + +# When you're done and have a bot, it's important to remember the username +# you provided the bot and the API key generated. +# +# If your {user} was : goober-bot@apprise.zulipchat.com +# and your {apikey} was: lqn6mpwpam6VZzbCW0o7olmk3hwbQSK +# +# Then the following URLs would be accepted by Apprise: +# - zulip://goober-bot@apprise.zulipchat.com/lqn6mpwpam6VZzbCW0o7olmk3hwbQSK +# - zulip://goober-bot@apprise/lqn6mpwpam6VZzbCW0o7olmk3hwbQSK +# - zulip://goober@apprise/lqn6mpwpam6VZzbCW0o7olmk3hwbQSK +# - zulip://goober@apprise.zulipchat.com/lqn6mpwpam6VZzbCW0o7olmk3hwbQSK + +# The API reference used to build this plugin was documented here: +# https://zulipchat.com/api/send-message +# +import re +import requests + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import parse_list +from ..utils import validate_regex +from ..utils import is_email +from ..utils import remove_suffix +from ..AppriseLocale import gettext_lazy as _ + +# A Valid Bot Name +VALIDATE_BOTNAME = re.compile(r'(?P[A-Z0-9_-]{1,32})', re.I) + +# Organization required as part of the API request +VALIDATE_ORG = re.compile( + r'(?P[A-Z0-9_-]{1,32})(\.(?P[^\s]+))?', re.I) + +# Extend HTTP Error Messages +ZULIP_HTTP_ERROR_MAP = { + 401: 'Unauthorized - Invalid Token.', +} + +# Used to break path apart into list of streams +TARGET_LIST_DELIM = re.compile(r'[ \t\r\n,#\\/]+') + +# Used to detect a streams +IS_VALID_TARGET_RE = re.compile( + r'#?(?P[A-Z0-9_]{1,32})', re.I) + + +class NotifyZulip(NotifyBase): + """ + A wrapper for Zulip Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Zulip' + + # The services URL + service_url = 'https://zulipchat.com/' + + # The default secure protocol + secure_protocol = 'zulip' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_zulip' + + # Zulip uses the http protocol with JSON requests + notify_url = 'https://{org}.{hostname}/api/v1/messages' + + # The maximum allowable characters allowed in the title per message + title_maxlen = 60 + + # The maximum allowable characters allowed in the body per message + body_maxlen = 10000 + + # Define object templates + templates = ( + '{schema}://{botname}@{organization}/{token}', + '{schema}://{botname}@{organization}/{token}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'botname': { + 'name': _('Bot Name'), + 'type': 'string', + 'regex': (r'^[A-Z0-9_-]{1,32}$', 'i'), + 'required': True, + }, + 'organization': { + 'name': _('Organization'), + 'type': 'string', + 'required': True, + 'regex': (r'^[A-Z0-9_-]{1,32})$', 'i') + }, + 'token': { + 'name': _('Token'), + 'type': 'string', + 'required': True, + 'private': True, + 'regex': (r'^[A-Z0-9]{32}$', 'i'), + }, + 'target_user': { + 'name': _('Target User'), + 'type': 'string', + 'map_to': 'targets', + }, + 'target_stream': { + 'name': _('Target Stream'), + 'type': 'string', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + }) + + # The default hostname to append to a defined organization + # if one isn't defined in the apprise url + default_hostname = 'zulipchat.com' + + # The default stream to notify if no targets are specified + default_notification_stream = 'general' + + def __init__(self, botname, organization, token, targets=None, **kwargs): + """ + Initialize Zulip Object + """ + super().__init__(**kwargs) + + # our default hostname + self.hostname = self.default_hostname + + try: + match = VALIDATE_BOTNAME.match(botname.strip()) + if not match: + # let outer exception handle this + raise TypeError + + # The botname + botname = match.group('name') + botname = remove_suffix(botname, '-bot') + self.botname = botname + + except (TypeError, AttributeError): + msg = 'The Zulip botname specified ({}) is invalid.'\ + .format(botname) + self.logger.warning(msg) + raise TypeError(msg) + + try: + match = VALIDATE_ORG.match(organization.strip()) + if not match: + # let outer exception handle this + raise TypeError + + # The organization + self.organization = match.group('org') + if match.group('hostname'): + self.hostname = match.group('hostname') + + except (TypeError, AttributeError): + msg = 'The Zulip organization specified ({}) is invalid.'\ + .format(organization) + self.logger.warning(msg) + raise TypeError(msg) + + self.token = validate_regex( + token, *self.template_tokens['token']['regex']) + if not self.token: + msg = 'The Zulip token specified ({}) is invalid.'\ + .format(token) + self.logger.warning(msg) + raise TypeError(msg) + + self.targets = parse_list(targets) + if len(self.targets) == 0: + # No streams identified, use default + self.targets.append(self.default_notification_stream) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Zulip Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', + } + + # error tracking (used for function return) + has_error = False + + # Prepare our notification URL + url = self.notify_url.format( + org=self.organization, + hostname=self.hostname, + ) + + # prepare JSON Object + payload = { + 'subject': title, + 'content': body, + } + + # Determine Authentication + auth = ( + '{botname}-bot@{org}.{hostname}'.format( + botname=self.botname, + org=self.organization, + hostname=self.hostname, + ), + self.token, + ) + + # Create a copy of the target list + targets = list(self.targets) + while len(targets): + target = targets.pop(0) + result = is_email(target) + if result: + # Send a private message + payload['type'] = 'private' + else: + # Send a stream message + payload['type'] = 'stream' + + # Set our target + payload['to'] = target if not result else result['full_email'] + + self.logger.debug('Zulip POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('Zulip Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + url, + data=payload, + headers=headers, + auth=auth, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyZulip.http_response_code_lookup( + r.status_code, ZULIP_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send Zulip notification to {}: ' + '{}{}error={}.'.format( + target, + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent Zulip notification to {}.'.format(target)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Zulip ' + 'notification to {}.'.format(target)) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + return not has_error + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Our URL parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + # simplify our organization in our URL if we can + organization = '{}{}'.format( + self.organization, + '.{}'.format(self.hostname) + if self.hostname != self.default_hostname else '') + + return '{schema}://{botname}@{org}/{token}/' \ + '{targets}?{params}'.format( + schema=self.secure_protocol, + botname=NotifyZulip.quote(self.botname, safe=''), + org=NotifyZulip.quote(organization, safe=''), + token=self.pprint(self.token, privacy, safe=''), + targets='/'.join( + [NotifyZulip.quote(x, safe='') for x in self.targets]), + params=NotifyZulip.urlencode(params), + ) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return 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 + + # The botname + results['botname'] = NotifyZulip.unquote(results['user']) + + # The first token is stored in the hostname + results['organization'] = NotifyZulip.unquote(results['host']) + + # Now fetch the remaining tokens + try: + results['token'] = \ + NotifyZulip.split_path(results['fullpath'])[0] + + except IndexError: + # no token + results['token'] = None + + # Get unquoted entries + results['targets'] = NotifyZulip.split_path(results['fullpath'])[1:] + + # Support the 'to' variable so that we can support rooms this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += [x for x in filter( + bool, TARGET_LIST_DELIM.split( + NotifyZulip.unquote(results['qsd']['to'])))] + + return results diff --git a/lib/apprise/plugins/__init__.py b/lib/apprise/plugins/__init__.py new file mode 100644 index 0000000..27afef0 --- /dev/null +++ b/lib/apprise/plugins/__init__.py @@ -0,0 +1,575 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 os +import re +import copy + +from os.path import dirname +from os.path import abspath + +# Used for testing +from .NotifyBase import NotifyBase + +from ..common import NotifyImageSize +from ..common import NOTIFY_IMAGE_SIZES +from ..common import NotifyType +from ..common import NOTIFY_TYPES +from .. import common +from ..utils import parse_list +from ..utils import cwe312_url +from ..utils import GET_SCHEMA_RE +from ..logger import logger +from ..AppriseLocale import gettext_lazy as _ +from ..AppriseLocale import LazyTranslation + +__all__ = [ + # Reference + 'NotifyImageSize', 'NOTIFY_IMAGE_SIZES', 'NotifyType', 'NOTIFY_TYPES', + 'NotifyBase', + + # Tokenizer + 'url_to_dict', +] + + +# Load our Lookup Matrix +def __load_matrix(path=abspath(dirname(__file__)), name='apprise.plugins'): + """ + Dynamically load our schema map; this allows us to gracefully + skip over modules we simply don't have the dependencies for. + + """ + # 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'^(?PNotify[a-z0-9]+)(\.py)?$', re.I) + + for f in os.listdir(path): + match = module_re.match(f) + if not match: + # keep going + continue + + # Store our notification/plugin name: + plugin_name = match.group('name') + try: + module = __import__( + '{}.{}'.format(name, plugin_name), + globals(), locals(), + fromlist=[plugin_name]) + + except ImportError: + # No problem, we can't use this object + continue + + if not hasattr(module, plugin_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. + continue + + # Get our plugin + plugin = getattr(module, plugin_name) + if not hasattr(plugin, 'app_id'): + # Filter out non-notification modules + continue + + elif plugin_name in common.NOTIFY_MODULE_MAP: + # we're already handling this object + continue + + # Add our plugin name to our module map + common.NOTIFY_MODULE_MAP[plugin_name] = { + 'plugin': plugin, + 'module': module, + } + + # Add our module name to our __all__ + __all__.append(plugin_name) + + 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 common.NOTIFY_SCHEMA_MAP: + logger.error( + "Notification schema ({}) mismatch detected - {} to {}" + .format(schema, common.NOTIFY_SCHEMA_MAP[schema], plugin)) + continue + + # Assign plugin + common.NOTIFY_SCHEMA_MAP[schema] = plugin + + return common.NOTIFY_SCHEMA_MAP + + +# Reset our Lookup Matrix +def __reset_matrix(): + """ + Restores the Lookup matrix to it's base setting. This is only used through + testing and should not be directly called. + """ + + # Reset our schema map + common.NOTIFY_SCHEMA_MAP.clear() + + # Iterate over our module map so we can clear out our __all__ and globals + for plugin_name in common.NOTIFY_MODULE_MAP.keys(): + + # Remove element from plugins + __all__.remove(plugin_name) + + # Clear out our module map + common.NOTIFY_MODULE_MAP.clear() + + +# Dynamically build our schema base +__load_matrix() + + +def _sanitize_token(tokens, default_delimiter): + """ + This is called by the details() function and santizes the output by + populating expected and consistent arguments if they weren't otherwise + specified. + + """ + + # Used for tracking groups + group_map = {} + + # Iterate over our tokens + for key in tokens.keys(): + + for element in tokens[key].keys(): + # Perform translations (if detected to do so) + if isinstance(tokens[key][element], LazyTranslation): + tokens[key][element] = str(tokens[key][element]) + + if 'alias_of' in tokens[key]: + # Do not touch this field + continue + + if 'map_to' not in tokens[key]: + # Default type to key + tokens[key]['map_to'] = key + + # Track our map_to objects + if tokens[key]['map_to'] not in group_map: + group_map[tokens[key]['map_to']] = set() + group_map[tokens[key]['map_to']].add(key) + + if 'type' not in tokens[key]: + # Default type to string + tokens[key]['type'] = 'string' + + elif tokens[key]['type'].startswith('list'): + if 'delim' not in tokens[key]: + # Default list delimiter (if not otherwise specified) + tokens[key]['delim'] = default_delimiter + + if key in group_map[tokens[key]['map_to']]: # pragma: no branch + # Remove ourselves from the list + group_map[tokens[key]['map_to']].remove(key) + + # Pointing to the set directly so we can dynamically update + # ourselves + tokens[key]['group'] = group_map[tokens[key]['map_to']] + + elif tokens[key]['type'].startswith('choice') \ + and 'default' not in tokens[key] \ + and 'values' in tokens[key] \ + and len(tokens[key]['values']) == 1: + + # If there is only one choice; then make it the default + # - support dictionaries too + tokens[key]['default'] = tokens[key]['values'][0] \ + if not isinstance(tokens[key]['values'], dict) \ + else next(iter(tokens[key]['values'])) + + if 'values' in tokens[key] and isinstance(tokens[key]['values'], dict): + # Convert values into a list if it was defined as a dictionary + tokens[key]['values'] = [k for k in tokens[key]['values'].keys()] + + if 'regex' in tokens[key]: + # Verify that we are a tuple; convert strings to tuples + if isinstance(tokens[key]['regex'], str): + # Default tuple setup + tokens[key]['regex'] = \ + (tokens[key]['regex'], None) + + elif not isinstance(tokens[key]['regex'], (list, tuple)): + # Invalid regex + del tokens[key]['regex'] + + if 'required' not in tokens[key]: + # Default required is False + tokens[key]['required'] = False + + if 'private' not in tokens[key]: + # Private flag defaults to False if not set + tokens[key]['private'] = False + return + + +def details(plugin): + """ + Provides templates that can be used by developers to build URLs + dynamically. + + If a list of templates is provided, then they will be used over + the default value. + + If a list of tokens are provided, then they will over-ride any + additional settings built from this script and/or will be appended + to them afterwards. + """ + + # Our unique list of parsing will be based on the provided templates + # if none are provided we will use our own + templates = tuple(plugin.templates) + + # The syntax is simple + # { + # # The token_name must tie back to an entry found in the + # # templates list. + # 'token_name': { + # + # # types can be 'string', 'int', 'choice', 'list, 'float' + # # both choice and list may additionally have a : identify + # # what the list/choice type is comprised of; the default + # # is string. + # 'type': 'choice:string', + # + # # values will only exist the type must be a fixed + # # list of inputs (generated from type choice for example) + # + # # If this is a choice:bool then you should ALWAYS define + # # this list as a (True, False) such as ('Yes, 'No') or + # # ('Enabled', 'Disabled'), etc + # 'values': [ 'http', 'https' ], + # + # # Identifies if the entry specified is required or not + # 'required': True, + # + # # Identifies all tokens detected to be associated with the + # # list:string + # # This is ony present in list:string objects and is only set + # # if this element acts as an alias for several other + # # kwargs/fields. + # 'group': [], + # + # # Identify a default value + # 'default': 'http', + # + # # Optional Verification Entries min and max are for floats + # # and/or integers + # 'min': 4, + # 'max': 5, + # + # # A list will always identify a delimiter. If this is + # # part of a path, this may be a '/', or it could be a + # # comma and/or space. delimiters are always in a list + # # eg (if space and/or comma is a delimiter the entry + # # would look like: 'delim': [',' , ' ' ] + # 'delim': None, + # + # # Use regex if you want to share the regular expression + # # required to validate the field. The regex will never + # # accomodate the prefix (if one is specified). That is + # # up to the user building the URLs to include the prefix + # # on the URL when constructing it. + # # The format is ('regex', 'reg options') + # 'regex': (r'[A-Z0-9]+', 'i'), + # + # # A Prefix is always a string, to differentiate between + # # multiple arguments, sometimes content is prefixed. + # 'prefix': '@', + # + # # By default the key of this object is to be interpreted + # # as the argument to the notification in question. However + # # To accomodate cases where there are multiple types that + # # all map to the same entry, one can find a map_to value. + # 'map_to': 'function_arg', + # + # # Some arguments act as an alias_of an already defined object + # # This plays a role more with configuration file generation + # # since yaml files allow you to define different argumuments + # # in line to simplify things. If this directive is set, then + # # it should be treated exactly the same as the object it is + # # an alias of + # 'alias_of': 'function_arg', + # + # # Advise developers to consider the potential sensitivity + # # of this field owned by the user. This is for passwords, + # # and api keys, etc... + # 'private': False, + # }, + # } + + # Template tokens identify the arguments required to initialize the + # plugin itself. It identifies all of the tokens and provides some + # details on their use. Each token defined should in some way map + # back to at least one URL {token} defined in the templates + + # Since we nest a dictionary within a dictionary, a simple copy isn't + # enough. a deepcopy allows us to manipulate this object in this + # funtion without obstructing the original. + template_tokens = copy.deepcopy(plugin.template_tokens) + + # Arguments and/or Options either have a default value and/or are + # optional to be set. + # + # Since we nest a dictionary within a dictionary, a simple copy isn't + # enough. a deepcopy allows us to manipulate this object in this + # funtion without obstructing the original. + template_args = copy.deepcopy(plugin.template_args) + + # Our template keyword arguments ?+key=value&-key=value + # Basically the user provides both the key and the value. this is only + # possibly by identifying the key prefix required for them to be + # interpreted hence the +/- keys are built into apprise by default for easy + # reference. In these cases, entry might look like '+' being the prefix: + # { + # 'arg_name': { + # 'name': 'label', + # 'prefix': '+', + # } + # } + # + # Since we nest a dictionary within a dictionary, a simple copy isn't + # enough. a deepcopy allows us to manipulate this object in this + # funtion without obstructing the original. + template_kwargs = copy.deepcopy(plugin.template_kwargs) + + # We automatically create a schema entry + template_tokens['schema'] = { + 'name': _('Schema'), + 'type': 'choice:string', + 'required': True, + 'values': parse_list(plugin.secure_protocol, plugin.protocol) + } + + # Sanitize our tokens + _sanitize_token(template_tokens, default_delimiter=('/', )) + # Delimiter(s) are space and/or comma + _sanitize_token(template_args, default_delimiter=(',', ' ')) + _sanitize_token(template_kwargs, default_delimiter=(',', ' ')) + + # Argument/Option Handling + for key in list(template_args.keys()): + + if 'alias_of' in template_args[key]: + # Check if the mapped reference is a list; if it is, then + # we need to store a different delimiter + alias_of = template_tokens.get(template_args[key]['alias_of'], {}) + if alias_of.get('type', '').startswith('list') \ + and 'delim' not in template_args[key]: + # Set a default delimiter of a comma and/or space if one + # hasn't already been specified + template_args[key]['delim'] = (',', ' ') + + # _lookup_default looks up what the default value + if '_lookup_default' in template_args[key]: + template_args[key]['default'] = getattr( + plugin, template_args[key]['_lookup_default']) + + # Tidy as we don't want to pass this along in response + del template_args[key]['_lookup_default'] + + # _exists_if causes the argument to only exist IF after checking + # the return of an internal variable requiring a check + if '_exists_if' in template_args[key]: + if not getattr(plugin, + template_args[key]['_exists_if']): + # Remove entire object + del template_args[key] + + else: + # We only nee to remove this key + del template_args[key]['_exists_if'] + + return { + 'templates': templates, + 'tokens': template_tokens, + 'args': template_args, + 'kwargs': template_kwargs, + } + + +def requirements(plugin): + """ + Provides a list of packages and its requirement details + + """ + requirements = { + # Use the description to provide a human interpretable description of + # what is required to make the plugin work. This is only nessisary + # if there are package dependencies + 'details': '', + + # Define any required packages needed for the plugin to run. This is + # an array of strings that simply look like lines in the + # `requirements.txt` file... + # + # A single string is perfectly acceptable: + # 'packages_required' = 'cryptography' + # + # Multiple entries should look like the following + # 'packages_required' = [ + # 'cryptography < 3.4`, + # ] + # + 'packages_required': [], + + # Recommended packages identify packages that are not required to make + # your plugin work, but would improve it's use or grant it access to + # full functionality (that might otherwise be limited). + + # Similar to `packages_required`, you would identify each entry in + # the array as you would in a `requirements.txt` file. + # + # - Do not re-provide entries already in the `packages_required` + 'packages_recommended': [], + } + + # Populate our template differently if we don't find anything above + if not (hasattr(plugin, 'requirements') + and isinstance(plugin.requirements, dict)): + # We're done early + return requirements + + # Get our required packages + _req_packages = plugin.requirements.get('packages_required') + if isinstance(_req_packages, str): + # Convert to list + _req_packages = [_req_packages] + + elif not isinstance(_req_packages, (set, list, tuple)): + # Allow one to set the required packages to None (as an example) + _req_packages = [] + + requirements['packages_required'] = [str(p) for p in _req_packages] + + # Get our recommended packages + _opt_packages = plugin.requirements.get('packages_recommended') + if isinstance(_opt_packages, str): + # Convert to list + _opt_packages = [_opt_packages] + + elif not isinstance(_opt_packages, (set, list, tuple)): + # Allow one to set the recommended packages to None (as an example) + _opt_packages = [] + + requirements['packages_recommended'] = [str(p) for p in _opt_packages] + + # Get our package details + _req_details = plugin.requirements.get('details') + if not _req_details: + if not (_req_packages or _opt_packages): + _req_details = _('No dependencies.') + + elif _req_packages: + _req_details = _('Packages are required to function.') + + else: # opt_packages + _req_details = \ + _('Packages are recommended to improve functionality.') + else: + # Store our details if defined + requirements['details'] = _req_details + + # Return our compiled package requirements + return requirements + + +def url_to_dict(url, secure_logging=True): + """ + Takes an apprise URL and returns the tokens associated with it + if they can be acquired based on the plugins available. + + None is returned if the URL could not be parsed, otherwise the + tokens are returned. + + These tokens can be loaded into apprise through it's add() + function. + """ + + # swap hash (#) tag values with their html version + _url = url.replace('/#', '/%23') + + # CWE-312 (Secure Logging) Handling + loggable_url = url if not secure_logging else cwe312_url(url) + + # Attempt to acquire the schema at the very least to allow our plugins to + # determine if they can make a better interpretation of a URL geared for + # them. + schema = GET_SCHEMA_RE.match(_url) + if schema is None: + # Not a valid URL; take an early exit + logger.error('Unsupported URL: {}'.format(loggable_url)) + return None + + # Ensure our schema is always in lower case + schema = schema.group('schema').lower() + if schema not in common.NOTIFY_SCHEMA_MAP: + # Give the user the benefit of the doubt that the user may be using + # one of the URLs provided to them by their notification service. + # Before we fail for good, just scan all the plugins that support the + # native_url() parse function + results = \ + next((r['plugin'].parse_native_url(_url) + for r in common.NOTIFY_MODULE_MAP.values() + if r['plugin'].parse_native_url(_url) is not None), + None) + + if not results: + logger.error('Unparseable URL {}'.format(loggable_url)) + return None + + logger.trace('URL {} unpacked as:{}{}'.format( + url, os.linesep, os.linesep.join( + ['{}="{}"'.format(k, v) for k, v in results.items()]))) + + else: + # Parse our url details of the server object as dictionary + # containing all of the information parsed from our URL + results = common.NOTIFY_SCHEMA_MAP[schema].parse_url(_url) + if not results: + logger.error('Unparseable {} URL {}'.format( + common.NOTIFY_SCHEMA_MAP[schema].service_name, loggable_url)) + return None + + logger.trace('{} URL {} unpacked as:{}{}'.format( + common.NOTIFY_SCHEMA_MAP[schema].service_name, url, + os.linesep, os.linesep.join( + ['{}="{}"'.format(k, v) for k, v in results.items()]))) + + # Return our results + return results diff --git a/lib/apprise/py.typed b/lib/apprise/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/lib/apprise/utils.py b/lib/apprise/utils.py new file mode 100644 index 0000000..8d644ce --- /dev/null +++ b/lib/apprise/utils.py @@ -0,0 +1,1710 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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 re +import sys +import json +import contextlib +import os +import hashlib +import locale +from itertools import chain +from os.path import expanduser +from functools import reduce +from . import common +from .logger import logger + +from urllib.parse import unquote +from urllib.parse import quote +from urllib.parse import urlparse +from urllib.parse import urlencode as _urlencode + +import importlib.util + + +def import_module(path, name): + """ + Load our module based on path + """ + spec = importlib.util.spec_from_file_location(name, path) + try: + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + + spec.loader.exec_module(module) + + except Exception as e: + # module isn't loadable + try: + del sys.modules[name] + + except KeyError: + # nothing to clean up + pass + + module = None + + logger.debug( + 'Custom module exception raised from %s (name=%s) %s', + path, name, str(e)) + + return module + + +# Hash of all paths previously scanned so we don't waste effort/overhead doing +# it again +PATHS_PREVIOUSLY_SCANNED = set() + +# URL Indexing Table for returns via parse_url() +# The below accepts and scans for: +# - schema:// +# - schema://path +# - schema://path?kwargs +# +VALID_URL_RE = re.compile( + r'^[\s]*((?P[^:\s]+):[/\\]+)?((?P[^?]+)' + r'(\?(?P.+))?)?[\s]*$', +) +VALID_QUERY_RE = re.compile(r'^(?P.*[/\\])(?P[^/\\]+)?$') + +# delimiters used to separate values when content is passed in by string. +# This is useful when turning a string into a list +STRING_DELIMITERS = r'[\[\]\;,\s]+' + +# Pre-Escape content since we reference it so much +ESCAPED_PATH_SEPARATOR = re.escape('\\/') +ESCAPED_WIN_PATH_SEPARATOR = re.escape('\\') +ESCAPED_NUX_PATH_SEPARATOR = re.escape('/') + +TIDY_WIN_PATH_RE = re.compile( + r'(^[%s]{2}|[^%s\s][%s]|[\s][%s]{2}])([%s]+)' % ( + ESCAPED_WIN_PATH_SEPARATOR, + ESCAPED_WIN_PATH_SEPARATOR, + ESCAPED_WIN_PATH_SEPARATOR, + ESCAPED_WIN_PATH_SEPARATOR, + ESCAPED_WIN_PATH_SEPARATOR, + ), +) +TIDY_WIN_TRIM_RE = re.compile( + r'^(.+[^:][^%s])[\s%s]*$' % ( + ESCAPED_WIN_PATH_SEPARATOR, + ESCAPED_WIN_PATH_SEPARATOR, + ), +) + +TIDY_NUX_PATH_RE = re.compile( + r'([%s])([%s]+)' % ( + ESCAPED_NUX_PATH_SEPARATOR, + ESCAPED_NUX_PATH_SEPARATOR, + ), +) + +TIDY_NUX_TRIM_RE = re.compile( + r'([^%s])[\s%s]+$' % ( + ESCAPED_NUX_PATH_SEPARATOR, + ESCAPED_NUX_PATH_SEPARATOR, + ), +) + +# The handling of custom arguments passed in the URL; we treat any +# argument (which would otherwise appear in the qsd area of our parse_url() +# function differently if they start with a +, - or : value +NOTIFY_CUSTOM_ADD_TOKENS = re.compile(r'^( |\+)(?P.*)\s*') +NOTIFY_CUSTOM_DEL_TOKENS = re.compile(r'^-(?P.*)\s*') +NOTIFY_CUSTOM_COLON_TOKENS = re.compile(r'^:(?P.*)\s*') + +# Used for attempting to acquire the schema if the URL can't be parsed. +GET_SCHEMA_RE = re.compile(r'\s*(?P[a-z0-9]{1,12})://.*$', re.I) + +# Used for validating that a provided entry is indeed a schema +# this is slightly different then the GET_SCHEMA_RE above which +# insists the schema is only valid with a :// entry. this one +# extrapolates the individual entries +URL_DETAILS_RE = re.compile( + r'\s*(?P[a-z0-9]{1,12})(://(?P.*))?$', re.I) + +# Regular expression based and expanded from: +# http://www.regular-expressions.info/email.html +# Extended to support colon (:) delimiter for parsing names from the URL +# such as: +# - 'Optional Name':user@example.com +# - 'Optional Name' +# +# The expression also parses the general email as well such as: +# - user@example.com +# - label+user@example.com +GET_EMAIL_RE = re.compile( + r'(([\s"\']+)?(?P[^:<\'"]+)?[:<\s\'"]+)?' + r'(?P((?P