mirror of
https://github.com/SickGear/SickGear.git
synced 2024-12-22 18:53:38 +00:00
Merge branch 'release/3.32.0'
This commit is contained in:
commit
283a9258d5
324 changed files with 22244 additions and 11049 deletions
30
CHANGES.md
30
CHANGES.md
|
@ -1,4 +1,32 @@
|
|||
### 3.31.2 (2024-06-23 18:20:00 UTC)
|
||||
### 3.32.0 (2024-06-25 21:15:00 UTC)
|
||||
|
||||
* Update apprise 1.6.0 (0c0d5da) to 1.8.0 (81caf92)
|
||||
* Update attr 23.1.0 (67e4ff2) to 23.2.0 (b393d79)
|
||||
* Update Beautiful Soup 4.12.2 (30c58a1) to 4.12.3 (7fb5175)
|
||||
* Update CacheControl 0.13.1 (783a338) to 0.14.0 (e2be0c2)
|
||||
* Update certifi 2024.02.02 to 2024.06.02
|
||||
* Update dateutil 2.8.2 (296d419) to 2.9.0.post0 (0353b78)
|
||||
* Update diskcache 5.6.3 (323787f) to 5.6.3 (ebfa37c)
|
||||
* Update feedparser 6.0.10 (9865dec) to 6.0.11 (efcb89b)
|
||||
* Update filelock 3.12.4 (c1163ae) to 3.14.0 (8556141)
|
||||
* Update idna library 3.4 (cab054c) to 3.7 (1d365e1)
|
||||
* Update imdbpie 5.6.4 (f695e87) to 5.6.5 (f8ed7a0)
|
||||
* Update package resource API 68.1.2 (1ef36f2) to 68.2.2 (8ad627d)
|
||||
* Update profilehooks module 1.12.1 (c3fc078) to 1.13.0.dev0 (99f8a31)
|
||||
* Update pytz 2023.3/2023c (488d3eb) to 2024.1/2024a (3680953)
|
||||
* Update Rarfile 4.1a1 (8a72967) to 4.2 (db1df33)
|
||||
* Update Requests library 2.31.0 (8812812) to 2.32.3 (0e322af)
|
||||
* Update Send2Trash 1.5.0 (66afce7) to 1.8.3 (91d0698)
|
||||
* Update Tornado Web Server 6.4 (b3f2a4b) to 6.4.1 (2a0e1d1)
|
||||
* Update unidecode module 1.3.6 (4141992) to 1.3.8 (dfe397d)
|
||||
* Update urllib3 2.0.7 (56f01e0) to 2.2.1 (54d6edf)
|
||||
* Change growl notifier location for Apprise update refactor
|
||||
* Change systemd remove py2 and add basic hardening options
|
||||
* Change alphabetically sort list at edit show/Exclude global ignore/require words
|
||||
* Add search on TVmaze, TMDb, or Trakt for other shows with the actor that is viewed on Person page
|
||||
|
||||
|
||||
### 3.31.2 (2024-06-23 18:20:00 UTC)
|
||||
|
||||
* Fix parsing show names that contain the word "parts" by making parser more restrictive
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
#import re
|
||||
#import sickgear
|
||||
#from sickgear import TVInfoAPI
|
||||
#from sickgear.indexers.indexer_config import TVINFO_TMDB, TVINFO_TRAKT, TVINFO_TVMAZE
|
||||
#from sickgear.helpers import anon_url
|
||||
#from sickgear.tv import PersonGenders
|
||||
#from sg_helpers import spoken_height
|
||||
|
@ -44,6 +45,7 @@
|
|||
#person-content .thumb{display:block}
|
||||
#person-content > .main-image{margin-bottom:19px}
|
||||
#person-content > .cast .cast-bg{height:300px; margin:0 auto; background:url(/images/poster-person.jpg) center center no-repeat}
|
||||
#character-content{margin-left:235px}
|
||||
</style>
|
||||
<%
|
||||
def param(visible=True, rid=None, cache_person=None, cache_char=None, person=None, role=None, tvid_prodid=None, thumb=None, oid=None, pid=None):
|
||||
|
@ -63,10 +65,6 @@ def param(visible=True, rid=None, cache_person=None, cache_char=None, person=Non
|
|||
%>
|
||||
<div id="person">
|
||||
<div id="person-content">
|
||||
<div class="main-image cast">
|
||||
<a class="thumb" href="$sbRoot/$param(rid=$person.ref_id(), cache_person=True, thumb=0, oid=$person.id)" rel="dialog"><img src="$sbRoot/$param(False, rid=$person.id, cache_person=True)" class="cast-bg"></a>
|
||||
</div>
|
||||
|
||||
<div class="intro">#slurp
|
||||
#set $gender = ''
|
||||
#if $PersonGenders.female == $person.gender#
|
||||
|
@ -77,6 +75,10 @@ def param(visible=True, rid=None, cache_person=None, cache_char=None, person=Non
|
|||
<h2><span class="name">$person.name</span>#if $age #<span class="age">($age)</span>#end if##if $gender #<span class="gender" title="Biological gender">$gender</span>#end if##if $person.deathday # †#end if#</h2>
|
||||
</div>
|
||||
|
||||
<div class="main-image cast">
|
||||
<a class="thumb" href="$sbRoot/$param(rid=$person.ref_id(), cache_person=True, thumb=0, oid=$person.id)" rel="dialog"><img src="$sbRoot/$param(False, rid=$person.id, cache_person=True)" class="cast-bg"></a>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#character-content .cast-bg{display:block; background-color:#181818; border:1px solid #181818; -moz-border-radius:10px; -webkit-border-radius:10px; border-radius:10px}
|
||||
#character-content .cast .cast-bg{height:200px; background:url(/images/poster-person.jpg) center center no-repeat}
|
||||
|
@ -97,7 +99,7 @@ def param(visible=True, rid=None, cache_person=None, cache_char=None, person=Non
|
|||
#if not $section_header
|
||||
#set $section_header = True
|
||||
<div id="character-content">
|
||||
<div style="margin:40px 0 7px">is known in your show list as,</div>
|
||||
<div style="margin:0 0 7px">is known in your show list as,</div>
|
||||
#end if
|
||||
|
||||
<div class="role-panel">
|
||||
|
@ -145,15 +147,17 @@ def param(visible=True, rid=None, cache_person=None, cache_char=None, person=Non
|
|||
#end if
|
||||
|
||||
<style>
|
||||
#vitals{clear:both}
|
||||
.vitals{clear:both}
|
||||
.details-title{width:90px !important}
|
||||
.details-info{margin-left:95px !important}
|
||||
.details-info.akas{max-height:100px; overflow:auto; min-width:300px; word-break:normal}
|
||||
.details-info i{font-style:normal; font-size:smaller}
|
||||
.links{display:block; padding:0}
|
||||
.links{display:block; padding:0; margin:3px 0 0}
|
||||
.links li{display: inline-block; padding:0 10px 0 0}
|
||||
.links img{margin-bottom: -1px; vertical-align:initial}
|
||||
</style>
|
||||
|
||||
<div class="#vitals" data-birthdate="$person.birthday" data-deathdate="$person.deathday">
|
||||
<div class="vitals" data-birthdate="$person.birthday" data-deathdate="$person.deathday">
|
||||
#if $person.real_name
|
||||
<div><span class="details-title">Real name</span><span class="details-info">$person.real_name</span></div>
|
||||
#end if
|
||||
|
@ -207,6 +211,22 @@ def param(visible=True, rid=None, cache_person=None, cache_char=None, person=Non
|
|||
</ul>
|
||||
</span>
|
||||
</div>
|
||||
#end if
|
||||
#set $src = (($TVINFO_TVMAZE, 'tvm'), ($TVINFO_TMDB, 'tmdb'), ($TVINFO_TRAKT, 'trakt'))
|
||||
#if any([$person.ids.get($cur_src) for ($cur_src, _) in $src])
|
||||
<div>
|
||||
<span class="details-title">Other shows</span>
|
||||
<span class="details-info">
|
||||
<ul class="links">
|
||||
#for ($cur_src, $cur_api) in $src
|
||||
#if $person.ids.get($cur_src)
|
||||
<img alt="$TVInfoAPI($cur_src).name" height="16" width="16" src="$sbRoot/images/$TVInfoAPI($cur_src).config['icon']">#slurp
|
||||
<li><a href="$sbRoot/add-shows/${cur_api}-person?person_${cur_api}_id=$person.ids.get($cur_src)">$TVInfoAPI($cur_src).name</a></li>
|
||||
#end if
|
||||
#end for
|
||||
</ul>
|
||||
</span>
|
||||
</div>
|
||||
#end if
|
||||
</div>
|
||||
|
||||
|
|
|
@ -166,7 +166,7 @@
|
|||
#set $options = ''
|
||||
#set $selected = ' selected=\"selected\"'
|
||||
#set $num_selected = 0
|
||||
#for $gw in $sickgear.IGNORE_WORDS:
|
||||
#for $gw in sorted($sickgear.IGNORE_WORDS, key=$str.lower):
|
||||
#set $sel_html = ''
|
||||
#if $gw in $show_obj.rls_global_exclude_ignore
|
||||
#set $sel_html = $selected
|
||||
|
@ -202,7 +202,7 @@
|
|||
#set $options = ''
|
||||
#set $selected = ' selected=\"selected\"'
|
||||
#set $num_selected = 0
|
||||
#for $gw in $sickgear.REQUIRE_WORDS:
|
||||
#for $gw in sorted($sickgear.REQUIRE_WORDS, key=$str.lower):
|
||||
#set $sel_html = ''
|
||||
#if $gw in $show_obj.rls_global_exclude_require
|
||||
#set $sel_html = $selected
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
#from sickgear import WEB_ROOT, THEME_NAME
|
||||
#from sickgear.common import *
|
||||
#from sickgear.helpers import anon_url, try_float
|
||||
#from lib.tvinfo_base import RoleTypes
|
||||
#from _23 import quote
|
||||
<% def sg_var(varname, default=False): return getattr(sickgear, varname, default) %>#slurp#
|
||||
<% def sg_str(varname, default=''): return getattr(sickgear, varname, default) %>#slurp#
|
||||
|
@ -10,7 +11,7 @@
|
|||
#set $mode = $kwargs and $kwargs.get('mode', '')
|
||||
#set $use_network = $kwargs.get('use_networks', False)
|
||||
#set $use_returning = 'returning' == mode
|
||||
#set $use_filter = $kwargs and $kwargs.get('use_filter', True)
|
||||
#set $use_filter = $kwargs and $kwargs.get('use_filter', True) and not $p_ref
|
||||
#set $use_ratings = $kwargs and $kwargs.get('use_ratings', True)
|
||||
#set $use_votes = $kwargs and $kwargs.get('use_votes', True)
|
||||
#set $term_vote = $kwargs and $kwargs.get('term_vote', 'Votes')
|
||||
|
@ -23,7 +24,10 @@
|
|||
#set sg_root = $getVar('sbRoot', WEB_ROOT)
|
||||
##
|
||||
#import os.path
|
||||
#set global $inc_ofi = True
|
||||
#include $os.path.join($sg_str('PROG_DIR'), 'gui/slick/interfaces/default/inc_top.tmpl')
|
||||
<script type="text/javascript" src="$sbRoot/js/cast.js?v=$sbPID"></script>
|
||||
|
||||
<script>
|
||||
var config = {
|
||||
homeSearchFocus: #echo ['!1','!0'][$sg_var('HOME_SEARCH_FOCUS', True)]#,
|
||||
|
@ -38,6 +42,62 @@
|
|||
$(this).css('cursor', 'help');
|
||||
$(this).qtip({
|
||||
show: {solo:true},
|
||||
// Change qTip to manual hide when it contains many roles to scroll
|
||||
hide: {event:(5 < $(this).closest('div.show-card').attr('data-nroles')) ? 'unfocus' : 'mouseleave'},
|
||||
events: { // Callback events
|
||||
render: function(event, api) {
|
||||
// Grab the tooltip element from the API
|
||||
var tooltip = api.elements.tooltip
|
||||
tooltip.bind('tooltipshow', function(event, api) {
|
||||
var showcardEl = $(api.target).closest('div.show-card')
|
||||
if ('1' === showcardEl.attr('data-ajax')) { // do a one time fetch
|
||||
var qtipEl = $(this).find('.qtip-content'),
|
||||
premiereEl = qtipEl.find('.premiere'),
|
||||
genreEl = qtipEl.find('.genre'),
|
||||
overviewEl = qtipEl.find('.overview'),
|
||||
oldestEl = $('#oldest'),
|
||||
newestEl = $('#newest');
|
||||
|
||||
// Set initial text
|
||||
overviewEl.html('Fetching overview...');
|
||||
$.getJSON($.SickGear.Root + '/add-shows/tvm-get-showinfo', {
|
||||
tvid_prodid: showcardEl.attr('data-id'),
|
||||
oldest_dt: $('#oldest').attr('data-oldest-dt'),
|
||||
newest_dt: $('#newest').attr('data-newest-dt'),
|
||||
},
|
||||
function (data) {
|
||||
if (undefined !== data.overview) {
|
||||
showcardEl.attr('data-ajax', '0'); // mark one time fetch as completed
|
||||
if (undefined !== data.oldest) {
|
||||
oldestEl.attr('data-oldest-dt', data.oldest_dt)
|
||||
oldestEl.html(data.oldest);
|
||||
} else if (undefined !== data.newest) {
|
||||
newestEl.attr('data-newest-dt', data.newest_dt)
|
||||
newestEl.html(data.newest);
|
||||
}
|
||||
var premiere = '';
|
||||
if (data.str_premiered.length) {
|
||||
showcardEl.attr('data-premiered', data.ord_premiered);
|
||||
premiere = "<span style='font-weight:bold;font-size:0.9em;color:#888'><em>First air" + (data.started_past ? 'ed' : 's') + ": " + data.str_premiered + "</em></span>";
|
||||
}
|
||||
if (data.genres) {
|
||||
genreEl.css('display', 'block');
|
||||
genreEl.find('em').html(data.genres);
|
||||
}
|
||||
overviewEl.html(data.overview);
|
||||
if (data.network.length) {
|
||||
premiere += "<span style='display:block;clear:both;font-weight:bold;font-size:0.9em;color:#888'><em>On: " + data.network + "</em></span>";
|
||||
}
|
||||
premiereEl.html(premiere);
|
||||
} else {
|
||||
overviewEl.html('Failed to fetch TVmaze overview' );
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
position: {viewport:$(window), my:'left center', adjust:{y: -10,x: 2 }},
|
||||
style: {tip: {corner:true, method:'polygon'}, classes:'qtip-rounded qtip-bootstrap qtip-shadow ui-tooltip-sb'}
|
||||
});
|
||||
|
@ -46,6 +106,8 @@
|
|||
$.ll.handleScroll();
|
||||
});
|
||||
|
||||
$('.nav').on('mouseover', function() {$('.service, .browse-image').qtip('hide')})
|
||||
|
||||
savePrefs = (function(){
|
||||
var showsort = [], showfilter = [];
|
||||
|
||||
|
@ -62,7 +124,7 @@
|
|||
});
|
||||
});
|
||||
|
||||
$(document).ready(function(){
|
||||
$(function() {
|
||||
// initialise combos for dirty page refreshes
|
||||
$('#showsort').val('#end raw#$saved_showsort_view#raw#');
|
||||
|
||||
|
@ -258,7 +320,7 @@ $(document).ready(function(){
|
|||
}
|
||||
});
|
||||
|
||||
$('.service, .browse-image').each(addQTip);
|
||||
$('.service, a.browse-image').each(addQTip);
|
||||
|
||||
if (config.homeSearchFocus) {
|
||||
$('#search_show_name').focus();
|
||||
|
@ -287,6 +349,13 @@ $(document).ready(function(){
|
|||
input.focus();
|
||||
}
|
||||
});
|
||||
|
||||
objectFitImages();
|
||||
|
||||
$('#person .person-bg').each(function(i, oImage){
|
||||
removeImageBackground(oImage);
|
||||
scaleImage(oImage);
|
||||
});
|
||||
});
|
||||
|
||||
#end raw
|
||||
|
@ -296,6 +365,10 @@ $(document).ready(function(){
|
|||
<style>
|
||||
#set theme_suffix = ('', '-dark')['dark' == $getVar('sbThemeName', THEME_NAME)]
|
||||
.bfr{position:absolute;left:-999px;top:-999px}.bfr img{width:16px;height:16px}.spinner{display:inline-block;width:16px;height:16px;background:url(${sg_root}/images/loading16${theme_suffix}.gif) no-repeat 0 0}
|
||||
#person{min-height:130px; height:auto; width:215px; margin:auto; display:block}
|
||||
.main-image{margin:15px auto}
|
||||
.person-bg{height:300px; width:215px; display:block; background-color:#181818 !important; border:1px solid #181818; object-fit: contain; font-family: 'object-fit: contain;'; -moz-border-radius:10px; -webkit-border-radius:10px; border-radius:10px; background:url(/images/poster-person.jpg) center center no-repeat}
|
||||
.person-bg{margin:0 auto !important}
|
||||
</style>
|
||||
<div class="bfr"><img src="$sg_root/images/loading16${theme_suffix}.gif" /></div>
|
||||
|
||||
|
@ -340,7 +413,7 @@ $(document).ready(function(){
|
|||
<option value="by_rating"#if 'by_rating' in $saved_showsort_sortby#$selected>> #else#>#end if#% Rating</option>
|
||||
#end if
|
||||
#if $use_ratings and $use_votes
|
||||
<option value="by_rating_votes"#if 'by_rating_votes' in $saved_showsort_sortby#$selected>> #else#>#end if#% Rating > Votes</option>
|
||||
<option value="by_rating_votes"#if 'by_rating_votes' in $saved_showsort_sortby#$selected>> #else#>#end if#% Rating > $term_vote</option>
|
||||
#end if
|
||||
</optgroup>
|
||||
</select>
|
||||
|
@ -463,12 +536,21 @@ $(document).ready(function(){
|
|||
<input id="search_show_name" class="search form-control form-control-inline input-sm input200" type="search" placeholder="Filter Show Name#if $use_network#/Network#end if#">
|
||||
<button type="button" class="resetshows btn btn-inline">Reset Filter</button>
|
||||
</div>
|
||||
|
||||
<h4 style="float:left;margin:0 0 0 2px">$browse_title</h4>
|
||||
#if $kwargs and $kwargs.get('oldest')
|
||||
<div class="grey-text" style="clear:left;margin-left:2px;font-size:0.85em">
|
||||
First aired from $kwargs['oldest'] until $kwargs['newest']
|
||||
First aired from <span id="oldest" data-oldest-dt="$kwargs.get('oldest_dt', '')">$kwargs['oldest']</span> until <span id="newest" data-newest-dt="$kwargs.get('newest_dt', '')">$kwargs['newest']</span>
|
||||
</div>
|
||||
#end if
|
||||
|
||||
#if $p_ref
|
||||
<div id="person">
|
||||
<div id="person-content" class="main-image">
|
||||
<a class="thumb" href="$sbRoot/imagecache/person?pid=$p_ref&thumb=1" rel="dialog"><img class="person-bg" src="$sbRoot/imagecache/person?pid=$p_ref&thumb=0"></a>
|
||||
</div>
|
||||
</div>
|
||||
#end if
|
||||
#end if
|
||||
|
||||
<div id="container">
|
||||
|
@ -481,7 +563,7 @@ $(document).ready(function(){
|
|||
#if 'returning' == $mode
|
||||
#set $overview = '%s: %s' % (
|
||||
'Season %s' % $this_show['episode_season'],
|
||||
$this_show['episode_overview'] or $this_show['overview'])
|
||||
$this_show[('episode_overview', 'overview')['No overview yet' == $this_show['episode_overview']]])
|
||||
#else
|
||||
#set $overview = $this_show['overview']
|
||||
#end if
|
||||
|
@ -492,14 +574,13 @@ $(document).ready(function(){
|
|||
#if $use_ratings:
|
||||
#set $data_rating = $try_float($this_show['rating'])
|
||||
#end if
|
||||
|
||||
<div class="show-card ${hide}${known}inlibrary" data-name="#echo re.sub(r'([\'\"])', r'', $this_show['title'])#" data_id="$show_id"#if $use_ratings# data-rating="$data_rating"#end if##if $use_votes# data-votes="$this_show['votes']"#end if# data-premiered="$this_show['ord_premiered']"#if $use_returning# data-returning="$this_show['ord_returning']"#end if# data-order="$this_show['order']"#if $use_network# data-network="$this_show['network']"#end if#>
|
||||
<div class="show-card ${hide}${known}inlibrary" data-name="#echo re.sub(r'([\'\"])', r'', $this_show['title'])#" data-id="$show_id" data-ajax="$this_show.get('overview_ajax', '0')" data-nroles="#echo len($this_show.get('p_chars', []))#" #if $use_ratings# data-rating="$data_rating"#end if##if $use_votes# data-votes="$this_show['votes']"#end if# data-premiered="$this_show['ord_premiered']"#if $use_returning# data-returning="$this_show['ord_returning']"#end if# data-order="$this_show['order']"#if $use_network# data-network="$this_show['network']"#end if#>
|
||||
<div class="show-card-inner">
|
||||
<div class="browse-image">
|
||||
<a class="browse-image" href="<%= anon_url(this_show['url_src_db']) %>" target="_blank"
|
||||
title="<span style='color: rgb(66, 139, 202)'>$re.sub(r'(?m)\s+\((?:19|20)\d\d\)\s*$', '', $title_html)</span>
|
||||
title="<span style='color: #226baa'>$re.sub(r'(?m)\s+\((?:19|20)\d\d\)\s*$', '', $title_html)</span>
|
||||
|
||||
#if $this_show['genres']#<br><div style='font-weight:bold'>(<em>$this_show['genres']</em>)</div>#end if#
|
||||
<div class='genre' style='display:#echo ('none', 'block')[bool($this_show['genres'])]#;font-weight:bold'>(<em>$this_show['genres']</em>)</div>
|
||||
#if $kwargs and $use_returning#<span style='display:block;clear:both;font-weight:bold;font-size:0.9em;color:#888'><em>Season $this_show['episode_season'] return#echo ('s', 'ed')[$this_show['return_past']]# $this_show['str_returning']</em></span>#end if#
|
||||
#if $this_show.get('country') or $this_show.get('language')
|
||||
<p style='line-height:15px;margin-bottom:2px'>
|
||||
|
@ -511,8 +592,15 @@ $(document).ready(function(){
|
|||
#end if
|
||||
</p>
|
||||
#end if
|
||||
<p style='margin:0 0 2px'>#echo re.sub(r'([,\.!][^,\.!]*?)$', '...', re.sub(r'([!\?\.])(?=\w)', r'\1 ', $overview)).replace('.....', '...')#</p>
|
||||
<p>#if $this_show['str_premiered']#<span style='font-weight:bold;font-size:0.9em;color:#888'><em>#if 'Trakt' == $browse_type and $kwargs and 'returning' == $mode#Air#else#First air#end if##echo ('s', 'ed')[$this_show['started_past']]#: $this_show['str_premiered']</em></span>#end if#
|
||||
#if $this_show.get('p_chars')
|
||||
<p style='overflow-y:auto;max-height:152px'>
|
||||
#for $char in $this_show['p_chars']
|
||||
<span style='display:block;clear:both;font-weight:bold;font-size:0.9em;color:#393'>as $char[0]#if $RoleTypes.ActorMain != $char[1]# ($char[2]/$char[3] eps)#end if#</span>
|
||||
#end for
|
||||
</p>
|
||||
#end if
|
||||
<p class='overview' style='margin:0 0 2px'>$overview</p>
|
||||
<p class='premiere'>#if $this_show['str_premiered']#<span style='font-weight:bold;font-size:0.9em;color:#888'><em>#if 'Trakt' == $browse_type and $kwargs and 'returning' == $mode#Air#else#First air#end if##echo ('s', 'ed')[$this_show['started_past']]#: $this_show['str_premiered']</em></span>#end if#
|
||||
#if $this_show.get('ended_str')# - <span style='font-weight:bold;font-size:0.9em;color:#888'><em>Ended: $this_show['ended_str']</em></span>#end if#
|
||||
#if $this_show.get('network')#<span style='display:block;clear:both;font-weight:bold;font-size:0.9em;color:#888'><em>On: $this_show['network']</em></span>#end if#
|
||||
</p>
|
||||
|
@ -536,6 +624,8 @@ $(document).ready(function(){
|
|||
<div class="clearfix">
|
||||
#if $use_ratings or $use_votes
|
||||
<p>#if $use_ratings#<span class="rating">$this_show['rating']#if $re.search(r'^\d+(\.\d+)?$', (str($this_show['rating'])))#%</span>#end if##end if##if $use_votes#<i class="heart icon-glyph"></i><i>$this_show['votes'] $term_vote.lower()</i>#end if#</p>#slurp#
|
||||
#else
|
||||
<p> </p>
|
||||
#end if
|
||||
#if 'url_tvdb' in $this_show and $this_show['url_tvdb']
|
||||
<a class="service" href="<%= anon_url(this_show['url_tvdb']) %>" onclick="window.open(this.href, '_blank'); return false;"
|
||||
|
|
|
@ -39,10 +39,14 @@ User=sickgear
|
|||
Group=sickgear
|
||||
|
||||
Environment=PYTHONUNBUFFERED=true
|
||||
ExecStart=/usr/bin/python2 /opt/sickgear/app/sickgear.py --systemd --datadir=/opt/sickgear/data
|
||||
ExecStart=/opt/sickgear/app/sickgear.py --systemd --datadir=/opt/sickgear/data
|
||||
KillMode=process
|
||||
Restart=on-failure
|
||||
WorkingDirectory=/opt/sickgear
|
||||
ProtectSystem=full
|
||||
DeviceAllow=/dev/null rw
|
||||
DeviceAllow=/dev/urandom r
|
||||
DevicePolicy=strict
|
||||
NoNewPrivileges=yesWorkingDirectory=/opt/sickgear
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
|
@ -310,8 +310,9 @@ class TmdbIndexer(TVInfoBase):
|
|||
self.img_base_url, self.size_map[TVInfoImageType.person_poster][TVInfoImageSize.medium],
|
||||
tmdb_person_obj['profile_path'])
|
||||
|
||||
clean_person_name = clean_data(tmdb_person_obj.get('name'))
|
||||
_it_person_obj = TVInfoPerson(
|
||||
p_id=tmdb_person_obj.get('id'), ids=TVInfoIDs(ids=person_ids), name=clean_data(tmdb_person_obj.get('name')),
|
||||
p_id=tmdb_person_obj.get('id'), ids=TVInfoIDs(ids=person_ids), name=clean_person_name,
|
||||
akas=clean_data(set(tmdb_person_obj.get('also_known_as') or [])),
|
||||
bio=clean_data(tmdb_person_obj.get('biography')), gender=gender,
|
||||
image=main_image, images=image_list, thumb_url=main_thumb,
|
||||
|
@ -331,6 +332,10 @@ class TmdbIndexer(TVInfoBase):
|
|||
ti_show.overview = self._enforce_text(character.get('overview'))
|
||||
ti_show.firstaired = clean_data(character.get('first_air_date'))
|
||||
ti_show.language = clean_data(character.get('original_language'))
|
||||
ti_show.popularity = character.get('popularity')
|
||||
ti_show.vote_count = character.get('vote_count')
|
||||
ti_show.vote_average = character.get('vote_average')
|
||||
ti_show.rating = ti_show.vote_average
|
||||
ti_show.genre_list = []
|
||||
for g in character.get('genre_ids') or []:
|
||||
if g in self.tv_genres:
|
||||
|
@ -350,9 +355,13 @@ class TmdbIndexer(TVInfoBase):
|
|||
(self.img_base_url,
|
||||
self.size_map[TVInfoImageType.person_poster][TVInfoImageSize.original],
|
||||
character['backdrop_path'])
|
||||
clean_char_name = clean_data(character.get('character'))
|
||||
clean_lower_person_name = (clean_person_name or '').lower() or None
|
||||
characters.append(
|
||||
TVInfoCharacter(name=clean_data(character.get('character')), ti_show=ti_show, person=[_it_person_obj],
|
||||
episode_count=character.get('episode_count'))
|
||||
TVInfoCharacter(name=clean_char_name, ti_show=ti_show, person=[_it_person_obj],
|
||||
episode_count=character.get('episode_count'),
|
||||
plays_self=clean_char_name and
|
||||
(clean_char_name or '').lower() in ('self', clean_lower_person_name))
|
||||
)
|
||||
|
||||
_it_person_obj.characters = characters
|
||||
|
@ -754,11 +763,16 @@ class TmdbIndexer(TVInfoBase):
|
|||
for character in sorted(list(filter(lambda b: b['credit_id'] in main_cast_credit_ids,
|
||||
person_obj.get('roles', []) or [])),
|
||||
key=lambda c: c['episode_count'], reverse=True):
|
||||
clean_char_name = clean_data(character['character'])
|
||||
clean_person_name = clean_data(person_obj['name'])
|
||||
clean_lower_person_name = (clean_person_name or '').lower() or None
|
||||
character_obj = TVInfoCharacter(
|
||||
name=clean_data(character['character']),
|
||||
name=clean_char_name,
|
||||
plays_self=clean_char_name and
|
||||
(clean_char_name or '').lower() in ('self', clean_lower_person_name),
|
||||
person=[
|
||||
TVInfoPerson(
|
||||
p_id=person_obj['id'], name=clean_data(person_obj['name']),
|
||||
p_id=person_obj['id'], name=clean_person_name,
|
||||
ids=TVInfoIDs(ids={TVINFO_TMDB: person_obj['id']}),
|
||||
image='%s%s%s' % (
|
||||
self.img_base_url,
|
||||
|
|
|
@ -6,7 +6,7 @@ from exceptions_helper import ConnectionSkipException, ex
|
|||
from six import iteritems
|
||||
from .trakt import TraktAPI
|
||||
from lib.tvinfo_base.exceptions import BaseTVinfoShownotfound
|
||||
from lib.tvinfo_base import TVInfoBase, TVINFO_TRAKT, TVINFO_TMDB, TVINFO_TVDB, TVINFO_TVRAGE, TVINFO_IMDB, \
|
||||
from lib.tvinfo_base import PersonGenders, TVInfoBase, TVINFO_TRAKT, TVINFO_TMDB, TVINFO_TVDB, TVINFO_TVRAGE, TVINFO_IMDB, \
|
||||
TVINFO_SLUG, TVInfoPerson, TVINFO_TWITTER, TVINFO_FACEBOOK, TVINFO_WIKIPEDIA, TVINFO_INSTAGRAM, TVInfoCharacter, \
|
||||
TVInfoShow, TVInfoIDs, TVInfoSocialIDs, TVINFO_TRAKT_SLUG, TVInfoEpisode, TVInfoSeason, RoleTypes
|
||||
from sg_helpers import clean_data, enforce_type, try_int
|
||||
|
@ -262,6 +262,7 @@ class TraktIndexer(TVInfoBase):
|
|||
deathdate=deathdate,
|
||||
homepage=person_obj['homepage'],
|
||||
birthplace=person_obj['birthplace'],
|
||||
gender=PersonGenders.trakt_map.get(person_obj['gender'], PersonGenders.unknown),
|
||||
social_ids=TVInfoSocialIDs(
|
||||
ids={TVINFO_TWITTER: person_obj['social_ids']['twitter'],
|
||||
TVINFO_FACEBOOK: person_obj['social_ids']['facebook'],
|
||||
|
@ -308,6 +309,7 @@ class TraktIndexer(TVInfoBase):
|
|||
if resp:
|
||||
if show_credits:
|
||||
pc = []
|
||||
clean_lower_person_name = (result.name or '').lower()
|
||||
for c in resp.get('cast') or []:
|
||||
ti_show = TVInfoShow()
|
||||
ti_show.id = c['show']['ids'].get('trakt')
|
||||
|
@ -321,10 +323,17 @@ class TraktIndexer(TVInfoBase):
|
|||
ti_show.imdb_id = c['show']['ids'].get('imdb')
|
||||
ti_show.runtime = c['show']['runtime']
|
||||
ti_show.genre_list = c['show']['genres']
|
||||
ti_show.slug = c['show'].get('ids', {}).get('slug')
|
||||
ti_show.language = c['show'].get('language')
|
||||
ti_show.network_country = c['show'].get('country')
|
||||
ti_show.rating = c['show'].get('rating')
|
||||
ti_show.vote_count = c['show'].get('votes')
|
||||
for ch in c.get('characters') or []:
|
||||
_ti_character = TVInfoCharacter(name=ch, regular=c.get('series_regular'),
|
||||
ti_show=ti_show, person=[result],
|
||||
episode_count=c.get('episode_count'))
|
||||
clean_ch = clean_data(ch)
|
||||
_ti_character = TVInfoCharacter(
|
||||
name=clean_ch, regular=c.get('series_regular'), ti_show=ti_show, person=[result],
|
||||
episode_count=c.get('episode_count'),
|
||||
plays_self=(clean_ch or '').lower() in ('self', clean_lower_person_name))
|
||||
pc.append(_ti_character)
|
||||
ti_show.cast[(RoleTypes.ActorGuest, RoleTypes.ActorMain)[
|
||||
c.get('series_regular', False)]].append(_ti_character)
|
||||
|
|
|
@ -57,6 +57,8 @@ empty_ep = TVInfoEpisode()
|
|||
empty_se = TVInfoSeason()
|
||||
tz_p = parser()
|
||||
|
||||
character_clean_regex = re.compile(r'^tb(a|d)$', flags=re.I)
|
||||
|
||||
img_type_map = {
|
||||
'poster': TVInfoImageType.poster,
|
||||
'banner': TVInfoImageType.banner,
|
||||
|
@ -397,6 +399,14 @@ class TvMaze(TVInfoBase):
|
|||
# type: (...) -> Dict[integer_types, integer_types]
|
||||
return {sid: v.seconds_since_epoch for sid, v in iteritems(tvmaze.show_updates().updates)}
|
||||
|
||||
@staticmethod
|
||||
def _clean_character_name(name):
|
||||
# type: (Optional[str]) -> str
|
||||
name = clean_data(name)
|
||||
if isinstance(name, str):
|
||||
return enforce_type(character_clean_regex.sub('', name), str, '')
|
||||
return enforce_type(name, str, '')
|
||||
|
||||
def _convert_person(self, tvmaze_person_obj, load_credits=True):
|
||||
# type: (tvmaze.Person, bool) -> TVInfoPerson
|
||||
ch = []
|
||||
|
@ -410,7 +420,15 @@ class TvMaze(TVInfoBase):
|
|||
ti_show.ids = TVInfoIDs(ids={TVINFO_TVMAZE: ti_show.id})
|
||||
ti_show.overview = clean_data(c.show.summary)
|
||||
ti_show.status = clean_data(c.show.status)
|
||||
ti_show.vote_average = clean_data((c.show.rating and c.show.rating.get('average'))) or None
|
||||
ti_show.rating = ti_show.vote_average
|
||||
net = c.show.network or c.show.web_channel
|
||||
ti_show.genre_list = clean_data(c.show.genres or [])
|
||||
ti_show.genre = '|'.join(ti_show.genre_list or [])
|
||||
ti_show.show_type = clean_data((
|
||||
isinstance(c.show.type, string_types) and [c.show.type.lower()] or
|
||||
isinstance(c.show.type, list) and [x.lower() for x in c.show.type] or []
|
||||
))
|
||||
if net:
|
||||
ti_show.network = clean_data(net.name)
|
||||
ti_show.network_id = net.maze_id
|
||||
|
@ -418,7 +436,18 @@ class TvMaze(TVInfoBase):
|
|||
ti_show.network_country_code = clean_data(net.code)
|
||||
ti_show.network_timezone = clean_data(net.timezone)
|
||||
ti_show.network_is_stream = None is not c.show.web_channel
|
||||
ch.append(TVInfoCharacter(name=clean_data(c.character.name), ti_show=ti_show, episode_count=1))
|
||||
_images = None
|
||||
if c.character.image and all(i_s in c.character.image and c.character.image[i_s]
|
||||
for i_s in ('original', 'medium')):
|
||||
_images = [TVInfoImage(TVInfoImageType.poster,
|
||||
sizes={TVInfoImageSize.original: c.character.image['original'],
|
||||
TVInfoImageSize.medium: c.character.image['medium']})]
|
||||
ch.append(TVInfoCharacter(name=self._clean_character_name(c.character.name),
|
||||
ti_show=ti_show, episode_count=1, plays_self=c.character.plays_self,
|
||||
voice=c.character.voice,
|
||||
image= c.character.image and c.character.image.get('original'),
|
||||
thumb_url= c.character.image and c.character.image.get('medium'),
|
||||
p_id=c.character.id, images=_images))
|
||||
try:
|
||||
birthdate = tvmaze_person_obj.birthday and tz_p.parse(tvmaze_person_obj.birthday).date()
|
||||
except (BaseException, Exception):
|
||||
|
@ -446,7 +475,7 @@ class TvMaze(TVInfoBase):
|
|||
(tvmaze_person_obj.guestcastcredits or [], False)]:
|
||||
for c in c_t: # type: tvmaze.CastCredit
|
||||
_show = c.show or c.episode.show
|
||||
_clean_char_name = clean_data(c.character.name)
|
||||
_clean_char_name = self._clean_character_name(c.character.name)
|
||||
ti_show = TVInfoShow()
|
||||
if None is not _show:
|
||||
_clean_show_name = clean_data(_show.name)
|
||||
|
@ -478,6 +507,8 @@ class TvMaze(TVInfoBase):
|
|||
ti_show.ids = TVInfoIDs(ids={TVINFO_TVMAZE: ti_show.id})
|
||||
ti_show.overview = enforce_type(clean_data(_show.summary), str, '')
|
||||
ti_show.status = clean_data(_show.status)
|
||||
ti_show.vote_average = clean_data(_show.rating and _show.rating.get('average')) or None
|
||||
ti_show.rating = ti_show.vote_average
|
||||
net = _show.network or _show.web_channel
|
||||
if net:
|
||||
ti_show.network = clean_data(net.name)
|
||||
|
@ -499,8 +530,18 @@ class TvMaze(TVInfoBase):
|
|||
_g_kw = {'guest_episodes_numbers': {c.episode.season_number: [c.episode.episode_number or 0]}}
|
||||
else:
|
||||
_g_kw = {}
|
||||
_images = None
|
||||
if c.character.image and all(i_s in c.character.image and c.character.image[i_s]
|
||||
for i_s in ('original', 'medium')):
|
||||
_images = [TVInfoImage(TVInfoImageType.poster,
|
||||
sizes={TVInfoImageSize.original: c.character.image['original'],
|
||||
TVInfoImageSize.medium: c.character.image['medium']})]
|
||||
ch.append(TVInfoCharacter(name=_clean_char_name, ti_show=ti_show, regular=regular, episode_count=1,
|
||||
person=[_ti_person_obj], **_g_kw))
|
||||
person=[_ti_person_obj], plays_self=c.character.plays_self,
|
||||
voice=c.character.voice,
|
||||
image=c.character.image and c.character.image.get('original'),
|
||||
thumb_url=c.character.image and c.character.image.get('medium'),
|
||||
p_id=c.character.id, images=_images, **_g_kw))
|
||||
_ti_person_obj.characters = ch
|
||||
return _ti_person_obj
|
||||
|
||||
|
@ -588,7 +629,7 @@ class TvMaze(TVInfoBase):
|
|||
else:
|
||||
_s_o.cast[RoleTypes.ActorMain].append(
|
||||
TVInfoCharacter(image=cur_ch.image and cur_ch.image.get('original'),
|
||||
name=clean_data(cur_ch.name),
|
||||
name=self._clean_character_name(cur_ch.name),
|
||||
ids=TVInfoIDs({TVINFO_TVMAZE: cur_ch.id}),
|
||||
p_id=cur_ch.id, person=[person], plays_self=cur_ch.plays_self,
|
||||
thumb_url=cur_ch.image and cur_ch.image.get('medium'),
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -27,10 +27,10 @@
|
|||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
__title__ = 'Apprise'
|
||||
__version__ = '1.6.0'
|
||||
__version__ = '1.8.0'
|
||||
__author__ = 'Chris Caron'
|
||||
__license__ = 'BSD'
|
||||
__copywrite__ = 'Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>'
|
||||
__copywrite__ = 'Copyright (C) 2024 Chris Caron <lead2gold@gmail.com>'
|
||||
__email__ = 'lead2gold@gmail.com'
|
||||
__status__ = 'Production'
|
||||
|
||||
|
@ -49,17 +49,20 @@ 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 .url import URLBase
|
||||
from .url import PrivacyMode
|
||||
from .plugins.base import NotifyBase
|
||||
from .config.base import ConfigBase
|
||||
from .attachment.base import AttachBase
|
||||
|
||||
from .apprise import Apprise
|
||||
from .locale import AppriseLocale
|
||||
from .asset import AppriseAsset
|
||||
from .apprise_config import AppriseConfig
|
||||
from .apprise_attachment import AppriseAttachment
|
||||
from .manager_attachment import AttachmentManager
|
||||
from .manager_config import ConfigurationManager
|
||||
from .manager_plugins import NotificationManager
|
||||
from . import decorators
|
||||
|
||||
# Inherit our logging with our additional entries added to it
|
||||
|
@ -73,7 +76,7 @@ logging.getLogger(__name__).addHandler(logging.NullHandler())
|
|||
__all__ = [
|
||||
# Core
|
||||
'Apprise', 'AppriseAsset', 'AppriseConfig', 'AppriseAttachment', 'URLBase',
|
||||
'NotifyBase', 'ConfigBase', 'AttachBase',
|
||||
'NotifyBase', 'ConfigBase', 'AttachBase', 'AppriseLocale',
|
||||
|
||||
# Reference
|
||||
'NotifyType', 'NotifyImageSize', 'NotifyFormat', 'OverflowMode',
|
||||
|
@ -83,6 +86,9 @@ __all__ = [
|
|||
'ContentLocation', 'CONTENT_LOCATIONS',
|
||||
'PrivacyMode',
|
||||
|
||||
# Managers
|
||||
'NotificationManager', 'ConfigurationManager', 'AttachmentManager',
|
||||
|
||||
# Decorator
|
||||
'decorators',
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -33,21 +33,25 @@ from itertools import chain
|
|||
from . import common
|
||||
from .conversion import convert_between
|
||||
from .utils import is_exclusive_match
|
||||
from .manager_plugins import NotificationManager
|
||||
from .utils import parse_list
|
||||
from .utils import parse_urls
|
||||
from .utils import cwe312_url
|
||||
from .emojis import apply_emojis
|
||||
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 .asset import AppriseAsset
|
||||
from .apprise_config import AppriseConfig
|
||||
from .apprise_attachment import AppriseAttachment
|
||||
from .locale import AppriseLocale
|
||||
from .config.base import ConfigBase
|
||||
from .plugins.base import NotifyBase
|
||||
|
||||
from . import plugins
|
||||
from . import __version__
|
||||
|
||||
# Grant access to our Notification Manager Singleton
|
||||
N_MGR = NotificationManager()
|
||||
|
||||
|
||||
class Apprise:
|
||||
"""
|
||||
|
@ -137,7 +141,7 @@ class Apprise:
|
|||
# We already have our result set
|
||||
results = url
|
||||
|
||||
if results.get('schema') not in common.NOTIFY_SCHEMA_MAP:
|
||||
if results.get('schema') not in N_MGR:
|
||||
# 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.')
|
||||
|
@ -160,7 +164,7 @@ class Apprise:
|
|||
type(url))
|
||||
return None
|
||||
|
||||
if not common.NOTIFY_SCHEMA_MAP[results['schema']].enabled:
|
||||
if not N_MGR[results['schema']].enabled:
|
||||
#
|
||||
# First Plugin Enable Check (Pre Initialization)
|
||||
#
|
||||
|
@ -180,13 +184,12 @@ class Apprise:
|
|||
try:
|
||||
# Attempt to create an instance of our plugin using the parsed
|
||||
# URL information
|
||||
plugin = common.NOTIFY_SCHEMA_MAP[results['schema']](**results)
|
||||
plugin = N_MGR[results['schema']](**results)
|
||||
|
||||
# Create log entry of loaded URL
|
||||
logger.debug(
|
||||
'Loaded {} URL: {}'.format(
|
||||
common.
|
||||
NOTIFY_SCHEMA_MAP[results['schema']].service_name,
|
||||
N_MGR[results['schema']].service_name,
|
||||
plugin.url(privacy=asset.secure_logging)))
|
||||
|
||||
except Exception:
|
||||
|
@ -197,15 +200,14 @@ class Apprise:
|
|||
# the arguments are invalid or can not be used.
|
||||
logger.error(
|
||||
'Could not load {} URL: {}'.format(
|
||||
common.
|
||||
NOTIFY_SCHEMA_MAP[results['schema']].service_name,
|
||||
N_MGR[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)
|
||||
plugin = N_MGR[results['schema']](**results)
|
||||
|
||||
if not plugin.enabled:
|
||||
#
|
||||
|
@ -376,7 +378,7 @@ class Apprise:
|
|||
body, title,
|
||||
notify_type=notify_type, body_format=body_format,
|
||||
tag=tag, match_always=match_always, attach=attach,
|
||||
interpret_escapes=interpret_escapes
|
||||
interpret_escapes=interpret_escapes,
|
||||
)
|
||||
|
||||
except TypeError:
|
||||
|
@ -501,6 +503,11 @@ class Apprise:
|
|||
key = server.notify_format if server.title_maxlen > 0\
|
||||
else f'_{server.notify_format}'
|
||||
|
||||
if server.interpret_emojis:
|
||||
# alter our key slightly to handle emojis since their value is
|
||||
# pulled out of the notification
|
||||
key += "-emojis"
|
||||
|
||||
if key not in conversion_title_map:
|
||||
|
||||
# Prepare our title
|
||||
|
@ -542,6 +549,16 @@ class Apprise:
|
|||
logger.error(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
if server.interpret_emojis:
|
||||
#
|
||||
# Convert our :emoji: definitions
|
||||
#
|
||||
|
||||
conversion_body_map[key] = \
|
||||
apply_emojis(conversion_body_map[key])
|
||||
conversion_title_map[key] = \
|
||||
apply_emojis(conversion_title_map[key])
|
||||
|
||||
kwargs = dict(
|
||||
body=conversion_body_map[key],
|
||||
title=conversion_title_map[key],
|
||||
|
@ -674,7 +691,7 @@ class Apprise:
|
|||
'asset': self.asset.details(),
|
||||
}
|
||||
|
||||
for plugin in set(common.NOTIFY_SCHEMA_MAP.values()):
|
||||
for plugin in N_MGR.plugins():
|
||||
# Iterate over our hashed plugins and dynamically build details on
|
||||
# their status:
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -26,15 +26,18 @@
|
|||
# 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 .attachment.base import AttachBase
|
||||
from .asset import AppriseAsset
|
||||
from .manager_attachment import AttachmentManager
|
||||
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
|
||||
|
||||
# Grant access to our Notification Manager Singleton
|
||||
A_MGR = AttachmentManager()
|
||||
|
||||
|
||||
class AppriseAttachment:
|
||||
"""
|
||||
|
@ -139,13 +142,8 @@ class AppriseAttachment:
|
|||
# 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
|
||||
if isinstance(attachments, (AttachBase, str)):
|
||||
# store our instance
|
||||
attachments = (attachments, )
|
||||
|
||||
elif not isinstance(attachments, (tuple, set, list)):
|
||||
|
@ -169,7 +167,7 @@ class AppriseAttachment:
|
|||
# returns None if it fails
|
||||
instance = AppriseAttachment.instantiate(
|
||||
_attachment, asset=asset, cache=cache)
|
||||
if not isinstance(instance, attachment.AttachBase):
|
||||
if not isinstance(instance, AttachBase):
|
||||
return_status = False
|
||||
continue
|
||||
|
||||
|
@ -178,7 +176,7 @@ class AppriseAttachment:
|
|||
# append our content together
|
||||
instance = _attachment.attachments
|
||||
|
||||
elif not isinstance(_attachment, attachment.AttachBase):
|
||||
elif not isinstance(_attachment, AttachBase):
|
||||
logger.warning(
|
||||
"An invalid attachment (type={}) was specified.".format(
|
||||
type(_attachment)))
|
||||
|
@ -228,7 +226,7 @@ class AppriseAttachment:
|
|||
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
|
||||
schema = 'file'
|
||||
url = '{}://{}'.format(schema, URLBase.quote(url))
|
||||
|
||||
else:
|
||||
|
@ -236,13 +234,13 @@ class AppriseAttachment:
|
|||
schema = schema.group('schema').lower()
|
||||
|
||||
# Some basic validation
|
||||
if schema not in ATTACHMENT_SCHEMA_MAP:
|
||||
if schema not in A_MGR:
|
||||
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)
|
||||
results = A_MGR[schema].parse_url(url)
|
||||
|
||||
if not results:
|
||||
# Failed to parse the server URL
|
||||
|
@ -261,8 +259,7 @@ class AppriseAttachment:
|
|||
try:
|
||||
# Attempt to create an instance of our plugin using the parsed
|
||||
# URL information
|
||||
attach_plugin = \
|
||||
ATTACHMENT_SCHEMA_MAP[results['schema']](**results)
|
||||
attach_plugin = A_MGR[results['schema']](**results)
|
||||
|
||||
except Exception:
|
||||
# the arguments are invalid or can not be used.
|
||||
|
@ -272,7 +269,7 @@ class AppriseAttachment:
|
|||
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)
|
||||
attach_plugin = A_MGR[results['schema']](**results)
|
||||
|
||||
return attach_plugin
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -26,17 +26,20 @@
|
|||
# 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 .manager_config import ConfigurationManager
|
||||
from . import URLBase
|
||||
from .AppriseAsset import AppriseAsset
|
||||
from .asset 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
|
||||
|
||||
# Grant access to our Configuration Manager Singleton
|
||||
C_MGR = ConfigurationManager()
|
||||
|
||||
|
||||
class AppriseConfig:
|
||||
"""
|
||||
|
@ -251,7 +254,7 @@ class AppriseConfig:
|
|||
logger.debug("Loading raw configuration: {}".format(content))
|
||||
|
||||
# Create ourselves a ConfigMemory Object to store our configuration
|
||||
instance = config.ConfigMemory(
|
||||
instance = C_MGR['memory'](
|
||||
content=content, format=format, asset=asset, tag=tag,
|
||||
recursion=recursion, insecure_includes=insecure_includes)
|
||||
|
||||
|
@ -326,7 +329,7 @@ class AppriseConfig:
|
|||
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
|
||||
schema = 'file'
|
||||
url = '{}://{}'.format(schema, URLBase.quote(url))
|
||||
|
||||
else:
|
||||
|
@ -334,13 +337,13 @@ class AppriseConfig:
|
|||
schema = schema.group('schema').lower()
|
||||
|
||||
# Some basic validation
|
||||
if schema not in common.CONFIG_SCHEMA_MAP:
|
||||
if schema not in C_MGR:
|
||||
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)
|
||||
results = C_MGR[schema].parse_url(url)
|
||||
|
||||
if not results:
|
||||
# Failed to parse the server URL
|
||||
|
@ -368,8 +371,7 @@ class AppriseConfig:
|
|||
try:
|
||||
# Attempt to create an instance of our plugin using the parsed
|
||||
# URL information
|
||||
cfg_plugin = \
|
||||
common.CONFIG_SCHEMA_MAP[results['schema']](**results)
|
||||
cfg_plugin = C_MGR[results['schema']](**results)
|
||||
|
||||
except Exception:
|
||||
# the arguments are invalid or can not be used.
|
||||
|
@ -379,7 +381,7 @@ class AppriseConfig:
|
|||
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)
|
||||
cfg_plugin = C_MGR[results['schema']](**results)
|
||||
|
||||
return cfg_plugin
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -33,7 +33,11 @@ from os.path import dirname
|
|||
from os.path import isfile
|
||||
from os.path import abspath
|
||||
from .common import NotifyType
|
||||
from .utils import module_detection
|
||||
from .manager_plugins import NotificationManager
|
||||
|
||||
|
||||
# Grant access to our Notification Manager Singleton
|
||||
N_MGR = NotificationManager()
|
||||
|
||||
|
||||
class AppriseAsset:
|
||||
|
@ -66,6 +70,9 @@ class AppriseAsset:
|
|||
NotifyType.WARNING: '#CACF29',
|
||||
}
|
||||
|
||||
# The default color to return if a mapping isn't found in our table above
|
||||
default_html_color = '#888888'
|
||||
|
||||
# Ascii Notification
|
||||
ascii_notify_map = {
|
||||
NotifyType.INFO: '[i]',
|
||||
|
@ -74,8 +81,8 @@ class AppriseAsset:
|
|||
NotifyType.WARNING: '[~]',
|
||||
}
|
||||
|
||||
# The default color to return if a mapping isn't found in our table above
|
||||
default_html_color = '#888888'
|
||||
# The default ascii to return if a mapping isn't found in our table above
|
||||
default_ascii_chars = '[?]'
|
||||
|
||||
# The default image extension to use
|
||||
default_extension = '.png'
|
||||
|
@ -121,6 +128,12 @@ class AppriseAsset:
|
|||
# notifications are sent sequentially (one after another)
|
||||
async_mode = True
|
||||
|
||||
# Support :smile:, and other alike keywords swapping them for their
|
||||
# unicode value. A value of None leaves the interpretation up to the
|
||||
# end user to control (allowing them to specify emojis=yes on the
|
||||
# URL)
|
||||
interpret_emojis = None
|
||||
|
||||
# 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.
|
||||
|
@ -174,7 +187,7 @@ class AppriseAsset:
|
|||
|
||||
if plugin_paths:
|
||||
# Load any decorated modules if defined
|
||||
module_detection(plugin_paths)
|
||||
N_MGR.module_detection(plugin_paths)
|
||||
|
||||
def color(self, notify_type, color_type=None):
|
||||
"""
|
||||
|
@ -213,9 +226,8 @@ class AppriseAsset:
|
|||
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)
|
||||
return self.ascii_notify_map.get(notify_type, self.default_ascii_chars)
|
||||
|
||||
def image_url(self, notify_type, image_size, logo=False, extension=None):
|
||||
"""
|
|
@ -1,337 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
#
|
||||
# 1. Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
#
|
||||
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
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<name>[^'\"]+)['\"]?", 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
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -26,93 +26,15 @@
|
|||
# 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
|
||||
# Used for testing
|
||||
from .base import AttachBase
|
||||
from ..manager_attachment import AttachmentManager
|
||||
|
||||
__all__ = []
|
||||
# Initalize our Attachment Manager Singleton
|
||||
A_MGR = AttachmentManager()
|
||||
|
||||
|
||||
# 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'^(?P<name>Attach[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()
|
||||
__all__ = [
|
||||
# Reference
|
||||
'AttachBase',
|
||||
'AttachmentManager',
|
||||
]
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -29,10 +29,10 @@
|
|||
import os
|
||||
import time
|
||||
import mimetypes
|
||||
from ..URLBase import URLBase
|
||||
from ..url import URLBase
|
||||
from ..utils import parse_bool
|
||||
from ..common import ContentLocation
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
|
||||
class AttachBase(URLBase):
|
||||
|
@ -148,6 +148,9 @@ class AttachBase(URLBase):
|
|||
# Absolute path to attachment
|
||||
self.download_path = None
|
||||
|
||||
# Track open file pointers
|
||||
self.__pointers = set()
|
||||
|
||||
# Set our cache flag; it can be True, False, None, or a (positive)
|
||||
# integer... nothing else
|
||||
if cache is not None:
|
||||
|
@ -226,15 +229,14 @@ class AttachBase(URLBase):
|
|||
Content is cached once determied to prevent overhead of future
|
||||
calls.
|
||||
"""
|
||||
if not self.exists():
|
||||
# we could not obtain our attachment
|
||||
return None
|
||||
|
||||
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.
|
||||
|
@ -253,11 +255,14 @@ class AttachBase(URLBase):
|
|||
return self.detected_mimetype \
|
||||
if self.detected_mimetype else self.unknown_mimetype
|
||||
|
||||
def exists(self):
|
||||
def exists(self, retrieve_if_missing=True):
|
||||
"""
|
||||
Simply returns true if the object has downloaded and stored the
|
||||
attachment AND the attachment has not expired.
|
||||
"""
|
||||
if self.location == ContentLocation.INACCESSIBLE:
|
||||
# our content is inaccessible
|
||||
return False
|
||||
|
||||
cache = self.template_args['cache']['default'] \
|
||||
if self.cache is None else self.cache
|
||||
|
@ -282,7 +287,7 @@ class AttachBase(URLBase):
|
|||
# The file is not present
|
||||
pass
|
||||
|
||||
return self.download()
|
||||
return False if not retrieve_if_missing else self.download()
|
||||
|
||||
def invalidate(self):
|
||||
"""
|
||||
|
@ -295,6 +300,11 @@ class AttachBase(URLBase):
|
|||
- download_path: Must contain a absolute path to content
|
||||
- detected_mimetype: Should identify mimetype of content
|
||||
"""
|
||||
|
||||
# Remove all open pointers
|
||||
while self.__pointers:
|
||||
self.__pointers.pop().close()
|
||||
|
||||
self.detected_name = None
|
||||
self.download_path = None
|
||||
self.detected_mimetype = None
|
||||
|
@ -314,8 +324,28 @@ class AttachBase(URLBase):
|
|||
raise NotImplementedError(
|
||||
"download() is implimented by the child class.")
|
||||
|
||||
def open(self, mode='rb'):
|
||||
"""
|
||||
return our file pointer and track it (we'll auto close later
|
||||
"""
|
||||
pointer = open(self.path, mode=mode)
|
||||
self.__pointers.add(pointer)
|
||||
return pointer
|
||||
|
||||
def __enter__(self):
|
||||
"""
|
||||
support with keyword
|
||||
"""
|
||||
return self.open()
|
||||
|
||||
def __exit__(self, value_type, value, traceback):
|
||||
"""
|
||||
stub to do nothing; but support exit of with statement gracefully
|
||||
"""
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def parse_url(url, verify_host=True, mimetype_db=None):
|
||||
def parse_url(url, verify_host=True, mimetype_db=None, sanitize=True):
|
||||
"""Parses the URL and returns it broken apart into a dictionary.
|
||||
|
||||
This is very specific and customized for Apprise.
|
||||
|
@ -333,7 +363,8 @@ class AttachBase(URLBase):
|
|||
successful, otherwise None is returned.
|
||||
"""
|
||||
|
||||
results = URLBase.parse_url(url, verify_host=verify_host)
|
||||
results = URLBase.parse_url(
|
||||
url, verify_host=verify_host, sanitize=sanitize)
|
||||
|
||||
if not results:
|
||||
# We're done; we failed to parse our url
|
||||
|
@ -375,3 +406,9 @@ class AttachBase(URLBase):
|
|||
True is returned if our content was downloaded correctly.
|
||||
"""
|
||||
return True if self.path else False
|
||||
|
||||
def __del__(self):
|
||||
"""
|
||||
Perform any house cleaning
|
||||
"""
|
||||
self.invalidate()
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -28,9 +28,9 @@
|
|||
|
||||
import re
|
||||
import os
|
||||
from .AttachBase import AttachBase
|
||||
from .base import AttachBase
|
||||
from ..common import ContentLocation
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
|
||||
class AttachFile(AttachBase):
|
||||
|
@ -78,7 +78,8 @@ class AttachFile(AttachBase):
|
|||
|
||||
return 'file://{path}{params}'.format(
|
||||
path=self.quote(self.dirty_path),
|
||||
params='?{}'.format(self.urlencode(params)) if params else '',
|
||||
params='?{}'.format(self.urlencode(params, safe='/'))
|
||||
if params else '',
|
||||
)
|
||||
|
||||
def download(self, **kwargs):
|
375
lib/apprise/attachment/http.py
Normal file
375
lib/apprise/attachment/http.py
Normal file
|
@ -0,0 +1,375 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
#
|
||||
# 1. Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
#
|
||||
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
import re
|
||||
import os
|
||||
import requests
|
||||
import threading
|
||||
from tempfile import NamedTemporaryFile
|
||||
from .base import AttachBase
|
||||
from ..common import ContentLocation
|
||||
from ..url import PrivacyMode
|
||||
from ..locale 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
|
||||
|
||||
# thread safe loading
|
||||
_lock = threading.Lock()
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
# Where our request object will temporarily live.
|
||||
r = None
|
||||
|
||||
# Always call throttle before any remote server i/o is made
|
||||
self.throttle()
|
||||
|
||||
with self._lock:
|
||||
if self.exists(retrieve_if_missing=False):
|
||||
# Due to locking; it's possible a concurrent thread already
|
||||
# handled the retrieval in which case we can safely move on
|
||||
self.logger.trace(
|
||||
'HTTP Attachment %s already retrieved',
|
||||
self._temp_file.name)
|
||||
return True
|
||||
|
||||
# Ensure any existing content set has been invalidated
|
||||
self.invalidate()
|
||||
|
||||
self.logger.debug(
|
||||
'HTTP Attachment Fetch URL: %s (cert_verify=%r)' % (
|
||||
url, self.verify_certificate))
|
||||
|
||||
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<name>[^'\"]+)['\"]?", d, re.I)
|
||||
if result:
|
||||
self.detected_name = result.group('name').strip()
|
||||
|
||||
# Create a temporary file to work with; delete must be set
|
||||
# to False or it isn't compatible with Microsoft Windows
|
||||
# instances. In lieu of this, __del__ will clean up the
|
||||
# file for us.
|
||||
self._temp_file = NamedTemporaryFile(delete=False)
|
||||
|
||||
# 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. In this case 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
|
||||
# reading 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.logger.trace(
|
||||
'Attachment cleanup of %s', self._temp_file.name)
|
||||
self._temp_file.close()
|
||||
|
||||
try:
|
||||
# Ensure our file is removed (if it exists)
|
||||
os.unlink(self._temp_file.name)
|
||||
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Reset our temporary file to prevent from entering
|
||||
# this block again
|
||||
self._temp_file = None
|
||||
|
||||
super().invalidate()
|
||||
|
||||
def __del__(self):
|
||||
"""
|
||||
Tidy memory if open
|
||||
"""
|
||||
self.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, safe='/'),
|
||||
)
|
||||
|
||||
@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, sanitize=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
|
||||
results['headers'] = results['qsd-']
|
||||
results['headers'].update(results['qsd+'])
|
||||
|
||||
return results
|
212
lib/apprise/attachment/memory.py
Normal file
212
lib/apprise/attachment/memory.py
Normal file
|
@ -0,0 +1,212 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
#
|
||||
# 1. Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
#
|
||||
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
import re
|
||||
import os
|
||||
import io
|
||||
from .base import AttachBase
|
||||
from ..common import ContentLocation
|
||||
from ..locale import gettext_lazy as _
|
||||
import uuid
|
||||
|
||||
|
||||
class AttachMemory(AttachBase):
|
||||
"""
|
||||
A wrapper for Memory based attachment sources
|
||||
"""
|
||||
|
||||
# The default descriptive name associated with the service
|
||||
service_name = _('Memory')
|
||||
|
||||
# The default protocol
|
||||
protocol = 'memory'
|
||||
|
||||
# Content is local to the same location as the apprise instance
|
||||
# being called (server-side)
|
||||
location = ContentLocation.LOCAL
|
||||
|
||||
def __init__(self, content=None, name=None, mimetype=None,
|
||||
encoding='utf-8', **kwargs):
|
||||
"""
|
||||
Initialize Memory Based Attachment Object
|
||||
|
||||
"""
|
||||
# Create our BytesIO object
|
||||
self._data = io.BytesIO()
|
||||
|
||||
if content is None:
|
||||
# Empty; do nothing
|
||||
pass
|
||||
|
||||
elif isinstance(content, str):
|
||||
content = content.encode(encoding)
|
||||
if mimetype is None:
|
||||
mimetype = 'text/plain'
|
||||
|
||||
if not name:
|
||||
# Generate a unique filename
|
||||
name = str(uuid.uuid4()) + '.txt'
|
||||
|
||||
elif not isinstance(content, bytes):
|
||||
raise TypeError(
|
||||
'Provided content for memory attachment is invalid')
|
||||
|
||||
# Store our content
|
||||
if content:
|
||||
self._data.write(content)
|
||||
|
||||
if mimetype is None:
|
||||
# Default mimetype
|
||||
mimetype = 'application/octet-stream'
|
||||
|
||||
if not name:
|
||||
# Generate a unique filename
|
||||
name = str(uuid.uuid4()) + '.dat'
|
||||
|
||||
# Initialize our base object
|
||||
super().__init__(name=name, mimetype=mimetype, **kwargs)
|
||||
|
||||
return
|
||||
|
||||
def url(self, privacy=False, *args, **kwargs):
|
||||
"""
|
||||
Returns the URL built dynamically based on specified arguments.
|
||||
"""
|
||||
|
||||
# Define any URL parameters
|
||||
params = {
|
||||
'mime': self._mimetype,
|
||||
}
|
||||
|
||||
return 'memory://{name}?{params}'.format(
|
||||
name=self.quote(self._name),
|
||||
params=self.urlencode(params, safe='/')
|
||||
)
|
||||
|
||||
def open(self, *args, **kwargs):
|
||||
"""
|
||||
return our memory object
|
||||
"""
|
||||
# Return our object
|
||||
self._data.seek(0, 0)
|
||||
return self._data
|
||||
|
||||
def __enter__(self):
|
||||
"""
|
||||
support with clause
|
||||
"""
|
||||
# Return our object
|
||||
self._data.seek(0, 0)
|
||||
return self._data
|
||||
|
||||
def download(self, **kwargs):
|
||||
"""
|
||||
Handle memory download() call
|
||||
"""
|
||||
|
||||
if self.location == ContentLocation.INACCESSIBLE:
|
||||
# our content is inaccessible
|
||||
return False
|
||||
|
||||
if self.max_file_size > 0 and len(self) > self.max_file_size:
|
||||
# The content to attach is to large
|
||||
self.logger.error(
|
||||
'Content exceeds allowable maximum memory size '
|
||||
'({}KB): {}'.format(
|
||||
int(self.max_file_size / 1024), self.url(privacy=True)))
|
||||
|
||||
# Return False (signifying a failure)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def invalidate(self):
|
||||
"""
|
||||
Removes data
|
||||
"""
|
||||
self._data.truncate(0)
|
||||
return
|
||||
|
||||
def exists(self):
|
||||
"""
|
||||
over-ride exists() call
|
||||
"""
|
||||
size = len(self)
|
||||
return True if self.location != ContentLocation.INACCESSIBLE \
|
||||
and size > 0 and (
|
||||
self.max_file_size <= 0 or
|
||||
(self.max_file_size > 0 and size <= self.max_file_size)) \
|
||||
else False
|
||||
|
||||
@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
|
||||
|
||||
if 'name' not in results:
|
||||
# Allow fall-back to be from URL
|
||||
match = re.match(r'memory://(?P<path>[^?]+)(\?.*)?', url, re.I)
|
||||
if match:
|
||||
# Store our filename only (ignore any defined paths)
|
||||
results['name'] = \
|
||||
os.path.basename(AttachMemory.unquote(match.group('path')))
|
||||
return results
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
"""
|
||||
return the filename
|
||||
"""
|
||||
if not self.exists():
|
||||
# we could not obtain our path
|
||||
return None
|
||||
|
||||
return self._name
|
||||
|
||||
def __len__(self):
|
||||
"""
|
||||
Returns the size of he memory attachment
|
||||
|
||||
"""
|
||||
return self._data.getbuffer().nbytes
|
||||
|
||||
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 self.exists()
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -26,50 +26,6 @@
|
|||
# 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': <CustomNotifyWrapperPlugin>
|
||||
# },
|
||||
# 'schema2': {
|
||||
# 'name': 'Custom schema name',
|
||||
# 'fn_name': 'name_of_function_decorator_was_found_on',
|
||||
# 'url': 'schema://any/additional/info/found/on/url'
|
||||
# 'plugin': <CustomNotifyWrapperPlugin>
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# Note: that the <CustomNotifyWrapperPlugin> 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:
|
||||
"""
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -26,84 +26,15 @@
|
|||
# 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
|
||||
# Used for testing
|
||||
from .base import ConfigBase
|
||||
from ..manager_config import ConfigurationManager
|
||||
|
||||
__all__ = []
|
||||
# Initalize our Config Manager Singleton
|
||||
C_MGR = ConfigurationManager()
|
||||
|
||||
|
||||
# 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'^(?P<name>Config[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()
|
||||
__all__ = [
|
||||
# Reference
|
||||
'ConfigBase',
|
||||
'ConfigurationManager',
|
||||
]
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -32,18 +32,26 @@ import time
|
|||
|
||||
from .. import plugins
|
||||
from .. import common
|
||||
from ..AppriseAsset import AppriseAsset
|
||||
from ..URLBase import URLBase
|
||||
from ..asset import AppriseAsset
|
||||
from ..url 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
|
||||
from ..manager_config import ConfigurationManager
|
||||
from ..manager_plugins import NotificationManager
|
||||
|
||||
# Test whether token is valid or not
|
||||
VALID_TOKEN = re.compile(
|
||||
r'(?P<token>[a-z0-9][a-z0-9_]+)', re.I)
|
||||
|
||||
# Grant access to our Notification Manager Singleton
|
||||
N_MGR = NotificationManager()
|
||||
|
||||
# Grant access to our Configuration Manager Singleton
|
||||
C_MGR = ConfigurationManager()
|
||||
|
||||
|
||||
class ConfigBase(URLBase):
|
||||
"""
|
||||
|
@ -228,7 +236,7 @@ class ConfigBase(URLBase):
|
|||
schema = schema.group('schema').lower()
|
||||
|
||||
# Some basic validation
|
||||
if schema not in common.CONFIG_SCHEMA_MAP:
|
||||
if schema not in C_MGR:
|
||||
ConfigBase.logger.warning(
|
||||
'Unsupported include schema {}.'.format(schema))
|
||||
continue
|
||||
|
@ -239,7 +247,7 @@ class ConfigBase(URLBase):
|
|||
|
||||
# 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)
|
||||
results = C_MGR[schema].parse_url(url)
|
||||
if not results:
|
||||
# Failed to parse the server URL
|
||||
self.logger.warning(
|
||||
|
@ -247,11 +255,10 @@ class ConfigBase(URLBase):
|
|||
continue
|
||||
|
||||
# Handle cross inclusion based on allow_cross_includes rules
|
||||
if (common.CONFIG_SCHEMA_MAP[schema].allow_cross_includes ==
|
||||
if (C_MGR[schema].allow_cross_includes ==
|
||||
common.ContentIncludeMode.STRICT
|
||||
and schema not in self.schemas()
|
||||
and not self.insecure_includes) or \
|
||||
common.CONFIG_SCHEMA_MAP[schema] \
|
||||
and not self.insecure_includes) or C_MGR[schema] \
|
||||
.allow_cross_includes == \
|
||||
common.ContentIncludeMode.NEVER:
|
||||
|
||||
|
@ -279,8 +286,7 @@ class ConfigBase(URLBase):
|
|||
try:
|
||||
# Attempt to create an instance of our plugin using the
|
||||
# parsed URL information
|
||||
cfg_plugin = \
|
||||
common.CONFIG_SCHEMA_MAP[results['schema']](**results)
|
||||
cfg_plugin = C_MGR[results['schema']](**results)
|
||||
|
||||
except Exception as e:
|
||||
# the arguments are invalid or can not be used.
|
||||
|
@ -392,7 +398,11 @@ class ConfigBase(URLBase):
|
|||
# Track our groups
|
||||
groups.add(tag)
|
||||
|
||||
# Store what we know is worth keping
|
||||
# Store what we know is worth keeping
|
||||
if tag not in group_tags: # pragma: no cover
|
||||
# handle cases where the tag doesn't exist
|
||||
group_tags[tag] = set()
|
||||
|
||||
results |= group_tags[tag] - tag_groups
|
||||
|
||||
# Get simple tag assignments
|
||||
|
@ -753,8 +763,7 @@ class ConfigBase(URLBase):
|
|||
try:
|
||||
# Attempt to create an instance of our plugin using the
|
||||
# parsed URL information
|
||||
plugin = common.NOTIFY_SCHEMA_MAP[
|
||||
results['schema']](**results)
|
||||
plugin = N_MGR[results['schema']](**results)
|
||||
|
||||
# Create log entry of loaded URL
|
||||
ConfigBase.logger.debug(
|
||||
|
@ -807,8 +816,7 @@ class ConfigBase(URLBase):
|
|||
# Create a copy of our dictionary
|
||||
tokens = tokens.copy()
|
||||
|
||||
for kw, meta in common.NOTIFY_SCHEMA_MAP[schema]\
|
||||
.template_kwargs.items():
|
||||
for kw, meta in N_MGR[schema].template_kwargs.items():
|
||||
|
||||
# Determine our prefix:
|
||||
prefix = meta.get('prefix', '+')
|
||||
|
@ -851,8 +859,7 @@ class ConfigBase(URLBase):
|
|||
#
|
||||
# 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])
|
||||
class_templates = plugins.details(N_MGR[schema])
|
||||
|
||||
for key in list(tokens.keys()):
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -28,10 +28,10 @@
|
|||
|
||||
import re
|
||||
import os
|
||||
from .ConfigBase import ConfigBase
|
||||
from .base import ConfigBase
|
||||
from ..common import ConfigFormat
|
||||
from ..common import ContentIncludeMode
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
|
||||
class ConfigFile(ConfigBase):
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -28,11 +28,11 @@
|
|||
|
||||
import re
|
||||
import requests
|
||||
from .ConfigBase import ConfigBase
|
||||
from .base import ConfigBase
|
||||
from ..common import ConfigFormat
|
||||
from ..common import ContentIncludeMode
|
||||
from ..URLBase import PrivacyMode
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..url import PrivacyMode
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
# Support TEXT formats
|
||||
# text/plain
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -26,8 +26,8 @@
|
|||
# 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 _
|
||||
from .base import ConfigBase
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
|
||||
class ConfigMemory(ConfigBase):
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -29,7 +29,7 @@
|
|||
import re
|
||||
#from markdown import markdown
|
||||
from .common import NotifyFormat
|
||||
from .URLBase import URLBase
|
||||
from .url import URLBase
|
||||
|
||||
from html.parser import HTMLParser
|
||||
|
||||
|
@ -58,8 +58,8 @@ def convert_between(from_format, to_format, content):
|
|||
# """
|
||||
# Converts specified content from markdown to HTML.
|
||||
# """
|
||||
|
||||
# return markdown(content)
|
||||
# return markdown(content, extensions=[
|
||||
# 'markdown.extensions.nl2br', 'markdown.extensions.tables'])
|
||||
|
||||
|
||||
def text_to_html(content):
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -27,7 +27,8 @@
|
|||
# POSSIBILITY OF SUCH DAMAGE.USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
from ..plugins.NotifyBase import NotifyBase
|
||||
from ..plugins.base import NotifyBase
|
||||
from ..manager_plugins import NotificationManager
|
||||
from ..utils import URL_DETAILS_RE
|
||||
from ..utils import parse_url
|
||||
from ..utils import url_assembly
|
||||
|
@ -36,6 +37,9 @@ from .. import common
|
|||
from ..logger import logger
|
||||
import inspect
|
||||
|
||||
# Grant access to our Notification Manager Singleton
|
||||
N_MGR = NotificationManager()
|
||||
|
||||
|
||||
class CustomNotifyPlugin(NotifyBase):
|
||||
"""
|
||||
|
@ -51,6 +55,9 @@ class CustomNotifyPlugin(NotifyBase):
|
|||
# should be treated differently.
|
||||
category = 'custom'
|
||||
|
||||
# Support Attachments
|
||||
attachment_support = True
|
||||
|
||||
# Define object templates
|
||||
templates = (
|
||||
'{schema}://',
|
||||
|
@ -91,17 +98,17 @@ class CustomNotifyPlugin(NotifyBase):
|
|||
logger.warning(msg)
|
||||
return None
|
||||
|
||||
# Acquire our plugin name
|
||||
plugin_name = re_match.group('schema').lower()
|
||||
# Acquire our schema
|
||||
schema = re_match.group('schema').lower()
|
||||
|
||||
if not re_match.group('base'):
|
||||
url = '{}://'.format(plugin_name)
|
||||
url = '{}://'.format(schema)
|
||||
|
||||
# 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)
|
||||
url, default_schema=schema, verify_host=False, simple=True)
|
||||
|
||||
if plugin_name in common.NOTIFY_SCHEMA_MAP:
|
||||
if schema in N_MGR:
|
||||
# we're already handling this object
|
||||
msg = 'The schema ({}) is already defined and could not be ' \
|
||||
'loaded from custom notify function {}.' \
|
||||
|
@ -117,10 +124,10 @@ class CustomNotifyPlugin(NotifyBase):
|
|||
|
||||
# Our Service Name
|
||||
service_name = name if isinstance(name, str) \
|
||||
and name else 'Custom - {}'.format(plugin_name)
|
||||
and name else 'Custom - {}'.format(schema)
|
||||
|
||||
# Store our matched schema
|
||||
secure_protocol = plugin_name
|
||||
secure_protocol = schema
|
||||
|
||||
requirements = {
|
||||
# Define our required packaging in order to work
|
||||
|
@ -143,6 +150,10 @@ class CustomNotifyPlugin(NotifyBase):
|
|||
|
||||
self._default_args = {}
|
||||
|
||||
# Some variables do not need to be set
|
||||
if 'secure' in kwargs:
|
||||
del kwargs['secure']
|
||||
|
||||
# Apply our updates based on what was parsed
|
||||
dict_full_update(self._default_args, self._base_args)
|
||||
dict_full_update(self._default_args, kwargs)
|
||||
|
@ -181,51 +192,26 @@ class CustomNotifyPlugin(NotifyBase):
|
|||
# Unhandled Exception
|
||||
self.logger.warning(
|
||||
'An exception occured sending a %s notification.',
|
||||
common.
|
||||
NOTIFY_SCHEMA_MAP[self.secure_protocol].service_name)
|
||||
N_MGR[self.secure_protocol].service_name)
|
||||
self.logger.debug(
|
||||
'%s Exception: %s',
|
||||
common.NOTIFY_SCHEMA_MAP[self.secure_protocol], str(e))
|
||||
N_MGR[self.secure_protocol], str(e))
|
||||
return False
|
||||
|
||||
if response:
|
||||
self.logger.info(
|
||||
'Sent %s notification.',
|
||||
common.
|
||||
NOTIFY_SCHEMA_MAP[self.secure_protocol].service_name)
|
||||
N_MGR[self.secure_protocol].service_name)
|
||||
else:
|
||||
self.logger.warning(
|
||||
'Failed to send %s notification.',
|
||||
common.
|
||||
NOTIFY_SCHEMA_MAP[self.secure_protocol].service_name)
|
||||
N_MGR[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]
|
||||
return N_MGR.add(
|
||||
plugin=CustomNotifyPluginWrapper,
|
||||
schemas=schema,
|
||||
send_func=send_func,
|
||||
url=url,
|
||||
)
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -26,7 +26,7 @@
|
|||
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
from .CustomNotifyPlugin import CustomNotifyPlugin
|
||||
from .base import CustomNotifyPlugin
|
||||
|
||||
|
||||
def notify(on, name=None):
|
||||
|
|
2273
lib/apprise/emojis.py
Normal file
2273
lib/apprise/emojis.py
Normal file
File diff suppressed because it is too large
Load diff
|
@ -1,21 +1,21 @@
|
|||
# Translations template for apprise.
|
||||
# Copyright (C) 2023 Chris Caron
|
||||
# Copyright (C) 2024 Chris Caron
|
||||
# This file is distributed under the same license as the apprise project.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, 2023.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, 2024.
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: apprise 1.6.0\n"
|
||||
"Project-Id-Version: apprise 1.8.0\n"
|
||||
"Report-Msgid-Bugs-To: lead2gold@gmail.com\n"
|
||||
"POT-Creation-Date: 2023-10-15 15:56-0400\n"
|
||||
"POT-Creation-Date: 2024-05-11 16:13-0400\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.11.0\n"
|
||||
"Generated-By: Babel 2.13.1\n"
|
||||
|
||||
msgid "A local Gnome environment is required."
|
||||
msgstr ""
|
||||
|
@ -32,6 +32,9 @@ msgstr ""
|
|||
msgid "API Secret"
|
||||
msgstr ""
|
||||
|
||||
msgid "API Token"
|
||||
msgstr ""
|
||||
|
||||
msgid "Access Key"
|
||||
msgstr ""
|
||||
|
||||
|
@ -101,9 +104,6 @@ msgstr ""
|
|||
msgid "Authentication Type"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authorization Token"
|
||||
msgstr ""
|
||||
|
||||
msgid "Avatar Image"
|
||||
msgstr ""
|
||||
|
||||
|
@ -125,6 +125,9 @@ msgstr ""
|
|||
msgid "Bot Token"
|
||||
msgstr ""
|
||||
|
||||
msgid "Bot Webhook Key"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cache Age"
|
||||
msgstr ""
|
||||
|
||||
|
@ -140,9 +143,15 @@ msgstr ""
|
|||
msgid "Category"
|
||||
msgstr ""
|
||||
|
||||
msgid "Channel ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Channels"
|
||||
msgstr ""
|
||||
|
||||
msgid "Chantify"
|
||||
msgstr ""
|
||||
|
||||
msgid "Class"
|
||||
msgstr ""
|
||||
|
||||
|
@ -230,6 +239,9 @@ msgstr ""
|
|||
msgid "Email Header"
|
||||
msgstr ""
|
||||
|
||||
msgid "Embed URL"
|
||||
msgstr ""
|
||||
|
||||
msgid "Entity"
|
||||
msgstr ""
|
||||
|
||||
|
@ -245,6 +257,9 @@ msgstr ""
|
|||
msgid "Facility"
|
||||
msgstr ""
|
||||
|
||||
msgid "Feishu"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fetch Method"
|
||||
msgstr ""
|
||||
|
||||
|
@ -266,6 +281,9 @@ msgstr ""
|
|||
msgid "Forced Mime Type"
|
||||
msgstr ""
|
||||
|
||||
msgid "Free-Mobile"
|
||||
msgstr ""
|
||||
|
||||
msgid "From Email"
|
||||
msgstr ""
|
||||
|
||||
|
@ -281,6 +299,12 @@ msgstr ""
|
|||
msgid "GET Params"
|
||||
msgstr ""
|
||||
|
||||
msgid "Gateway"
|
||||
msgstr ""
|
||||
|
||||
msgid "Gateway ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Gnome Notification"
|
||||
msgstr ""
|
||||
|
||||
|
@ -299,6 +323,9 @@ msgstr ""
|
|||
msgid "Icon Type"
|
||||
msgstr ""
|
||||
|
||||
msgid "Icon URL"
|
||||
msgstr ""
|
||||
|
||||
msgid "Idempotency-Key"
|
||||
msgstr ""
|
||||
|
||||
|
@ -323,6 +350,9 @@ msgstr ""
|
|||
msgid "Integration Key"
|
||||
msgstr ""
|
||||
|
||||
msgid "Interpret Emojis"
|
||||
msgstr ""
|
||||
|
||||
msgid "Is Ad?"
|
||||
msgstr ""
|
||||
|
||||
|
@ -344,6 +374,9 @@ msgstr ""
|
|||
msgid "Local File"
|
||||
msgstr ""
|
||||
|
||||
msgid "Locale"
|
||||
msgstr ""
|
||||
|
||||
msgid "Log PID"
|
||||
msgstr ""
|
||||
|
||||
|
@ -356,6 +389,9 @@ msgstr ""
|
|||
msgid "MacOSX Notification"
|
||||
msgstr ""
|
||||
|
||||
msgid "Markdown Version"
|
||||
msgstr ""
|
||||
|
||||
msgid "Master Key"
|
||||
msgstr ""
|
||||
|
||||
|
@ -490,6 +526,9 @@ msgstr ""
|
|||
msgid "Reply To Email"
|
||||
msgstr ""
|
||||
|
||||
msgid "Resend Delay"
|
||||
msgstr ""
|
||||
|
||||
msgid "Resubmit Flag"
|
||||
msgstr ""
|
||||
|
||||
|
@ -661,6 +700,9 @@ msgstr ""
|
|||
msgid "Target Team"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Threema ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Topic"
|
||||
msgstr ""
|
||||
|
||||
|
@ -757,6 +799,9 @@ msgstr ""
|
|||
msgid "Unicode Characters"
|
||||
msgstr ""
|
||||
|
||||
msgid "Upload"
|
||||
msgstr ""
|
||||
|
||||
msgid "Urgency"
|
||||
msgstr ""
|
||||
|
||||
|
@ -775,9 +820,6 @@ msgstr ""
|
|||
msgid "User Email"
|
||||
msgstr ""
|
||||
|
||||
msgid "User ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "User Key"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -219,6 +219,9 @@ class AppriseLocale:
|
|||
try:
|
||||
# Acquire our locale
|
||||
lang = locale.getlocale()[0]
|
||||
# Compatibility for Python >= 3.12
|
||||
if lang == 'C':
|
||||
lang = AppriseLocale._default_language
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
# This occurs when an invalid locale was parsed from the
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
|
756
lib/apprise/manager.py
Normal file
756
lib/apprise/manager.py
Normal file
|
@ -0,0 +1,756 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
#
|
||||
# 1. Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
#
|
||||
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import hashlib
|
||||
import inspect
|
||||
import threading
|
||||
from .utils import import_module
|
||||
from .utils import Singleton
|
||||
from .utils import parse_list
|
||||
from os.path import dirname
|
||||
from os.path import abspath
|
||||
from os.path import join
|
||||
|
||||
from .logger import logger
|
||||
|
||||
|
||||
class PluginManager(metaclass=Singleton):
|
||||
"""
|
||||
Designed to be a singleton object to maintain all initialized loading
|
||||
of modules in memory.
|
||||
"""
|
||||
|
||||
# Description (used for logging)
|
||||
name = 'Singleton Plugin'
|
||||
|
||||
# Memory Space
|
||||
_id = 'undefined'
|
||||
|
||||
# Our Module Python path name
|
||||
module_name_prefix = f'apprise.{_id}'
|
||||
|
||||
# The module path to scan
|
||||
module_path = join(abspath(dirname(__file__)), _id)
|
||||
|
||||
# For filtering our result when scanning a module
|
||||
module_filter_re = re.compile(r'^(?P<name>((?!_)[A-Za-z0-9]+))$')
|
||||
|
||||
# thread safe loading
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
Over-ride our class instantiation to provide a singleton
|
||||
"""
|
||||
|
||||
self._module_map = None
|
||||
self._schema_map = None
|
||||
|
||||
# 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 _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': <CustomNotifyWrapperPlugin>
|
||||
# },
|
||||
# 'schema2': {
|
||||
# 'name': 'Custom schema name',
|
||||
# 'fn_name': 'name_of_function_decorator_was_found_on',
|
||||
# 'url': 'schema://any/additional/info/found/on/url'
|
||||
# 'plugin': <CustomNotifyWrapperPlugin>
|
||||
# }
|
||||
# }
|
||||
# Note: that the <CustomNotifyWrapperPlugin> inherits from
|
||||
# NotifyBase
|
||||
self._custom_module_map = {}
|
||||
|
||||
# Track manually disabled modules (by their schema)
|
||||
self._disabled = set()
|
||||
|
||||
# Hash of all paths previously scanned so we don't waste
|
||||
# effort/overhead doing it again
|
||||
self._paths_previously_scanned = set()
|
||||
|
||||
# Track loaded module paths to prevent from loading them again
|
||||
self._loaded = set()
|
||||
|
||||
def unload_modules(self, disable_native=False):
|
||||
"""
|
||||
Reset our object and unload all modules
|
||||
"""
|
||||
|
||||
with self._lock:
|
||||
if self._custom_module_map:
|
||||
# Handle Custom Module Assignments
|
||||
for meta in self._custom_module_map.values():
|
||||
if meta['name'] not in self._module_map:
|
||||
# Nothing to remove
|
||||
continue
|
||||
|
||||
# For the purpose of tidying up un-used modules in memory
|
||||
loaded = [m for m in sys.modules.keys()
|
||||
if m.startswith(
|
||||
self._module_map[meta['name']]['path'])]
|
||||
|
||||
for module_path in loaded:
|
||||
del sys.modules[module_path]
|
||||
|
||||
# Reset disabled plugins (if any)
|
||||
for schema in self._disabled:
|
||||
self._schema_map[schema].enabled = True
|
||||
self._disabled.clear()
|
||||
|
||||
# Reset our variables
|
||||
self._schema_map = {}
|
||||
self._custom_module_map = {}
|
||||
if disable_native:
|
||||
self._module_map = {}
|
||||
|
||||
else:
|
||||
self._module_map = None
|
||||
self._loaded = set()
|
||||
|
||||
# Reset our path cache
|
||||
self._paths_previously_scanned = set()
|
||||
|
||||
def load_modules(self, path=None, name=None, force=False):
|
||||
"""
|
||||
Load our modules into memory
|
||||
"""
|
||||
|
||||
# Default value
|
||||
module_name_prefix = self.module_name_prefix if name is None else name
|
||||
module_path = self.module_path if path is None else path
|
||||
|
||||
with self._lock:
|
||||
if not force and module_path in self._loaded:
|
||||
# We're done
|
||||
return
|
||||
|
||||
# Our base reference
|
||||
module_count = len(self._module_map) if self._module_map else 0
|
||||
schema_count = len(self._schema_map) if self._schema_map else 0
|
||||
|
||||
if not self:
|
||||
# Initialize our maps
|
||||
self._module_map = {}
|
||||
self._schema_map = {}
|
||||
self._custom_module_map = {}
|
||||
|
||||
# Used for the detection of additional Notify Services objects
|
||||
# The .py extension is optional as we support loading directories
|
||||
# too
|
||||
module_re = re.compile(
|
||||
r'^(?P<name>(?!base|_)[a-z0-9_]+)(\.py)?$',
|
||||
re.I)
|
||||
|
||||
t_start = time.time()
|
||||
for f in os.listdir(module_path):
|
||||
tl_start = time.time()
|
||||
match = module_re.match(f)
|
||||
if not match:
|
||||
# keep going
|
||||
continue
|
||||
|
||||
# Store our notification/plugin name:
|
||||
module_name = match.group('name')
|
||||
module_pyname = '{}.{}'.format(module_name_prefix, module_name)
|
||||
|
||||
if module_name in self._module_map:
|
||||
logger.warning(
|
||||
"%s(s) (%s) already loaded; ignoring %s",
|
||||
self.name, module_name, os.path.join(module_path, f))
|
||||
continue
|
||||
|
||||
try:
|
||||
module = __import__(
|
||||
module_pyname,
|
||||
globals(), locals(),
|
||||
fromlist=[module_name])
|
||||
|
||||
except ImportError:
|
||||
# No problem, we can try again another way...
|
||||
module = import_module(
|
||||
os.path.join(module_path, f), module_pyname)
|
||||
if not module:
|
||||
# logging found in import_module and not needed here
|
||||
continue
|
||||
|
||||
module_class = None
|
||||
for m_class in [obj for obj in dir(module)
|
||||
if self.module_filter_re.match(obj)]:
|
||||
# Get our plugin
|
||||
plugin = getattr(module, m_class)
|
||||
if not hasattr(plugin, 'app_id'):
|
||||
# Filter out non-notification modules
|
||||
logger.trace(
|
||||
"(%s) import failed; no app_id defined in %s",
|
||||
self.name, m_class, os.path.join(module_path, f))
|
||||
continue
|
||||
|
||||
# Add our plugin name to our module map
|
||||
self._module_map[module_name] = {
|
||||
'plugin': set([plugin]),
|
||||
'module': module,
|
||||
'path': '{}.{}'.format(
|
||||
module_name_prefix, module_name),
|
||||
'native': True,
|
||||
}
|
||||
|
||||
fn = getattr(plugin, 'schemas', None)
|
||||
schemas = set([]) if not callable(fn) else fn(plugin)
|
||||
|
||||
# map our schema to our plugin
|
||||
for schema in schemas:
|
||||
if schema in self._schema_map:
|
||||
logger.error(
|
||||
"{} schema ({}) mismatch detected - {} to {}"
|
||||
.format(self.name, schema, self._schema_map,
|
||||
plugin))
|
||||
continue
|
||||
|
||||
# Assign plugin
|
||||
self._schema_map[schema] = plugin
|
||||
|
||||
# Store our class
|
||||
module_class = m_class
|
||||
break
|
||||
|
||||
if not module_class:
|
||||
# Not a library we can load as it doesn't follow the simple
|
||||
# rule that the class must bear the same name as the
|
||||
# notification file itself.
|
||||
logger.trace(
|
||||
"%s (%s) import failed; no filename/Class "
|
||||
"match found in %s",
|
||||
self.name, module_name, os.path.join(module_path, f))
|
||||
continue
|
||||
|
||||
logger.trace(
|
||||
'{} {} loaded in {:.6f}s'.format(
|
||||
self.name, module_name, (time.time() - tl_start)))
|
||||
|
||||
# Track the directory loaded so we never load it again
|
||||
self._loaded.add(module_path)
|
||||
|
||||
logger.debug(
|
||||
'{} {}(s) and {} Schema(s) loaded in {:.4f}s'
|
||||
.format(
|
||||
self.name,
|
||||
len(self._module_map) - module_count,
|
||||
len(self._schema_map) - schema_count,
|
||||
(time.time() - t_start)))
|
||||
|
||||
def module_detection(self, paths, cache=True):
|
||||
"""
|
||||
Leverage the @notify decorator and load all objects found matching
|
||||
this.
|
||||
"""
|
||||
# A simple restriction that we don't allow periods in the filename at
|
||||
# all so it can't be hidden (Linux OS's) and it won't conflict with
|
||||
# Python path naming. This also prevents us from loading any python
|
||||
# file that starts with an underscore or dash
|
||||
# We allow for __init__.py as well
|
||||
module_re = re.compile(
|
||||
r'^(?P<name>[_a-z0-9][a-z0-9._-]+)?(\.py)?$', re.I)
|
||||
|
||||
# Validate if we're a loadable Python file or not
|
||||
valid_python_file_re = re.compile(r'.+\.py(o|c)?$', re.IGNORECASE)
|
||||
|
||||
if isinstance(paths, str):
|
||||
paths = [paths, ]
|
||||
|
||||
if not paths or not isinstance(paths, (tuple, list)):
|
||||
# We're done
|
||||
return
|
||||
|
||||
def _import_module(path):
|
||||
# Since our plugin name can conflict (as a module) with another
|
||||
# we want to generate random strings to avoid steping on
|
||||
# another's namespace
|
||||
if not (path and valid_python_file_re.match(path)):
|
||||
# Ignore file/module type
|
||||
logger.trace('Plugin Scan: Skipping %s', path)
|
||||
return
|
||||
|
||||
t_start = time.time()
|
||||
module_name = hashlib.sha1(path.encode('utf-8')).hexdigest()
|
||||
module_pyname = "{prefix}.{name}".format(
|
||||
prefix='apprise.custom.module', name=module_name)
|
||||
|
||||
if module_pyname in self._custom_module_map:
|
||||
# First clear out existing entries
|
||||
for schema in \
|
||||
self._custom_module_map[module_pyname]['notify']\
|
||||
.keys():
|
||||
|
||||
# Remove any mapped modules to this file
|
||||
del self._schema_map[schema]
|
||||
|
||||
# Reset
|
||||
del self._custom_module_map[module_pyname]
|
||||
|
||||
# Load our module
|
||||
module = import_module(path, module_pyname)
|
||||
if not module:
|
||||
# No problem, we can't use this object
|
||||
logger.warning('Failed to load custom module: %s', _path)
|
||||
return
|
||||
|
||||
# Print our loaded modules if any
|
||||
if module_pyname in self._custom_module_map:
|
||||
logger.debug(
|
||||
'Custom module %s - %d schema(s) (name=%s) '
|
||||
'loaded in %.6fs', _path,
|
||||
len(self._custom_module_map[module_pyname]['notify']),
|
||||
module_name, (time.time() - t_start))
|
||||
|
||||
# Add our plugin name to our module map
|
||||
self._module_map[module_name] = {
|
||||
'plugin': set(),
|
||||
'module': module,
|
||||
'path': module_pyname,
|
||||
'native': False,
|
||||
}
|
||||
|
||||
for schema, meta in\
|
||||
self._custom_module_map[module_pyname]['notify']\
|
||||
.items():
|
||||
|
||||
# For mapping purposes; map our element in our main list
|
||||
self._module_map[module_name]['plugin'].add(
|
||||
self._schema_map[schema])
|
||||
|
||||
# Log our success
|
||||
logger.info('Loaded custom notification: %s://', schema)
|
||||
else:
|
||||
# The code reaches here if we successfully loaded the Python
|
||||
# module but no hooks/triggers were found. So we can safely
|
||||
# just remove/ignore this entry
|
||||
del sys.modules[module_pyname]
|
||||
return
|
||||
|
||||
# end of _import_module()
|
||||
return
|
||||
|
||||
for _path in paths:
|
||||
path = os.path.abspath(os.path.expanduser(_path))
|
||||
if (cache and path in self._paths_previously_scanned) \
|
||||
or not os.path.exists(path):
|
||||
# We're done as we've already scanned this
|
||||
continue
|
||||
|
||||
# Store our path as a way of hashing it has been handled
|
||||
self._paths_previously_scanned.add(path)
|
||||
|
||||
if os.path.isdir(path) and not \
|
||||
os.path.isfile(os.path.join(path, '__init__.py')):
|
||||
|
||||
logger.debug('Scanning for custom plugins in: %s', path)
|
||||
for entry in os.listdir(path):
|
||||
re_match = module_re.match(entry)
|
||||
if not re_match:
|
||||
# keep going
|
||||
logger.trace('Plugin Scan: Ignoring %s', entry)
|
||||
continue
|
||||
|
||||
new_path = os.path.join(path, entry)
|
||||
if os.path.isdir(new_path):
|
||||
# Update our path
|
||||
new_path = os.path.join(path, entry, '__init__.py')
|
||||
if not os.path.isfile(new_path):
|
||||
logger.trace(
|
||||
'Plugin Scan: Ignoring %s',
|
||||
os.path.join(path, entry))
|
||||
continue
|
||||
|
||||
if not cache or \
|
||||
(cache and new_path not in
|
||||
self._paths_previously_scanned):
|
||||
# Load our module
|
||||
_import_module(new_path)
|
||||
|
||||
# Add our subdir path
|
||||
self._paths_previously_scanned.add(new_path)
|
||||
else:
|
||||
if os.path.isdir(path):
|
||||
# This logic is safe to apply because we already
|
||||
# validated the directories state above; update our
|
||||
# path
|
||||
path = os.path.join(path, '__init__.py')
|
||||
if cache and path in self._paths_previously_scanned:
|
||||
continue
|
||||
|
||||
self._paths_previously_scanned.add(path)
|
||||
|
||||
# directly load as is
|
||||
re_match = module_re.match(os.path.basename(path))
|
||||
# must be a match and must have a .py extension
|
||||
if not re_match or not re_match.group(1):
|
||||
# keep going
|
||||
logger.trace('Plugin Scan: Ignoring %s', path)
|
||||
continue
|
||||
|
||||
# Load our module
|
||||
_import_module(path)
|
||||
|
||||
return None
|
||||
|
||||
def add(self, plugin, schemas=None, url=None, send_func=None):
|
||||
"""
|
||||
Ability to manually add Notification services to our stack
|
||||
"""
|
||||
|
||||
if not self:
|
||||
# Lazy load
|
||||
self.load_modules()
|
||||
|
||||
# Acquire a list of schemas
|
||||
p_schemas = parse_list(plugin.secure_protocol, plugin.protocol)
|
||||
if isinstance(schemas, str):
|
||||
schemas = [schemas, ]
|
||||
|
||||
elif schemas is None:
|
||||
# Default
|
||||
schemas = p_schemas
|
||||
|
||||
if not schemas or not isinstance(schemas, (set, tuple, list)):
|
||||
# We're done
|
||||
logger.error(
|
||||
'The schemas provided (type %s) is unsupported; '
|
||||
'loaded from %s.',
|
||||
type(schemas),
|
||||
send_func.__name__ if send_func else plugin.__class__.__name__)
|
||||
return False
|
||||
|
||||
# Convert our schemas into a set
|
||||
schemas = set([s.lower() for s in schemas]) | set(p_schemas)
|
||||
|
||||
# Valdation
|
||||
conflict = [s for s in schemas if s in self]
|
||||
if conflict:
|
||||
# we're already handling this schema
|
||||
logger.warning(
|
||||
'The schema(s) (%s) are already defined and could not be '
|
||||
'loaded from %s%s.',
|
||||
', '.join(conflict),
|
||||
'custom notify function ' if send_func else '',
|
||||
send_func.__name__ if send_func else plugin.__class__.__name__)
|
||||
return False
|
||||
|
||||
if send_func:
|
||||
# Acquire the function name
|
||||
fn_name = send_func.__name__
|
||||
|
||||
# Acquire the python filename path
|
||||
path = inspect.getfile(send_func)
|
||||
|
||||
# Acquire our path to our module
|
||||
module_name = str(send_func.__module__)
|
||||
|
||||
if module_name not in self._custom_module_map:
|
||||
# Support non-dynamic includes as well...
|
||||
self._custom_module_map[module_name] = {
|
||||
# Name can be useful for indexing back into the
|
||||
# _module_map object; this is the key to do it with:
|
||||
'name': module_name.split('.')[-1],
|
||||
|
||||
# The path to the module loaded
|
||||
'path': path,
|
||||
|
||||
# Initialize our template
|
||||
'notify': {},
|
||||
}
|
||||
|
||||
for schema in schemas:
|
||||
self._custom_module_map[module_name]['notify'][schema] = {
|
||||
# The name of the send function the @notify decorator
|
||||
# wrapped
|
||||
'fn_name': fn_name,
|
||||
# The URL that was provided in the @notify decorator call
|
||||
# associated with the 'on='
|
||||
'url': url,
|
||||
}
|
||||
|
||||
else:
|
||||
module_name = hashlib.sha1(
|
||||
''.join(schemas).encode('utf-8')).hexdigest()
|
||||
module_pyname = "{prefix}.{name}".format(
|
||||
prefix='apprise.adhoc.module', name=module_name)
|
||||
|
||||
# Add our plugin name to our module map
|
||||
self._module_map[module_name] = {
|
||||
'plugin': set([plugin]),
|
||||
'module': None,
|
||||
'path': module_pyname,
|
||||
'native': False,
|
||||
}
|
||||
|
||||
for schema in schemas:
|
||||
# Assign our mapping
|
||||
self._schema_map[schema] = plugin
|
||||
|
||||
return True
|
||||
|
||||
def remove(self, *schemas):
|
||||
"""
|
||||
Removes a loaded element (if defined)
|
||||
"""
|
||||
if not self:
|
||||
# Lazy load
|
||||
self.load_modules()
|
||||
|
||||
for schema in schemas:
|
||||
try:
|
||||
del self[schema]
|
||||
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def plugins(self, include_disabled=True):
|
||||
"""
|
||||
Return all of our loaded plugins
|
||||
"""
|
||||
if not self:
|
||||
# Lazy load
|
||||
self.load_modules()
|
||||
|
||||
for module in self._module_map.values():
|
||||
for plugin in module['plugin']:
|
||||
if not include_disabled and not plugin.enabled:
|
||||
continue
|
||||
yield plugin
|
||||
|
||||
def schemas(self, include_disabled=True):
|
||||
"""
|
||||
Return all of our loaded schemas
|
||||
|
||||
if include_disabled == True, then even disabled notifications are
|
||||
returned
|
||||
"""
|
||||
if not self:
|
||||
# Lazy load
|
||||
self.load_modules()
|
||||
|
||||
# Return our list
|
||||
return list(self._schema_map.keys()) if include_disabled else \
|
||||
[s for s in self._schema_map.keys() if self._schema_map[s].enabled]
|
||||
|
||||
def disable(self, *schemas):
|
||||
"""
|
||||
Disables the modules associated with the specified schemas
|
||||
"""
|
||||
if not self:
|
||||
# Lazy load
|
||||
self.load_modules()
|
||||
|
||||
for schema in schemas:
|
||||
if schema not in self._schema_map:
|
||||
continue
|
||||
|
||||
if not self._schema_map[schema].enabled:
|
||||
continue
|
||||
|
||||
# Disable
|
||||
self._schema_map[schema].enabled = False
|
||||
self._disabled.add(schema)
|
||||
|
||||
def enable_only(self, *schemas):
|
||||
"""
|
||||
Disables the modules associated with the specified schemas
|
||||
"""
|
||||
if not self:
|
||||
# Lazy load
|
||||
self.load_modules()
|
||||
|
||||
# convert to set for faster indexing
|
||||
schemas = set(schemas)
|
||||
|
||||
for plugin in self.plugins():
|
||||
# Get our plugin's schema list
|
||||
p_schemas = set(
|
||||
parse_list(plugin.secure_protocol, plugin.protocol))
|
||||
|
||||
if not schemas & p_schemas:
|
||||
if plugin.enabled:
|
||||
# Disable it (only if previously enabled); this prevents us
|
||||
# from adjusting schemas that were disabled due to missing
|
||||
# libraries or other environment reasons
|
||||
plugin.enabled = False
|
||||
self._disabled |= p_schemas
|
||||
continue
|
||||
|
||||
# If we reach here, our schema was flagged to be enabled
|
||||
if p_schemas & self._disabled:
|
||||
# Previously disabled; no worries, let's clear this up
|
||||
self._disabled -= p_schemas
|
||||
plugin.enabled = True
|
||||
|
||||
def __contains__(self, schema):
|
||||
"""
|
||||
Checks if a schema exists
|
||||
"""
|
||||
if not self:
|
||||
# Lazy load
|
||||
self.load_modules()
|
||||
|
||||
return schema in self._schema_map
|
||||
|
||||
def __delitem__(self, schema):
|
||||
if not self:
|
||||
# Lazy load
|
||||
self.load_modules()
|
||||
|
||||
# Get our plugin (otherwise we throw a KeyError) which is
|
||||
# intended on del action that doesn't align
|
||||
plugin = self._schema_map[schema]
|
||||
|
||||
# Our list of all schema entries
|
||||
p_schemas = set([schema])
|
||||
|
||||
for key in list(self._module_map.keys()):
|
||||
if plugin in self._module_map[key]['plugin']:
|
||||
# Remove our plugin
|
||||
self._module_map[key]['plugin'].remove(plugin)
|
||||
|
||||
# Custom Plugin Entry; Clean up cross reference
|
||||
module_pyname = self._module_map[key]['path']
|
||||
if not self._module_map[key]['native'] and \
|
||||
module_pyname in self._custom_module_map:
|
||||
|
||||
del self.\
|
||||
_custom_module_map[module_pyname]['notify'][schema]
|
||||
|
||||
if not self._custom_module_map[module_pyname]['notify']:
|
||||
#
|
||||
# Last custom loaded element
|
||||
#
|
||||
|
||||
# Free up custom object entry
|
||||
del self._custom_module_map[module_pyname]
|
||||
|
||||
if not self._module_map[key]['plugin']:
|
||||
#
|
||||
# Last element
|
||||
#
|
||||
if self._module_map[key]['native']:
|
||||
# Get our plugin's schema list
|
||||
p_schemas = \
|
||||
set([s for s in parse_list(
|
||||
plugin.secure_protocol, plugin.protocol)
|
||||
if s in self._schema_map])
|
||||
|
||||
# free system memory
|
||||
if self._module_map[key]['module']:
|
||||
del sys.modules[self._module_map[key]['path']]
|
||||
|
||||
# free last remaining pointer in module map
|
||||
del self._module_map[key]
|
||||
|
||||
for schema in p_schemas:
|
||||
# Final Tidy
|
||||
del self._schema_map[schema]
|
||||
|
||||
def __setitem__(self, schema, plugin):
|
||||
"""
|
||||
Support fast assigning of Plugin/Notification Objects
|
||||
"""
|
||||
if not self:
|
||||
# Lazy load
|
||||
self.load_modules()
|
||||
|
||||
# Set default values if not otherwise set
|
||||
if not plugin.service_name:
|
||||
# Assign service name if one doesn't exist
|
||||
plugin.service_name = f'{schema}://'
|
||||
|
||||
p_schemas = set(
|
||||
parse_list(plugin.secure_protocol, plugin.protocol))
|
||||
if not p_schemas:
|
||||
# Assign our protocol
|
||||
plugin.secure_protocol = schema
|
||||
p_schemas.add(schema)
|
||||
|
||||
elif schema not in p_schemas:
|
||||
# Add our others (if defined)
|
||||
plugin.secure_protocol = \
|
||||
set([schema] + parse_list(plugin.secure_protocol))
|
||||
p_schemas.add(schema)
|
||||
|
||||
if not self.add(plugin, schemas=p_schemas):
|
||||
raise KeyError('Conflicting Assignment')
|
||||
|
||||
def __getitem__(self, schema):
|
||||
"""
|
||||
Returns the indexed plugin identified by the schema specified
|
||||
"""
|
||||
if not self:
|
||||
# Lazy load
|
||||
self.load_modules()
|
||||
|
||||
return self._schema_map[schema]
|
||||
|
||||
def __iter__(self):
|
||||
"""
|
||||
Returns an iterator so we can iterate over our loaded modules
|
||||
"""
|
||||
if not self:
|
||||
# Lazy load
|
||||
self.load_modules()
|
||||
|
||||
return iter(self._module_map.values())
|
||||
|
||||
def __len__(self):
|
||||
"""
|
||||
Returns the number of modules/plugins loaded
|
||||
"""
|
||||
if not self:
|
||||
# Lazy load
|
||||
self.load_modules()
|
||||
|
||||
return len(self._module_map)
|
||||
|
||||
def __bool__(self):
|
||||
"""
|
||||
Determines if object has loaded or not
|
||||
"""
|
||||
return True if self._loaded and self._module_map is not None else False
|
59
lib/apprise/manager_attachment.py
Normal file
59
lib/apprise/manager_attachment.py
Normal file
|
@ -0,0 +1,59 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
#
|
||||
# 1. Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
#
|
||||
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
import re
|
||||
from os.path import dirname
|
||||
from os.path import abspath
|
||||
from os.path import join
|
||||
from .manager import PluginManager
|
||||
|
||||
|
||||
class AttachmentManager(PluginManager):
|
||||
"""
|
||||
Designed to be a singleton object to maintain all initialized
|
||||
attachment plugins/modules in memory.
|
||||
"""
|
||||
|
||||
# Description (used for logging)
|
||||
name = 'Attachment Plugin'
|
||||
|
||||
# Filename Prefix to filter on
|
||||
fname_prefix = 'Attach'
|
||||
|
||||
# Memory Space
|
||||
_id = 'attachment'
|
||||
|
||||
# Our Module Python path name
|
||||
module_name_prefix = f'apprise.{_id}'
|
||||
|
||||
# The module path to scan
|
||||
module_path = join(abspath(dirname(__file__)), _id)
|
||||
|
||||
# For filtering our result set
|
||||
module_filter_re = re.compile(
|
||||
r'^(?P<name>' + fname_prefix + r'(?!Base)[A-Za-z0-9]+)$')
|
59
lib/apprise/manager_config.py
Normal file
59
lib/apprise/manager_config.py
Normal file
|
@ -0,0 +1,59 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
#
|
||||
# 1. Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
#
|
||||
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
import re
|
||||
from os.path import dirname
|
||||
from os.path import abspath
|
||||
from os.path import join
|
||||
from .manager import PluginManager
|
||||
|
||||
|
||||
class ConfigurationManager(PluginManager):
|
||||
"""
|
||||
Designed to be a singleton object to maintain all initialized
|
||||
configuration plugins/modules in memory.
|
||||
"""
|
||||
|
||||
# Description (used for logging)
|
||||
name = 'Configuration Plugin'
|
||||
|
||||
# Filename Prefix to filter on
|
||||
fname_prefix = 'Config'
|
||||
|
||||
# Memory Space
|
||||
_id = 'config'
|
||||
|
||||
# Our Module Python path name
|
||||
module_name_prefix = f'apprise.{_id}'
|
||||
|
||||
# The module path to scan
|
||||
module_path = join(abspath(dirname(__file__)), _id)
|
||||
|
||||
# For filtering our result set
|
||||
module_filter_re = re.compile(
|
||||
r'^(?P<name>' + fname_prefix + r'(?!Base)[A-Za-z0-9]+)$')
|
60
lib/apprise/manager_plugins.py
Normal file
60
lib/apprise/manager_plugins.py
Normal file
|
@ -0,0 +1,60 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
#
|
||||
# 1. Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
#
|
||||
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
import re
|
||||
from os.path import dirname
|
||||
from os.path import abspath
|
||||
from os.path import join
|
||||
from .manager import PluginManager
|
||||
|
||||
|
||||
class NotificationManager(PluginManager):
|
||||
"""
|
||||
Designed to be a singleton object to maintain all initialized notifications
|
||||
in memory.
|
||||
"""
|
||||
|
||||
# Description (used for logging)
|
||||
name = 'Notification Plugin'
|
||||
|
||||
# Filename Prefix to filter on
|
||||
fname_prefix = 'Notify'
|
||||
|
||||
# Memory Space
|
||||
_id = 'plugins'
|
||||
|
||||
# Our Module Python path name
|
||||
module_name_prefix = f'apprise.{_id}'
|
||||
|
||||
# The module path to scan
|
||||
module_path = join(abspath(dirname(__file__)), _id)
|
||||
|
||||
# For filtering our result set
|
||||
module_filter_re = re.compile(
|
||||
r'^(?P<name>' + fname_prefix +
|
||||
r'(?!Base|ImageSize|Type)[A-Za-z0-9]+)$')
|
|
@ -1,386 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
#
|
||||
# 1. Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
#
|
||||
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
# 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<user>[a-z0-9_]+)([/\\]|\%2F))?'
|
||||
r'(?P<channel>[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
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -27,26 +27,26 @@
|
|||
# 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 .base 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
|
||||
from ..locale import gettext_lazy as _
|
||||
from ..locale import LazyTranslation
|
||||
from ..manager_plugins import NotificationManager
|
||||
|
||||
|
||||
# Grant access to our Notification Manager Singleton
|
||||
N_MGR = NotificationManager()
|
||||
|
||||
__all__ = [
|
||||
# Reference
|
||||
|
@ -58,101 +58,6 @@ __all__ = [
|
|||
]
|
||||
|
||||
|
||||
# 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'^(?P<name>Notify[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
|
||||
|
@ -176,6 +81,10 @@ def _sanitize_token(tokens, default_delimiter):
|
|||
# Do not touch this field
|
||||
continue
|
||||
|
||||
elif 'name' not in tokens[key]:
|
||||
# Default to key
|
||||
tokens[key]['name'] = key
|
||||
|
||||
if 'map_to' not in tokens[key]:
|
||||
# Default type to key
|
||||
tokens[key]['map_to'] = key
|
||||
|
@ -538,16 +447,16 @@ def url_to_dict(url, secure_logging=True):
|
|||
|
||||
# Ensure our schema is always in lower case
|
||||
schema = schema.group('schema').lower()
|
||||
if schema not in common.NOTIFY_SCHEMA_MAP:
|
||||
if schema not in N_MGR:
|
||||
# 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)
|
||||
results = None
|
||||
for plugin in N_MGR.plugins():
|
||||
results = plugin.parse_native_url(_url)
|
||||
if results:
|
||||
break
|
||||
|
||||
if not results:
|
||||
logger.error('Unparseable URL {}'.format(loggable_url))
|
||||
|
@ -560,14 +469,14 @@ def url_to_dict(url, secure_logging=True):
|
|||
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)
|
||||
results = N_MGR[schema].parse_url(_url)
|
||||
if not results:
|
||||
logger.error('Unparseable {} URL {}'.format(
|
||||
common.NOTIFY_SCHEMA_MAP[schema].service_name, loggable_url))
|
||||
N_MGR[schema].service_name, loggable_url))
|
||||
return None
|
||||
|
||||
logger.trace('{} URL {} unpacked as:{}{}'.format(
|
||||
common.NOTIFY_SCHEMA_MAP[schema].service_name, url,
|
||||
N_MGR[schema].service_name, url,
|
||||
os.linesep, os.linesep.join(
|
||||
['{}="{}"'.format(k, v) for k, v in results.items()])))
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -31,12 +31,12 @@ import requests
|
|||
from json import dumps
|
||||
import base64
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..URLBase import PrivacyMode
|
||||
from .base import NotifyBase
|
||||
from ..url import PrivacyMode
|
||||
from ..common import NotifyType
|
||||
from ..utils import parse_list
|
||||
from ..utils import validate_regex
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
|
||||
class AppriseAPIMethod:
|
||||
|
@ -123,7 +123,7 @@ class NotifyAppriseAPI(NotifyBase):
|
|||
'type': 'string',
|
||||
'required': True,
|
||||
'private': True,
|
||||
'regex': (r'^[A-Z0-9_-]{1,32}$', 'i'),
|
||||
'regex': (r'^[A-Z0-9_-]{1,128}$', 'i'),
|
||||
},
|
||||
})
|
||||
|
778
lib/apprise/plugins/aprs.py
Normal file
778
lib/apprise/plugins/aprs.py
Normal file
|
@ -0,0 +1,778 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
#
|
||||
# 1. Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
#
|
||||
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
#
|
||||
# To use this plugin, you need to be a licensed ham radio operator
|
||||
#
|
||||
# Plugin constraints:
|
||||
#
|
||||
# - message length = 67 chars max.
|
||||
# - message content = ASCII 7 bit
|
||||
# - APRS messages will be sent without msg ID, meaning that
|
||||
# ham radio operators cannot acknowledge them
|
||||
# - Bring your own APRS-IS passcode. If you don't know what
|
||||
# this is or how to get it, then this plugin is not for you
|
||||
# - Do NOT change the Device/ToCall ID setting UNLESS this
|
||||
# module is used outside of Apprise. This identifier helps
|
||||
# the ham radio community with determining the software behind
|
||||
# a given APRS message.
|
||||
# - With great (ham radio) power comes great responsibility; do
|
||||
# not use this plugin for spamming other ham radio operators
|
||||
|
||||
#
|
||||
# In order to digest text input which is not in plain English,
|
||||
# users can install the optional 'unidecode' package as part
|
||||
# of their venv environment. Details: see plugin description
|
||||
#
|
||||
|
||||
#
|
||||
# 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:
|
||||
# - aprs://{user}:{password}@{callsign}
|
||||
# - aprs://{user}:{password}@{callsign1}/{callsign2}
|
||||
|
||||
# Optional parameters:
|
||||
# - locale --> APRS-IS target server to connect with
|
||||
# Default: EURO --> 'euro.aprs2.net'
|
||||
# Details: https://www.aprs2.net/
|
||||
|
||||
#
|
||||
# APRS message format specification:
|
||||
# http://www.aprs.org/doc/APRS101.PDF
|
||||
#
|
||||
|
||||
import socket
|
||||
import sys
|
||||
from itertools import chain
|
||||
from .base import NotifyBase
|
||||
from ..locale import gettext_lazy as _
|
||||
from ..url import PrivacyMode
|
||||
from ..common import NotifyType
|
||||
from ..utils import is_call_sign
|
||||
from ..utils import parse_call_sign
|
||||
from .. import __version__
|
||||
import re
|
||||
|
||||
# Fixed APRS-IS server locales
|
||||
# Default is 'EURO'
|
||||
# See https://www.aprs2.net/ for details
|
||||
# Select the rotating server in case you
|
||||
# don"t care about a specific locale
|
||||
APRS_LOCALES = {
|
||||
"NOAM": "noam.aprs2.net",
|
||||
"SOAM": "soam.aprs2.net",
|
||||
"EURO": "euro.aprs2.net",
|
||||
"ASIA": "asia.aprs2.net",
|
||||
"AUNZ": "aunz.aprs2.net",
|
||||
"ROTA": "rotate.aprs2.net",
|
||||
}
|
||||
|
||||
# Identify all unsupported characters
|
||||
APRS_BAD_CHARMAP = {
|
||||
r"Ä": "Ae",
|
||||
r"Ö": "Oe",
|
||||
r"Ü": "Ue",
|
||||
r"ä": "ae",
|
||||
r"ö": "oe",
|
||||
r"ü": "ue",
|
||||
r"ß": "ss",
|
||||
}
|
||||
|
||||
# Our compiled mapping of bad characters
|
||||
APRS_COMPILED_MAP = re.compile(
|
||||
r'(' + '|'.join(APRS_BAD_CHARMAP.keys()) + r')')
|
||||
|
||||
|
||||
class NotifyAprs(NotifyBase):
|
||||
"""
|
||||
A wrapper for APRS Notifications via APRS-IS
|
||||
"""
|
||||
|
||||
# The default descriptive name associated with the Notification
|
||||
service_name = "Aprs"
|
||||
|
||||
# The services URL
|
||||
service_url = "https://www.aprs2.net/"
|
||||
|
||||
# The default secure protocol
|
||||
secure_protocol = "aprs"
|
||||
|
||||
# A URL that takes you to the setup/help of the specific protocol
|
||||
setup_url = "https://github.com/caronc/apprise/wiki/Notify_aprs"
|
||||
|
||||
# APRS default port, supported by all core servers
|
||||
# Details: https://www.aprs-is.net/Connecting.aspx
|
||||
notify_port = 10152
|
||||
|
||||
# The maximum length of the APRS message body
|
||||
body_maxlen = 67
|
||||
|
||||
# Apprise APRS Device ID / TOCALL ID
|
||||
# This is a FIXED value which is associated with this plugin.
|
||||
# Its value MUST NOT be changed. If you use this APRS plugin
|
||||
# code OUTSIDE of Apprise, please request your own TOCALL ID.
|
||||
# Details: see https://github.com/aprsorg/aprs-deviceid
|
||||
#
|
||||
# Do NOT use the generic "APRS" TOCALL ID !!!!!
|
||||
#
|
||||
device_id = "APPRIS"
|
||||
|
||||
# A title can not be used for APRS Messages. Setting this to zero will
|
||||
# cause any title (if defined) to get placed into the message body.
|
||||
title_maxlen = 0
|
||||
|
||||
# Helps to reduce the number of login-related errors where the
|
||||
# APRS-IS server "isn't ready yet". If we try to receive the rx buffer
|
||||
# without this grace perid in place, we may receive "incomplete" responses
|
||||
# where the login response lacks information. In case you receive too many
|
||||
# "Rx: APRS-IS msg is too short - needs to have at least two lines" error
|
||||
# messages, you might want to increase this value to a larger time span
|
||||
# Per previous experience, do not use values lower than 0.5 (seconds)
|
||||
request_rate_per_sec = 0.8
|
||||
|
||||
# Encoding of retrieved content
|
||||
aprs_encoding = 'latin-1'
|
||||
|
||||
# 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",
|
||||
},
|
||||
"delay": {
|
||||
"name": _("Resend Delay"),
|
||||
"type": "float",
|
||||
"min": 0.0,
|
||||
"max": 5.0,
|
||||
"default": 0.0,
|
||||
},
|
||||
"locale": {
|
||||
"name": _("Locale"),
|
||||
"type": "choice:string",
|
||||
"values": APRS_LOCALES,
|
||||
"default": "EURO",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(self, targets=None, locale=None, delay=None, **kwargs):
|
||||
"""
|
||||
Initialize APRS Object
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
|
||||
# Our (future) socket sobject
|
||||
self.sock = None
|
||||
|
||||
# Parse our targets
|
||||
self.targets = list()
|
||||
|
||||
"""
|
||||
Check if the user has provided credentials
|
||||
"""
|
||||
if not (self.user and self.password):
|
||||
msg = "An APRS user/pass was not provided."
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
"""
|
||||
Check if the user tries to use a read-only access
|
||||
to APRS-IS. We need to send content, meaning that
|
||||
read-only access will not work
|
||||
"""
|
||||
if self.password == "-1":
|
||||
msg = "APRS read-only passwords are not supported."
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
"""
|
||||
Check if the password is numeric
|
||||
"""
|
||||
if not self.password.isnumeric():
|
||||
msg = "Invalid APRS-IS password"
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
"""
|
||||
Convert given user name (FROM callsign) and
|
||||
device ID to to uppercase
|
||||
"""
|
||||
self.user = self.user.upper()
|
||||
self.device_id = self.device_id.upper()
|
||||
|
||||
"""
|
||||
Check if the user has provided a locale for the
|
||||
APRS-IS-server and validate it, if necessary
|
||||
"""
|
||||
if locale:
|
||||
if locale.upper() not in APRS_LOCALES:
|
||||
msg = (
|
||||
"Unsupported APRS-IS server locale. "
|
||||
"Received: {}. Valid: {}".format(
|
||||
locale, ", ".join(str(x) for x in APRS_LOCALES.keys())
|
||||
)
|
||||
)
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
# Update our delay
|
||||
if delay is None:
|
||||
self.delay = NotifyAprs.template_args["delay"]["default"]
|
||||
|
||||
else:
|
||||
try:
|
||||
self.delay = float(delay)
|
||||
if self.delay < NotifyAprs.template_args["delay"]["min"]:
|
||||
raise ValueError()
|
||||
|
||||
elif self.delay >= NotifyAprs.template_args["delay"]["max"]:
|
||||
raise ValueError()
|
||||
|
||||
except (TypeError, ValueError):
|
||||
msg = "Unsupported APRS-IS delay ({}) specified. ".format(
|
||||
delay)
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
# Bump up our request_rate
|
||||
self.request_rate_per_sec += self.delay
|
||||
|
||||
# Set the transmitter group
|
||||
self.locale = \
|
||||
NotifyAprs.template_args["locale"]["default"] \
|
||||
if not locale else locale.upper()
|
||||
|
||||
# Used for URL generation afterwards only
|
||||
self.invalid_targets = list()
|
||||
|
||||
for target in parse_call_sign(targets):
|
||||
# Validate targets and drop bad ones
|
||||
# We just need to know if the call sign (including SSID, if
|
||||
# provided) is valid and can then process the input as is
|
||||
result = is_call_sign(target)
|
||||
if not result:
|
||||
self.logger.warning(
|
||||
"Dropping invalid Amateur radio call sign ({}).".format(
|
||||
target
|
||||
),
|
||||
)
|
||||
self.invalid_targets.append(target.upper())
|
||||
continue
|
||||
|
||||
# Store entry
|
||||
self.targets.append(target.upper())
|
||||
|
||||
return
|
||||
|
||||
def socket_close(self):
|
||||
"""
|
||||
Closes the socket connection whereas present
|
||||
"""
|
||||
if self.sock:
|
||||
try:
|
||||
self.sock.close()
|
||||
|
||||
except Exception:
|
||||
# No worries if socket exception thrown on close()
|
||||
pass
|
||||
|
||||
self.sock = None
|
||||
|
||||
def socket_open(self):
|
||||
"""
|
||||
Establishes the connection to the APRS-IS
|
||||
socket server
|
||||
"""
|
||||
self.logger.debug(
|
||||
"Creating socket connection with APRS-IS {}:{}".format(
|
||||
APRS_LOCALES[self.locale], self.notify_port
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
self.sock = socket.create_connection(
|
||||
(APRS_LOCALES[self.locale], self.notify_port),
|
||||
self.socket_connect_timeout,
|
||||
)
|
||||
|
||||
except ConnectionError as e:
|
||||
self.logger.debug("Socket Exception socket_open: %s", str(e))
|
||||
self.sock = None
|
||||
return False
|
||||
|
||||
except socket.gaierror as e:
|
||||
self.logger.debug("Socket Exception socket_open: %s", str(e))
|
||||
self.sock = None
|
||||
return False
|
||||
|
||||
except socket.timeout as e:
|
||||
self.logger.debug(
|
||||
"Socket Timeout Exception socket_open: %s", str(e))
|
||||
self.sock = None
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.debug("General Exception socket_open: %s", str(e))
|
||||
self.sock = None
|
||||
return False
|
||||
|
||||
# We are connected.
|
||||
# getpeername() is not supported by every OS. Therefore,
|
||||
# we MAY receive an exception even though we are
|
||||
# connected successfully.
|
||||
try:
|
||||
# Get the physical host/port of the server
|
||||
host, port = self.sock.getpeername()
|
||||
# and create debug info
|
||||
self.logger.debug("Connected to {}:{}".format(host, port))
|
||||
|
||||
except ValueError:
|
||||
# Seens as if we are running on an operating
|
||||
# system that does not support getpeername()
|
||||
# Create a minimal log file entry
|
||||
self.logger.debug("Connected to APRS-IS")
|
||||
|
||||
# Return success
|
||||
return True
|
||||
|
||||
def aprsis_login(self):
|
||||
"""
|
||||
Generate the APRS-IS login string, send it to the server
|
||||
and parse the response
|
||||
|
||||
Returns True/False wrt whether the login was successful
|
||||
"""
|
||||
self.logger.debug("socket_login: init")
|
||||
|
||||
# Check if we are connected
|
||||
if not self.sock:
|
||||
self.logger.warning("socket_login: Not connected to APRS-IS")
|
||||
return False
|
||||
|
||||
# APRS-IS login string, see https://www.aprs-is.net/Connecting.aspx
|
||||
login_str = "user {0} pass {1} vers apprise {2}\r\n".format(
|
||||
self.user, self.password, __version__
|
||||
)
|
||||
|
||||
# Send the data & abort in case of error
|
||||
if not self.socket_send(login_str):
|
||||
self.logger.warning(
|
||||
"socket_login: Login to APRS-IS unsuccessful,"
|
||||
" exception occurred"
|
||||
)
|
||||
self.socket_close()
|
||||
return False
|
||||
|
||||
rx_buf = self.socket_receive(len(login_str) + 100)
|
||||
# Abort the remaining process in case an error has occurred
|
||||
if not rx_buf:
|
||||
self.logger.warning(
|
||||
"socket_login: Login to APRS-IS "
|
||||
"unsuccessful, exception occurred"
|
||||
)
|
||||
self.socket_close()
|
||||
return False
|
||||
|
||||
# APRS-IS sends at least two lines of data
|
||||
# The data that we need is in line #2 so
|
||||
# let's split the content and see what we have
|
||||
rx_lines = rx_buf.splitlines()
|
||||
if len(rx_lines) < 2:
|
||||
self.logger.warning(
|
||||
"socket_login: APRS-IS msg is too short"
|
||||
" - needs to have at least two lines"
|
||||
)
|
||||
self.socket_close()
|
||||
return False
|
||||
|
||||
# Now split the 2nd line's content and extract
|
||||
# both call sign and login status
|
||||
try:
|
||||
_, _, callsign, status, _ = rx_lines[1].split(" ", 4)
|
||||
|
||||
except ValueError:
|
||||
# ValueError is returned if there were not enough elements to
|
||||
# populate the response
|
||||
self.logger.warning(
|
||||
"socket_login: " "received invalid response from APRS-IS"
|
||||
)
|
||||
self.socket_close()
|
||||
return False
|
||||
|
||||
if callsign != self.user:
|
||||
self.logger.warning(
|
||||
"socket_login: " "call signs differ: %s" % callsign
|
||||
)
|
||||
self.socket_close()
|
||||
return False
|
||||
|
||||
if status.startswith("unverified"):
|
||||
self.logger.warning(
|
||||
"socket_login: "
|
||||
"invalid APRS-IS password for given call sign"
|
||||
)
|
||||
self.socket_close()
|
||||
return False
|
||||
|
||||
# all validations are successful; we are connected
|
||||
return True
|
||||
|
||||
def socket_send(self, tx_data):
|
||||
"""
|
||||
Generic "Send data to a socket"
|
||||
"""
|
||||
self.logger.debug("socket_send: init")
|
||||
|
||||
# Check if we are connected
|
||||
if not self.sock:
|
||||
self.logger.warning("socket_send: Not connected to APRS-IS")
|
||||
return False
|
||||
|
||||
# Encode our data if we are on Python3 or later
|
||||
payload = (
|
||||
tx_data.encode("utf-8") if sys.version_info[0] >= 3 else tx_data
|
||||
)
|
||||
|
||||
# Always call throttle before any remote server i/o is made
|
||||
self.throttle()
|
||||
|
||||
# Try to open the socket
|
||||
# Send the content to APRS-IS
|
||||
try:
|
||||
self.sock.setblocking(True)
|
||||
self.sock.settimeout(self.socket_connect_timeout)
|
||||
self.sock.sendall(payload)
|
||||
|
||||
except socket.gaierror as e:
|
||||
self.logger.warning("Socket Exception socket_send: %s" % str(e))
|
||||
self.sock = None
|
||||
return False
|
||||
|
||||
except socket.timeout as e:
|
||||
self.logger.warning(
|
||||
"Socket Timeout Exception " "socket_send: %s" % str(e)
|
||||
)
|
||||
self.sock = None
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(
|
||||
"General Exception " "socket_send: %s" % str(e)
|
||||
)
|
||||
self.sock = None
|
||||
return False
|
||||
|
||||
self.logger.debug("socket_send: successful")
|
||||
|
||||
# mandatory on several APRS-IS servers
|
||||
# helps to reduce the number of errors where
|
||||
# the server only returns an abbreviated message
|
||||
return True
|
||||
|
||||
def socket_reset(self):
|
||||
"""
|
||||
Resets the socket's buffer
|
||||
"""
|
||||
self.logger.debug("socket_reset: init")
|
||||
_ = self.socket_receive(0)
|
||||
self.logger.debug("socket_reset: successful")
|
||||
return True
|
||||
|
||||
def socket_receive(self, rx_len):
|
||||
"""
|
||||
Generic "Receive data from a socket"
|
||||
"""
|
||||
self.logger.debug("socket_receive: init")
|
||||
|
||||
# Check if we are connected
|
||||
if not self.sock:
|
||||
self.logger.warning("socket_receive: not connected to APRS-IS")
|
||||
return False
|
||||
|
||||
# len is zero in case we intend to
|
||||
# reset the socket
|
||||
if rx_len > 0:
|
||||
self.logger.debug("socket_receive: Receiving data from APRS-IS")
|
||||
|
||||
# Receive content from the socket
|
||||
try:
|
||||
self.sock.setblocking(False)
|
||||
self.sock.settimeout(self.socket_connect_timeout)
|
||||
rx_buf = self.sock.recv(rx_len)
|
||||
|
||||
except socket.gaierror as e:
|
||||
self.logger.warning(
|
||||
"Socket Exception socket_receive: %s" % str(e)
|
||||
)
|
||||
self.sock = None
|
||||
return False
|
||||
|
||||
except socket.timeout as e:
|
||||
self.logger.warning(
|
||||
"Socket Timeout Exception " "socket_receive: %s" % str(e)
|
||||
)
|
||||
self.sock = None
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(
|
||||
"General Exception " "socket_receive: %s" % str(e)
|
||||
)
|
||||
self.sock = None
|
||||
return False
|
||||
|
||||
rx_buf = (
|
||||
rx_buf.decode(self.aprs_encoding)
|
||||
if sys.version_info[0] >= 3 else rx_buf
|
||||
)
|
||||
|
||||
# There will be no data in case we reset the socket
|
||||
if rx_len > 0:
|
||||
self.logger.debug("Received content: {}".format(rx_buf))
|
||||
|
||||
self.logger.debug("socket_receive: successful")
|
||||
|
||||
return rx_buf.rstrip()
|
||||
|
||||
def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs):
|
||||
"""
|
||||
Perform APRS Notification
|
||||
"""
|
||||
|
||||
if not self.targets:
|
||||
# There is no one to notify; we're done
|
||||
self.logger.warning(
|
||||
"There are no amateur radio call signs to notify"
|
||||
)
|
||||
return False
|
||||
|
||||
# prepare payload
|
||||
payload = body
|
||||
|
||||
# sock object is "None" if we were unable to establish a connection
|
||||
# In case of errors, the error message has already been sent
|
||||
# to the logger object
|
||||
if not self.socket_open():
|
||||
return False
|
||||
|
||||
# We have established a successful connection
|
||||
# to the socket server. Now send the login information
|
||||
if not self.aprsis_login():
|
||||
return False
|
||||
|
||||
# Login & authorization confirmed
|
||||
# reset what is in our buffer
|
||||
self.socket_reset()
|
||||
|
||||
# error tracking (used for function return)
|
||||
has_error = False
|
||||
|
||||
# Create a copy of the targets list
|
||||
targets = list(self.targets)
|
||||
|
||||
self.logger.debug("Starting Payload setup")
|
||||
|
||||
# Prepare the outgoing message
|
||||
# Due to APRS's contraints, we need to do
|
||||
# a lot of filtering before we can send
|
||||
# the actual message
|
||||
#
|
||||
# First remove all characters from the
|
||||
# payload that would break APRS
|
||||
# see https://www.aprs.org/doc/APRS101.PDF pg. 71
|
||||
payload = re.sub("[{}|~]+", "", payload)
|
||||
|
||||
payload = ( # pragma: no branch
|
||||
APRS_COMPILED_MAP.sub(
|
||||
lambda x: APRS_BAD_CHARMAP[x.group()], payload)
|
||||
)
|
||||
|
||||
# Finally, constrain output string to 67 characters as
|
||||
# APRS messages are limited in length
|
||||
payload = payload[:67]
|
||||
|
||||
# Our outgoing message MUST end with a CRLF so
|
||||
# let's amend our payload respectively
|
||||
payload = payload.rstrip("\r\n") + "\r\n"
|
||||
|
||||
self.logger.debug("Payload setup complete: {}".format(payload))
|
||||
|
||||
# send the message to our target call sign(s)
|
||||
for index in range(0, len(targets)):
|
||||
# prepare the output string
|
||||
# Format:
|
||||
# Device ID/TOCALL - our call sign - target call sign - body
|
||||
buffer = "{}>{}::{:9}:{}".format(
|
||||
self.user, self.device_id, targets[index], payload
|
||||
)
|
||||
|
||||
# and send the content to the socket
|
||||
# Note that there will be no response from APRS and
|
||||
# that all exceptions are handled within the 'send' method
|
||||
self.logger.debug("Sending APRS message: {}".format(buffer))
|
||||
|
||||
# send the content
|
||||
if not self.socket_send(buffer):
|
||||
has_error = True
|
||||
break
|
||||
|
||||
# Finally, reset our socket buffer
|
||||
# we DO NOT read from the socket as we
|
||||
# would simply listen to the default APRS-IS stream
|
||||
self.socket_reset()
|
||||
|
||||
self.logger.debug("Closing socket.")
|
||||
self.socket_close()
|
||||
self.logger.info(
|
||||
"Sent %d/%d APRS-IS notification(s)", index + 1, len(targets))
|
||||
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.locale != NotifyAprs.template_args["locale"]["default"]:
|
||||
# Store our locale if not default
|
||||
params['locale'] = self.locale
|
||||
|
||||
if self.delay != NotifyAprs.template_args["delay"]["default"]:
|
||||
# Store our locale if not default
|
||||
params['delay'] = "{:.2f}".format(self.delay)
|
||||
|
||||
# Extend our parameters
|
||||
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
|
||||
|
||||
# Setup Authentication
|
||||
auth = "{user}:{password}@".format(
|
||||
user=NotifyAprs.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(chain(
|
||||
[self.pprint(x, privacy, safe="") for x in self.targets],
|
||||
[self.pprint(x, privacy, safe="")
|
||||
for x in self.invalid_targets],
|
||||
)),
|
||||
params=NotifyAprs.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
|
||||
|
||||
def __del__(self):
|
||||
"""
|
||||
Ensure we close any lingering connections
|
||||
"""
|
||||
self.socket_close()
|
||||
|
||||
@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"] = [NotifyAprs.unquote(results["host"])]
|
||||
|
||||
# All entries after the hostname are additional targets
|
||||
results["targets"].extend(NotifyAprs.split_path(results["fullpath"]))
|
||||
|
||||
# Get Delay (if set)
|
||||
if 'delay' in results['qsd'] and len(results['qsd']['delay']):
|
||||
results['delay'] = NotifyAprs.unquote(results['qsd']['delay'])
|
||||
|
||||
# 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"] += NotifyAprs.parse_list(results["qsd"]["to"])
|
||||
|
||||
# Set our APRS-IS server locale's key value and convert it to uppercase
|
||||
if "locale" in results["qsd"] and len(results["qsd"]["locale"]):
|
||||
results["locale"] = NotifyAprs.unquote(
|
||||
results["qsd"]["locale"]
|
||||
).upper()
|
||||
|
||||
return results
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -32,13 +32,13 @@
|
|||
import requests
|
||||
import json
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..URLBase import PrivacyMode
|
||||
from .base import NotifyBase
|
||||
from ..url 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 _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
|
||||
# Sounds generated off of: https://github.com/Finb/Bark/tree/master/Sounds
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -30,15 +30,16 @@ import asyncio
|
|||
import re
|
||||
from functools import partial
|
||||
|
||||
from ..URLBase import URLBase
|
||||
from ..url import URLBase
|
||||
from ..common import NotifyType
|
||||
from ..utils import parse_bool
|
||||
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
|
||||
from ..locale import gettext_lazy as _
|
||||
from ..apprise_attachment import AppriseAttachment
|
||||
|
||||
|
||||
class NotifyBase(URLBase):
|
||||
|
@ -135,6 +136,9 @@ class NotifyBase(URLBase):
|
|||
# Default Overflow Mode
|
||||
overflow_mode = OverflowMode.UPSTREAM
|
||||
|
||||
# Default Emoji Interpretation
|
||||
interpret_emojis = False
|
||||
|
||||
# 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
|
||||
|
@ -183,8 +187,66 @@ class NotifyBase(URLBase):
|
|||
# runtime.
|
||||
'_lookup_default': 'notify_format',
|
||||
},
|
||||
'emojis': {
|
||||
'name': _('Interpret Emojis'),
|
||||
# SSL Certificate Authority Verification
|
||||
'type': 'bool',
|
||||
# Provide a default
|
||||
'default': interpret_emojis,
|
||||
# look up default using the following parent class value at
|
||||
# runtime.
|
||||
'_lookup_default': 'interpret_emojis',
|
||||
},
|
||||
})
|
||||
|
||||
#
|
||||
# Overflow Defaults / Configuration applicable to SPLIT mode only
|
||||
#
|
||||
|
||||
# Display Count [X/X]
|
||||
# ^^^^^^
|
||||
# \\\\\\
|
||||
# 6 characters (space + count)
|
||||
# Display Count [XX/XX]
|
||||
# ^^^^^^^^
|
||||
# \\\\\\\\
|
||||
# 8 characters (space + count)
|
||||
# Display Count [XXX/XXX]
|
||||
# ^^^^^^^^^^
|
||||
# \\\\\\\\\\
|
||||
# 10 characters (space + count)
|
||||
# Display Count [XXXX/XXXX]
|
||||
# ^^^^^^^^^^^^
|
||||
# \\\\\\\\\\\\
|
||||
# 12 characters (space + count)
|
||||
#
|
||||
# Given the above + some buffer we come up with the following:
|
||||
# If this value is exceeded, display counts automatically shut off
|
||||
overflow_max_display_count_width = 12
|
||||
|
||||
# The number of characters to reserver for whitespace buffering
|
||||
# This is detected automatically, but you can enforce a value if
|
||||
# you desire:
|
||||
overflow_buffer = 0
|
||||
|
||||
# the min accepted length of a title to allow for a counter display
|
||||
overflow_display_count_threshold = 130
|
||||
|
||||
# Whether or not when over-flow occurs, if the title should be repeated
|
||||
# each time the message is split up
|
||||
# - None: Detect
|
||||
# - True: Always display title once
|
||||
# - False: Display the title for each occurance
|
||||
overflow_display_title_once = None
|
||||
|
||||
# If this is set to to True:
|
||||
# The title_maxlen should be considered as a subset of the body_maxlen
|
||||
# Hence: len(title) + len(body) should never be greater then body_maxlen
|
||||
#
|
||||
# If set to False, then there is no corrorlation between title_maxlen
|
||||
# restrictions and that of body_maxlen
|
||||
overflow_amalgamate_title = False
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""
|
||||
Initialize some general configuration that will keep things consistent
|
||||
|
@ -194,6 +256,29 @@ class NotifyBase(URLBase):
|
|||
|
||||
super().__init__(**kwargs)
|
||||
|
||||
# Store our interpret_emoji's setting
|
||||
# If asset emoji value is set to a default of True and the user
|
||||
# specifies it to be false, this is accepted and False over-rides.
|
||||
#
|
||||
# If asset emoji value is set to a default of None, a user may
|
||||
# optionally over-ride this and set it to True from the Apprise
|
||||
# URL. ?emojis=yes
|
||||
#
|
||||
# If asset emoji value is set to a default of False, then all emoji's
|
||||
# are turned off (no user over-rides allowed)
|
||||
#
|
||||
|
||||
# Take a default
|
||||
self.interpret_emojis = self.asset.interpret_emojis
|
||||
if 'emojis' in kwargs:
|
||||
# possibly over-ride default
|
||||
self.interpret_emojis = True if self.interpret_emojis \
|
||||
in (None, True) and \
|
||||
parse_bool(
|
||||
kwargs.get('emojis', False),
|
||||
default=NotifyBase.template_args['emojis']['default']) \
|
||||
else False
|
||||
|
||||
if 'format' in kwargs:
|
||||
# Store the specified format if specified
|
||||
notify_format = kwargs.get('format', '')
|
||||
|
@ -279,6 +364,17 @@ class NotifyBase(URLBase):
|
|||
color_type=color_type,
|
||||
)
|
||||
|
||||
def ascii(self, notify_type):
|
||||
"""
|
||||
Returns the ascii characters associated with the notify_type
|
||||
"""
|
||||
if notify_type not in NOTIFY_TYPES:
|
||||
return None
|
||||
|
||||
return self.asset.ascii(
|
||||
notify_type=notify_type,
|
||||
)
|
||||
|
||||
def notify(self, *args, **kwargs):
|
||||
"""
|
||||
Performs notification
|
||||
|
@ -372,6 +468,19 @@ class NotifyBase(URLBase):
|
|||
# Handle situations where the title is None
|
||||
title = '' if not title else title
|
||||
|
||||
# Truncate flag set with attachments ensures that only 1
|
||||
# attachment passes through. In the event there could be many
|
||||
# services specified, we only want to do this logic once.
|
||||
# The logic is only applicable if ther was more then 1 attachment
|
||||
# specified
|
||||
overflow = self.overflow_mode if overflow is None else overflow
|
||||
if attach and len(attach) > 1 and overflow == OverflowMode.TRUNCATE:
|
||||
# Save first attachment
|
||||
_attach = AppriseAttachment(attach[0], asset=self.asset)
|
||||
else:
|
||||
# reference same attachment
|
||||
_attach = attach
|
||||
|
||||
# Apply our overflow (if defined)
|
||||
for chunk in self._apply_overflow(
|
||||
body=body, title=title, overflow=overflow,
|
||||
|
@ -380,7 +489,7 @@ class NotifyBase(URLBase):
|
|||
# Send notification
|
||||
yield dict(
|
||||
body=chunk['body'], title=chunk['title'],
|
||||
notify_type=notify_type, attach=attach,
|
||||
notify_type=notify_type, attach=_attach,
|
||||
body_format=body_format
|
||||
)
|
||||
|
||||
|
@ -400,7 +509,7 @@ class NotifyBase(URLBase):
|
|||
},
|
||||
{
|
||||
title: 'the title goes here',
|
||||
body: 'the message body goes here',
|
||||
body: 'the continued message body goes here',
|
||||
},
|
||||
|
||||
]
|
||||
|
@ -417,7 +526,6 @@ class NotifyBase(URLBase):
|
|||
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}</{close_tag}>' \
|
||||
|
@ -453,29 +561,148 @@ class NotifyBase(URLBase):
|
|||
response.append({'body': body, 'title': title})
|
||||
return response
|
||||
|
||||
elif len(title) > self.title_maxlen:
|
||||
# Truncate our Title
|
||||
title = title[:self.title_maxlen]
|
||||
# a value of '2' allows for the \r\n that is applied when
|
||||
# amalgamating the title
|
||||
overflow_buffer = max(2, self.overflow_buffer) \
|
||||
if (self.title_maxlen == 0 and len(title)) \
|
||||
else self.overflow_buffer
|
||||
|
||||
if self.body_maxlen > 0 and len(body) <= self.body_maxlen:
|
||||
#
|
||||
# If we reach here in our code, then we're using TRUNCATE, or SPLIT
|
||||
# actions which require some math to handle the data
|
||||
#
|
||||
|
||||
# Handle situations where our body and title are amalamated into one
|
||||
# calculation
|
||||
title_maxlen = self.title_maxlen \
|
||||
if not self.overflow_amalgamate_title \
|
||||
else min(len(title) + self.overflow_max_display_count_width,
|
||||
self.title_maxlen, self.body_maxlen)
|
||||
|
||||
if len(title) > title_maxlen:
|
||||
# Truncate our Title
|
||||
title = title[:title_maxlen].rstrip()
|
||||
|
||||
if self.overflow_amalgamate_title and (
|
||||
self.body_maxlen - overflow_buffer) >= title_maxlen:
|
||||
body_maxlen = (self.body_maxlen if not title else (
|
||||
self.body_maxlen - title_maxlen)) - overflow_buffer
|
||||
else:
|
||||
# status quo
|
||||
body_maxlen = self.body_maxlen \
|
||||
if not self.overflow_amalgamate_title else \
|
||||
(self.body_maxlen - overflow_buffer)
|
||||
|
||||
if body_maxlen > 0 and len(body) <= 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],
|
||||
'body': body[:body_maxlen].lstrip('\r\n\x0b\x0c').rstrip(),
|
||||
'title': title,
|
||||
})
|
||||
# For truncate mode, we're done now
|
||||
return response
|
||||
|
||||
if self.overflow_display_title_once is None:
|
||||
# Detect if we only display our title once or not:
|
||||
overflow_display_title_once = \
|
||||
True if self.overflow_amalgamate_title and \
|
||||
body_maxlen < self.overflow_display_count_threshold \
|
||||
else False
|
||||
else:
|
||||
# Take on defined value
|
||||
|
||||
overflow_display_title_once = self.overflow_display_title_once
|
||||
|
||||
# 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)]
|
||||
if not overflow_display_title_once and not (
|
||||
# edge case that can occur when overflow_display_title_once is
|
||||
# forced off, but no body exists
|
||||
self.overflow_amalgamate_title and body_maxlen <= 0):
|
||||
|
||||
show_counter = title and len(body) > body_maxlen and \
|
||||
((self.overflow_amalgamate_title and
|
||||
body_maxlen >= self.overflow_display_count_threshold) or
|
||||
(not self.overflow_amalgamate_title and
|
||||
title_maxlen > self.overflow_display_count_threshold)) and (
|
||||
title_maxlen > (self.overflow_max_display_count_width +
|
||||
overflow_buffer) and
|
||||
self.title_maxlen >= self.overflow_display_count_threshold)
|
||||
|
||||
count = 0
|
||||
template = ''
|
||||
if show_counter:
|
||||
# introduce padding
|
||||
body_maxlen -= overflow_buffer
|
||||
|
||||
count = int(len(body) / body_maxlen) \
|
||||
+ (1 if len(body) % body_maxlen else 0)
|
||||
|
||||
# Detect padding and prepare template
|
||||
digits = len(str(count))
|
||||
template = ' [{:0%d}/{:0%d}]' % (digits, digits)
|
||||
|
||||
# Update our counter
|
||||
overflow_display_count_width = 4 + (digits * 2)
|
||||
if overflow_display_count_width <= \
|
||||
self.overflow_max_display_count_width:
|
||||
if len(title) > \
|
||||
title_maxlen - overflow_display_count_width:
|
||||
# Truncate our title further
|
||||
title = title[:title_maxlen -
|
||||
overflow_display_count_width]
|
||||
|
||||
else: # Way to many messages to display
|
||||
show_counter = False
|
||||
|
||||
response = [{
|
||||
'body': body[i: i + body_maxlen]
|
||||
.lstrip('\r\n\x0b\x0c').rstrip(),
|
||||
'title': title + (
|
||||
'' if not show_counter else
|
||||
template.format(idx, count))} for idx, i in
|
||||
enumerate(range(0, len(body), body_maxlen), start=1)]
|
||||
|
||||
else: # Display title once and move on
|
||||
response = []
|
||||
try:
|
||||
i = range(0, len(body), body_maxlen)[0]
|
||||
|
||||
response.append({
|
||||
'body': body[i: i + body_maxlen]
|
||||
.lstrip('\r\n\x0b\x0c').rstrip(),
|
||||
'title': title,
|
||||
})
|
||||
|
||||
except (ValueError, IndexError):
|
||||
# IndexError:
|
||||
# - This happens if there simply was no body to display
|
||||
|
||||
# ValueError:
|
||||
# - This happens when body_maxlen < 0 (due to title being
|
||||
# so large)
|
||||
|
||||
# No worries; send title along
|
||||
response.append({
|
||||
'body': '',
|
||||
'title': title,
|
||||
})
|
||||
|
||||
# Ensure our start is set properly
|
||||
body_maxlen = 0
|
||||
|
||||
# Now re-calculate based on the increased length
|
||||
for i in range(body_maxlen, len(body), self.body_maxlen):
|
||||
response.append({
|
||||
'body': body[i: i + self.body_maxlen]
|
||||
.lstrip('\r\n\x0b\x0c').rstrip(),
|
||||
'title': '',
|
||||
})
|
||||
|
||||
return response
|
||||
|
||||
|
@ -548,6 +775,10 @@ class NotifyBase(URLBase):
|
|||
results['overflow']))
|
||||
del results['overflow']
|
||||
|
||||
# Allow emoji's override
|
||||
if 'emojis' in results['qsd']:
|
||||
results['emojis'] = parse_bool(results['qsd'].get('emojis'))
|
||||
|
||||
return results
|
||||
|
||||
@staticmethod
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -33,20 +33,16 @@ from json import dumps
|
|||
from time import time
|
||||
from hashlib import sha1
|
||||
from itertools import chain
|
||||
try:
|
||||
from urlparse import urlparse
|
||||
from urllib.parse import urlparse
|
||||
|
||||
except ImportError:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..URLBase import PrivacyMode
|
||||
from .base import NotifyBase
|
||||
from ..url 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 _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
# Default to sending to all devices if nothing is specified
|
||||
DEFAULT_TAG = '@all'
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -36,13 +36,13 @@ import re
|
|||
import requests
|
||||
import json
|
||||
from itertools import chain
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..URLBase import PrivacyMode
|
||||
from .base import NotifyBase
|
||||
from ..url 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 _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
|
||||
IS_GROUP_RE = re.compile(
|
||||
|
@ -248,7 +248,7 @@ class NotifyBulkSMS(NotifyBase):
|
|||
|
||||
if not (self.targets or self.groups):
|
||||
# We have nothing to notify
|
||||
self.logger.warning('There are no Twist targets to notify')
|
||||
self.logger.warning('There are no BulkSMS targets to notify')
|
||||
return False
|
||||
|
||||
# Send in batches if identified to do so
|
394
lib/apprise/plugins/bulkvs.py
Normal file
394
lib/apprise/plugins/bulkvs.py
Normal file
|
@ -0,0 +1,394 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
#
|
||||
# 1. Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
#
|
||||
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
# To use this service you will need a BulkVS account
|
||||
# You will need credits (new accounts start with a few)
|
||||
# https://www.bulkvs.com/
|
||||
|
||||
# API is documented here:
|
||||
# - https://portal.bulkvs.com/api/v1.0/documentation#/\
|
||||
# Messaging/post_messageSend
|
||||
import requests
|
||||
import json
|
||||
from .base import NotifyBase
|
||||
from ..url import PrivacyMode
|
||||
from ..common import NotifyType
|
||||
from ..utils import is_phone_no
|
||||
from ..utils import parse_phone_no
|
||||
from ..utils import parse_bool
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
|
||||
class NotifyBulkVS(NotifyBase):
|
||||
"""
|
||||
A wrapper for BulkVS Notifications
|
||||
"""
|
||||
|
||||
# The default descriptive name associated with the Notification
|
||||
service_name = 'BulkVS'
|
||||
|
||||
# The services URL
|
||||
service_url = 'https://www.bulkvs.com/'
|
||||
|
||||
# All notification requests are secure
|
||||
secure_protocol = 'bulkvs'
|
||||
|
||||
# A URL that takes you to the setup/help of the specific protocol
|
||||
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_bulkvs'
|
||||
|
||||
# BulkVS uses the http protocol with JSON requests
|
||||
notify_url = 'https://portal.bulkvs.com/api/v1.0/messageSend'
|
||||
|
||||
# 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}@{from_phone}/{targets}',
|
||||
'{schema}://{user}:{password}@{from_phone}',
|
||||
)
|
||||
|
||||
# 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,
|
||||
},
|
||||
'from_phone': {
|
||||
'name': _('From Phone No'),
|
||||
'type': 'string',
|
||||
'regex': (r'^\+?[0-9\s)(+-]+$', 'i'),
|
||||
'map_to': 'source',
|
||||
'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',
|
||||
},
|
||||
'from': {
|
||||
'name': _('From Phone No'),
|
||||
'type': 'string',
|
||||
'regex': (r'^\+?[0-9\s)(+-]+$', 'i'),
|
||||
'map_to': 'source',
|
||||
},
|
||||
'batch': {
|
||||
'name': _('Batch Mode'),
|
||||
'type': 'bool',
|
||||
'default': False,
|
||||
},
|
||||
})
|
||||
|
||||
def __init__(self, source=None, targets=None, batch=None, **kwargs):
|
||||
"""
|
||||
Initialize BulkVS Object
|
||||
"""
|
||||
super(NotifyBulkVS, self).__init__(**kwargs)
|
||||
|
||||
if not (self.user and self.password):
|
||||
msg = 'A BulkVS user/pass was not provided.'
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
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 = result['full']
|
||||
|
||||
# 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()
|
||||
|
||||
has_error = False
|
||||
for target in parse_phone_no(targets):
|
||||
# Parse each phone number we found
|
||||
result = is_phone_no(target)
|
||||
if result:
|
||||
self.targets.append(result['full'])
|
||||
continue
|
||||
|
||||
has_error = True
|
||||
self.logger.warning(
|
||||
'Dropped invalid phone # ({}) specified.'.format(target),
|
||||
)
|
||||
|
||||
if not targets and not has_error:
|
||||
# Default the SMS Message to ourselves
|
||||
self.targets.append(self.source)
|
||||
|
||||
return
|
||||
|
||||
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
||||
"""
|
||||
Perform BulkVS Notification
|
||||
"""
|
||||
|
||||
if not self.targets:
|
||||
# We have nothing to notify
|
||||
self.logger.warning('There are no BulkVS 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,
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
# Prepare our payload
|
||||
payload = {
|
||||
# The To gets populated in the loop below
|
||||
'From': self.source,
|
||||
'To': None,
|
||||
'Message': body,
|
||||
}
|
||||
|
||||
# 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)]
|
||||
|
||||
while len(targets):
|
||||
# Get our target to notify
|
||||
target = targets.pop(0)
|
||||
|
||||
# Prepare our user
|
||||
payload['To'] = target
|
||||
|
||||
# Printable reference
|
||||
if isinstance(target, list):
|
||||
p_target = '{} targets'.format(len(target))
|
||||
|
||||
else:
|
||||
p_target = target
|
||||
|
||||
# Some Debug Logging
|
||||
self.logger.debug('BulkVS POST URL: {} (cert_verify={})'.format(
|
||||
self.notify_url, self.verify_certificate))
|
||||
self.logger.debug('BulkVS 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,
|
||||
)
|
||||
|
||||
# A Response may look like:
|
||||
# {
|
||||
# "RefId": "5a66dee6-ff7a-40ee-8218-5805c074dc01",
|
||||
# "From": "13109060901",
|
||||
# "MessageType": "SMS|MMS",
|
||||
# "Results": [
|
||||
# {
|
||||
# "To": "13105551212",
|
||||
# "Status": "SUCCESS"
|
||||
# },
|
||||
# {
|
||||
# "To": "13105551213",
|
||||
# "Status": "SUCCESS"
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
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
|
||||
|
||||
self.logger.warning(
|
||||
'Failed to send BulkVS 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 BulkVS notification to {}.'.format(p_target))
|
||||
|
||||
except requests.RequestException as e:
|
||||
self.logger.warning(
|
||||
'A Connection error occurred sending BulkVS: 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 = {
|
||||
'batch': 'yes' if self.batch else 'no',
|
||||
}
|
||||
|
||||
# Extend our parameters
|
||||
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
|
||||
|
||||
# A nice way of cleaning up the URL length a bit
|
||||
targets = [] if len(self.targets) == 1 \
|
||||
and self.targets[0] == self.source else self.targets
|
||||
|
||||
return '{schema}://{user}:{password}@{source}/{targets}' \
|
||||
'?{params}'.format(
|
||||
schema=self.secure_protocol,
|
||||
source=self.source,
|
||||
user=self.pprint(self.user, privacy, safe=''),
|
||||
password=self.pprint(
|
||||
self.password, privacy, mode=PrivacyMode.Secret, safe=''),
|
||||
targets='/'.join([
|
||||
NotifyBulkVS.quote('{}'.format(x), safe='+')
|
||||
for x in targets]),
|
||||
params=NotifyBulkVS.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 self.targets else 1
|
||||
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
|
||||
|
||||
# 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'] = \
|
||||
NotifyBulkVS.unquote(results['qsd']['from'])
|
||||
|
||||
# hostname will also be a target in this case
|
||||
results['targets'] = [
|
||||
*NotifyBulkVS.parse_phone_no(results['host']),
|
||||
*NotifyBulkVS.split_path(results['fullpath'])]
|
||||
|
||||
else:
|
||||
# store our source
|
||||
results['source'] = NotifyBulkVS.unquote(results['host'])
|
||||
|
||||
# store targets
|
||||
results['targets'] = NotifyBulkVS.split_path(results['fullpath'])
|
||||
|
||||
# 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'] += \
|
||||
NotifyBulkVS.parse_phone_no(results['qsd']['to'])
|
||||
|
||||
# Get Batch Mode Flag
|
||||
results['batch'] = \
|
||||
parse_bool(results['qsd'].get(
|
||||
'batch', NotifyBulkVS.template_args['batch']['default']))
|
||||
|
||||
return results
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -33,14 +33,14 @@
|
|||
#
|
||||
import requests
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..URLBase import PrivacyMode
|
||||
from .base import NotifyBase
|
||||
from ..url 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 _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
|
||||
class BurstSMSCountryCode:
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -26,118 +26,111 @@
|
|||
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
# Chantify
|
||||
# 1. Visit https://chanify.net/
|
||||
|
||||
# The API URL will look something like this:
|
||||
# https://api.chanify.net/v1/sender/token
|
||||
#
|
||||
|
||||
import requests
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..common import NotifyImageSize
|
||||
from .base import NotifyBase
|
||||
from ..common import NotifyType
|
||||
from ..utils import parse_bool
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..utils import validate_regex
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
|
||||
class NotifyFaast(NotifyBase):
|
||||
class NotifyChantify(NotifyBase):
|
||||
"""
|
||||
A wrapper for Faast Notifications
|
||||
A wrapper for Chantify Notifications
|
||||
"""
|
||||
|
||||
# The default descriptive name associated with the Notification
|
||||
service_name = 'Faast'
|
||||
service_name = _('Chantify')
|
||||
|
||||
# The services URL
|
||||
service_url = 'http://www.faast.io/'
|
||||
service_url = 'https://chanify.net/'
|
||||
|
||||
# The default protocol (this is secure for faast)
|
||||
protocol = 'faast'
|
||||
# The default secure protocol
|
||||
secure_protocol = 'chantify'
|
||||
|
||||
# A URL that takes you to the setup/help of the specific protocol
|
||||
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_faast'
|
||||
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_chantify'
|
||||
|
||||
# Faast uses the http protocol with JSON requests
|
||||
notify_url = 'https://www.appnotifications.com/account/notifications.json'
|
||||
|
||||
# Allows the user to specify the NotifyImageSize object
|
||||
image_size = NotifyImageSize.XY_72
|
||||
# Notification URL
|
||||
notify_url = 'https://api.chanify.net/v1/sender/{token}/'
|
||||
|
||||
# Define object templates
|
||||
templates = (
|
||||
'{schema}://{authtoken}',
|
||||
'{schema}://{token}',
|
||||
)
|
||||
|
||||
# Define our template tokens
|
||||
# The title is not used
|
||||
title_maxlen = 0
|
||||
|
||||
# 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, **{
|
||||
'authtoken': {
|
||||
'name': _('Authorization Token'),
|
||||
'token': {
|
||||
'name': _('Token'),
|
||||
'type': 'string',
|
||||
'private': True,
|
||||
'required': True,
|
||||
'regex': (r'^[A-Z0-9_-]+$', 'i'),
|
||||
},
|
||||
})
|
||||
|
||||
# Define our template arguments
|
||||
template_args = dict(NotifyBase.template_args, **{
|
||||
'image': {
|
||||
'name': _('Include Image'),
|
||||
'type': 'bool',
|
||||
'default': True,
|
||||
'map_to': 'include_image',
|
||||
'token': {
|
||||
'alias_of': 'token',
|
||||
},
|
||||
})
|
||||
|
||||
def __init__(self, authtoken, include_image=True, **kwargs):
|
||||
def __init__(self, token, **kwargs):
|
||||
"""
|
||||
Initialize Faast Object
|
||||
Initialize Chantify Object
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
|
||||
# Store the Authentication Token
|
||||
self.authtoken = validate_regex(authtoken)
|
||||
if not self.authtoken:
|
||||
msg = 'An invalid Faast Authentication Token ' \
|
||||
'({}) was specified.'.format(authtoken)
|
||||
self.token = validate_regex(
|
||||
token, *self.template_tokens['token']['regex'])
|
||||
if not self.token:
|
||||
msg = 'The Chantify token specified ({}) is invalid.'\
|
||||
.format(token)
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
# Associate an image with our post
|
||||
self.include_image = include_image
|
||||
|
||||
return
|
||||
|
||||
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
||||
"""
|
||||
Perform Faast Notification
|
||||
Send our notification
|
||||
"""
|
||||
|
||||
# prepare our headers
|
||||
headers = {
|
||||
'User-Agent': self.app_id,
|
||||
'Content-Type': 'multipart/form-data'
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}
|
||||
|
||||
# prepare JSON Object
|
||||
# Our Message
|
||||
payload = {
|
||||
'user_credentials': self.authtoken,
|
||||
'title': title,
|
||||
'message': body,
|
||||
'text': body
|
||||
}
|
||||
|
||||
# Acquire our image if we're 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
|
||||
|
||||
self.logger.debug('Faast POST URL: %s (cert_verify=%r)' % (
|
||||
self.notify_url, self.verify_certificate,
|
||||
))
|
||||
self.logger.debug('Faast Payload: %s' % str(payload))
|
||||
self.logger.debug('Chantify GET URL: %s (cert_verify=%r)' % (
|
||||
self.notify_url, self.verify_certificate))
|
||||
self.logger.debug('Chantify Payload: %s' % str(payload))
|
||||
|
||||
# Always call throttle before any remote server i/o is made
|
||||
self.throttle()
|
||||
|
||||
try:
|
||||
r = requests.post(
|
||||
self.notify_url,
|
||||
self.notify_url.format(token=self.token),
|
||||
data=payload,
|
||||
headers=headers,
|
||||
verify=self.verify_certificate,
|
||||
|
@ -146,10 +139,10 @@ class NotifyFaast(NotifyBase):
|
|||
if r.status_code != requests.codes.ok:
|
||||
# We had a problem
|
||||
status_str = \
|
||||
NotifyFaast.http_response_code_lookup(r.status_code)
|
||||
NotifyChantify.http_response_code_lookup(r.status_code)
|
||||
|
||||
self.logger.warning(
|
||||
'Failed to send Faast notification:'
|
||||
'Failed to send Chantify notification: '
|
||||
'{}{}error={}.'.format(
|
||||
status_str,
|
||||
', ' if status_str else '',
|
||||
|
@ -161,12 +154,12 @@ class NotifyFaast(NotifyBase):
|
|||
return False
|
||||
|
||||
else:
|
||||
self.logger.info('Sent Faast notification.')
|
||||
self.logger.info('Sent Chantify notification.')
|
||||
|
||||
except requests.RequestException as e:
|
||||
self.logger.warning(
|
||||
'A Connection error occurred sending Faast notification.',
|
||||
)
|
||||
'A Connection error occurred sending Chantify '
|
||||
'notification.')
|
||||
self.logger.debug('Socket Exception: %s' % str(e))
|
||||
|
||||
# Return; we're done
|
||||
|
@ -179,18 +172,13 @@ class NotifyFaast(NotifyBase):
|
|||
Returns the URL built dynamically based on specified arguments.
|
||||
"""
|
||||
|
||||
# Define any URL parameters
|
||||
params = {
|
||||
'image': 'yes' if self.include_image else 'no',
|
||||
}
|
||||
# Prepare our parameters
|
||||
params = self.url_parameters(privacy=privacy, *args, **kwargs)
|
||||
|
||||
# Extend our parameters
|
||||
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
|
||||
|
||||
return '{schema}://{authtoken}/?{params}'.format(
|
||||
schema=self.protocol,
|
||||
authtoken=self.pprint(self.authtoken, privacy, safe=''),
|
||||
params=NotifyFaast.urlencode(params),
|
||||
return '{schema}://{token}/?{params}'.format(
|
||||
schema=self.secure_protocol,
|
||||
token=self.pprint(self.token, privacy, safe=''),
|
||||
params=NotifyChantify.urlencode(params),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
@ -200,16 +188,19 @@ class NotifyFaast(NotifyBase):
|
|||
us to re-instantiate this object.
|
||||
|
||||
"""
|
||||
|
||||
# parse_url already handles getting the `user` and `password` fields
|
||||
# populated.
|
||||
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 authtoken using the host
|
||||
results['authtoken'] = NotifyFaast.unquote(results['host'])
|
||||
# Allow over-ride
|
||||
if 'token' in results['qsd'] and len(results['qsd']['token']):
|
||||
results['token'] = NotifyChantify.unquote(results['qsd']['token'])
|
||||
|
||||
# Include image with our post
|
||||
results['include_image'] = \
|
||||
parse_bool(results['qsd'].get('image', True))
|
||||
else:
|
||||
results['token'] = NotifyChantify.unquote(results['host'])
|
||||
|
||||
return results
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -41,15 +41,14 @@
|
|||
#
|
||||
import requests
|
||||
from json import dumps
|
||||
from base64 import b64encode
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..URLBase import PrivacyMode
|
||||
from .base import NotifyBase
|
||||
from ..url 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 _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
# Extend HTTP Error Messages
|
||||
CLICKSEND_HTTP_ERROR_MAP = {
|
||||
|
@ -89,7 +88,7 @@ class NotifyClickSend(NotifyBase):
|
|||
|
||||
# Define object templates
|
||||
templates = (
|
||||
'{schema}://{user}:{password}@{targets}',
|
||||
'{schema}://{user}:{apikey}@{targets}',
|
||||
)
|
||||
|
||||
# Define our template tokens
|
||||
|
@ -99,11 +98,12 @@ class NotifyClickSend(NotifyBase):
|
|||
'type': 'string',
|
||||
'required': True,
|
||||
},
|
||||
'password': {
|
||||
'name': _('Password'),
|
||||
'apikey': {
|
||||
'name': _('API Key'),
|
||||
'type': 'string',
|
||||
'private': True,
|
||||
'required': True,
|
||||
'map_to': 'password',
|
||||
},
|
||||
'target_phone': {
|
||||
'name': _('Target Phone No'),
|
||||
|
@ -124,6 +124,9 @@ class NotifyClickSend(NotifyBase):
|
|||
'to': {
|
||||
'alias_of': 'targets',
|
||||
},
|
||||
'key': {
|
||||
'alias_of': 'apikey',
|
||||
},
|
||||
'batch': {
|
||||
'name': _('Batch Mode'),
|
||||
'type': 'bool',
|
||||
|
@ -174,9 +177,6 @@ class NotifyClickSend(NotifyBase):
|
|||
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)
|
||||
|
@ -208,6 +208,7 @@ class NotifyClickSend(NotifyBase):
|
|||
r = requests.post(
|
||||
self.notify_url,
|
||||
data=dumps(payload),
|
||||
auth=(self.user, self.password),
|
||||
headers=headers,
|
||||
verify=self.verify_certificate,
|
||||
timeout=self.request_timeout,
|
||||
|
@ -322,6 +323,12 @@ class NotifyClickSend(NotifyBase):
|
|||
results['batch'] = \
|
||||
parse_bool(results['qsd'].get('batch', False))
|
||||
|
||||
# API Key
|
||||
if 'key' in results['qsd'] and len(results['qsd']['key']):
|
||||
# Extract the API Key from an argument
|
||||
results['password'] = \
|
||||
NotifyClickSend.unquote(results['qsd']['key'])
|
||||
|
||||
# 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']):
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -29,11 +29,11 @@
|
|||
import re
|
||||
import requests
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..URLBase import PrivacyMode
|
||||
from .base import NotifyBase
|
||||
from ..url import PrivacyMode
|
||||
from ..common import NotifyImageSize
|
||||
from ..common import NotifyType
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
|
||||
class FORMPayloadField:
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -30,11 +30,11 @@ import requests
|
|||
import base64
|
||||
from json import dumps
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..URLBase import PrivacyMode
|
||||
from .base import NotifyBase
|
||||
from ..url import PrivacyMode
|
||||
from ..common import NotifyImageSize
|
||||
from ..common import NotifyType
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
|
||||
class JSONPayloadField:
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -30,11 +30,11 @@ import re
|
|||
import requests
|
||||
import base64
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..URLBase import PrivacyMode
|
||||
from .base import NotifyBase
|
||||
from ..url import PrivacyMode
|
||||
from ..common import NotifyImageSize
|
||||
from ..common import NotifyType
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
|
||||
class XMLPayloadField:
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -39,13 +39,13 @@ import requests
|
|||
from json import dumps
|
||||
from json import loads
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from .base 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 _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
# Extend HTTP Error Messages
|
||||
D7NETWORKS_HTTP_ERROR_MAP = {
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -51,9 +51,9 @@ from json import dumps
|
|||
import requests
|
||||
from requests.auth import HTTPBasicAuth
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..URLBase import PrivacyMode
|
||||
from .base import NotifyBase
|
||||
from ..locale import gettext_lazy as _
|
||||
from ..url import PrivacyMode
|
||||
from ..common import NotifyType
|
||||
from ..utils import is_call_sign
|
||||
from ..utils import parse_call_sign
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -26,15 +26,12 @@
|
|||
# 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 .base import NotifyBase
|
||||
from ..common import NotifyImageSize
|
||||
from ..common import NotifyType
|
||||
from ..utils import parse_bool
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
# Default our global support flag
|
||||
NOTIFY_DBUS_SUPPORT_ENABLED = False
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -34,13 +34,13 @@ import base64
|
|||
import requests
|
||||
from json import dumps
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..URLBase import PrivacyMode
|
||||
from .base import NotifyBase
|
||||
from ..url 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 _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
# Register at https://dingtalk.com
|
||||
# - Download their PC based software as it is the only way you can create
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -50,14 +50,19 @@ from datetime import timedelta
|
|||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from .base 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
|
||||
from ..locale import gettext_lazy as _
|
||||
from ..attachment.base import AttachBase
|
||||
|
||||
|
||||
# Used to detect user/role IDs
|
||||
USER_ROLE_DETECTION_RE = re.compile(
|
||||
r'\s*(?:<@(?P<role>&?)(?P<id>[0-9]+)>|@(?P<value>[a-z0-9]+))', re.I)
|
||||
|
||||
|
||||
class NotifyDiscord(NotifyBase):
|
||||
|
@ -100,6 +105,10 @@ class NotifyDiscord(NotifyBase):
|
|||
# The maximum allowable characters allowed in the body per message
|
||||
body_maxlen = 2000
|
||||
|
||||
# The 2000 characters above defined by the body_maxlen include that of the
|
||||
# title. Setting this to True ensures overflow options behave properly
|
||||
overflow_amalgamate_title = True
|
||||
|
||||
# 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.
|
||||
|
@ -336,6 +345,33 @@ class NotifyDiscord(NotifyBase):
|
|||
payload['content'] = \
|
||||
body if not title else "{}\r\n{}".format(title, body)
|
||||
|
||||
# parse for user id's <@123> and role IDs <@&456>
|
||||
results = USER_ROLE_DETECTION_RE.findall(body)
|
||||
if results:
|
||||
payload['allow_mentions'] = {
|
||||
'parse': [],
|
||||
'users': [],
|
||||
'roles': [],
|
||||
}
|
||||
|
||||
_content = []
|
||||
for (is_role, no, value) in results:
|
||||
if value:
|
||||
payload['allow_mentions']['parse'].append(value)
|
||||
_content.append(f'@{value}')
|
||||
|
||||
elif is_role:
|
||||
payload['allow_mentions']['roles'].append(no)
|
||||
_content.append(f'<@&{no}>')
|
||||
|
||||
else: # is_user
|
||||
payload['allow_mentions']['users'].append(no)
|
||||
_content.append(f'<@{no}>')
|
||||
|
||||
if self.notify_format == NotifyFormat.MARKDOWN:
|
||||
# Add pingable elements to content field
|
||||
payload['content'] = '👉 ' + ' '.join(_content)
|
||||
|
||||
if not self._send(payload, params=params):
|
||||
# We failed to post our message
|
||||
return False
|
||||
|
@ -360,16 +396,21 @@ class NotifyDiscord(NotifyBase):
|
|||
'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']
|
||||
|
||||
if 'allow_mentions' in payload:
|
||||
del payload['allow_mentions']
|
||||
|
||||
#
|
||||
# Send our attachments
|
||||
#
|
||||
for attachment in attach:
|
||||
self.logger.info(
|
||||
'Posting Discord Attachment {}'.format(attachment.name))
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -41,12 +41,12 @@ from socket import error as SocketError
|
|||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..URLBase import PrivacyMode
|
||||
from .base import NotifyBase
|
||||
from ..url 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 ..utils import is_ipaddr, is_email, parse_emails, is_hostname
|
||||
from ..locale import gettext_lazy as _
|
||||
from ..logger import logger
|
||||
|
||||
# Globally Default encoding mode set to Quoted Printable.
|
||||
|
@ -295,6 +295,21 @@ EMAIL_TEMPLATES = (
|
|||
},
|
||||
),
|
||||
|
||||
# Comcast.net
|
||||
(
|
||||
'Comcast.net',
|
||||
re.compile(
|
||||
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
|
||||
r'(?P<domain>(comcast)\.net)$', re.I),
|
||||
{
|
||||
'port': 465,
|
||||
'smtp_host': 'smtp.comcast.net',
|
||||
'secure': True,
|
||||
'secure_mode': SecureMailMode.SSL,
|
||||
'login_type': (WebBaseLogin.EMAIL, )
|
||||
},
|
||||
),
|
||||
|
||||
# Catch All
|
||||
(
|
||||
'Custom',
|
||||
|
@ -481,34 +496,6 @@ class NotifyEmail(NotifyBase):
|
|||
# addresses from the URL provided
|
||||
self.from_addr = [False, '']
|
||||
|
||||
if self.user and self.host:
|
||||
# Prepare the bases of our email
|
||||
self.from_addr = [self.app_id, '{}@{}'.format(
|
||||
re.split(r'[\s@]+', self.user)[0],
|
||||
self.host,
|
||||
)]
|
||||
|
||||
if from_addr:
|
||||
result = is_email(from_addr)
|
||||
if result:
|
||||
self.from_addr = (
|
||||
result['name'] if result['name'] else False,
|
||||
result['full_email'])
|
||||
else:
|
||||
self.from_addr[0] = from_addr
|
||||
|
||||
result = is_email(self.from_addr[1])
|
||||
if not result:
|
||||
# Parse Source domain based on from_addr
|
||||
msg = 'Invalid ~From~ email specified: {}'.format(
|
||||
'{} <{}>'.format(self.from_addr[0], self.from_addr[1])
|
||||
if self.from_addr[0] else '{}'.format(self.from_addr[1]))
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
# Store our lookup
|
||||
self.names[self.from_addr[1]] = self.from_addr[0]
|
||||
|
||||
# Now detect the SMTP Server
|
||||
self.smtp_host = \
|
||||
smtp_host if isinstance(smtp_host, str) else ''
|
||||
|
@ -528,25 +515,6 @@ class NotifyEmail(NotifyBase):
|
|||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
if targets:
|
||||
# Validate recipients (to:) and drop bad ones:
|
||||
for recipient in parse_emails(targets):
|
||||
result = is_email(recipient)
|
||||
if result:
|
||||
self.targets.append(
|
||||
(result['name'] if result['name'] else False,
|
||||
result['full_email']))
|
||||
continue
|
||||
|
||||
self.logger.warning(
|
||||
'Dropped invalid To email '
|
||||
'({}) specified.'.format(recipient),
|
||||
)
|
||||
|
||||
else:
|
||||
# If our target email list is empty we want to add ourselves to it
|
||||
self.targets.append((False, self.from_addr[1]))
|
||||
|
||||
# Validate recipients (cc:) and drop bad ones:
|
||||
for recipient in parse_emails(cc):
|
||||
email = is_email(recipient)
|
||||
|
@ -598,6 +566,62 @@ class NotifyEmail(NotifyBase):
|
|||
# Apply any defaults based on certain known configurations
|
||||
self.NotifyEmailDefaults(secure_mode=secure_mode, **kwargs)
|
||||
|
||||
if self.user:
|
||||
if self.host:
|
||||
# Prepare the bases of our email
|
||||
self.from_addr = [self.app_id, '{}@{}'.format(
|
||||
re.split(r'[\s@]+', self.user)[0],
|
||||
self.host,
|
||||
)]
|
||||
|
||||
else:
|
||||
result = is_email(self.user)
|
||||
if result:
|
||||
# Prepare the bases of our email and include domain
|
||||
self.host = result['domain']
|
||||
self.from_addr = [self.app_id, self.user]
|
||||
|
||||
if from_addr:
|
||||
result = is_email(from_addr)
|
||||
if result:
|
||||
self.from_addr = (
|
||||
result['name'] if result['name'] else False,
|
||||
result['full_email'])
|
||||
else:
|
||||
# Only update the string but use the already detected info
|
||||
self.from_addr[0] = from_addr
|
||||
|
||||
result = is_email(self.from_addr[1])
|
||||
if not result:
|
||||
# Parse Source domain based on from_addr
|
||||
msg = 'Invalid ~From~ email specified: {}'.format(
|
||||
'{} <{}>'.format(self.from_addr[0], self.from_addr[1])
|
||||
if self.from_addr[0] else '{}'.format(self.from_addr[1]))
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
# Store our lookup
|
||||
self.names[self.from_addr[1]] = self.from_addr[0]
|
||||
|
||||
if targets:
|
||||
# Validate recipients (to:) and drop bad ones:
|
||||
for recipient in parse_emails(targets):
|
||||
result = is_email(recipient)
|
||||
if result:
|
||||
self.targets.append(
|
||||
(result['name'] if result['name'] else False,
|
||||
result['full_email']))
|
||||
continue
|
||||
|
||||
self.logger.warning(
|
||||
'Dropped invalid To email '
|
||||
'({}) specified.'.format(recipient),
|
||||
)
|
||||
|
||||
else:
|
||||
# If our target email list is empty we want to add ourselves to it
|
||||
self.targets.append((False, self.from_addr[1]))
|
||||
|
||||
if not self.secure and self.secure_mode != SecureMailMode.INSECURE:
|
||||
# Enable Secure mode if not otherwise set
|
||||
self.secure = True
|
||||
|
@ -664,9 +688,7 @@ class NotifyEmail(NotifyBase):
|
|||
# was specified, then we default to having them all set (which
|
||||
# basically implies that there are no restrictions and use use
|
||||
# whatever was specified)
|
||||
login_type = EMAIL_TEMPLATES[i][2]\
|
||||
.get('login_type', [])
|
||||
|
||||
login_type = EMAIL_TEMPLATES[i][2].get('login_type', [])
|
||||
if login_type:
|
||||
# only apply additional logic to our user if a login_type
|
||||
# was specified.
|
||||
|
@ -676,6 +698,10 @@ class NotifyEmail(NotifyBase):
|
|||
# not supported; switch it to user id
|
||||
self.user = match.group('id')
|
||||
|
||||
else:
|
||||
# Enforce our host information
|
||||
self.host = self.user.split('@')[1]
|
||||
|
||||
elif WebBaseLogin.USERID not in login_type:
|
||||
# user specified but login type
|
||||
# not supported; switch it to email
|
||||
|
@ -1019,11 +1045,29 @@ class NotifyEmail(NotifyBase):
|
|||
us to re-instantiate this object.
|
||||
|
||||
"""
|
||||
results = NotifyBase.parse_url(url)
|
||||
results = NotifyBase.parse_url(url, verify_host=False)
|
||||
if not results:
|
||||
# We're done early as we couldn't load the results
|
||||
return results
|
||||
|
||||
# Prepare our target lists
|
||||
results['targets'] = []
|
||||
|
||||
if is_ipaddr(results['host']):
|
||||
# Silently move on and do not disrupt any configuration
|
||||
pass
|
||||
|
||||
elif not is_hostname(results['host'], ipv4=False, ipv6=False,
|
||||
underscore=False):
|
||||
|
||||
if is_email(NotifyEmail.unquote(results['host'])):
|
||||
# Don't lose defined email addresses
|
||||
results['targets'].append(NotifyEmail.unquote(results['host']))
|
||||
|
||||
# Detect if we have a valid hostname or not; be sure to reset it's
|
||||
# value if invalid; we'll attempt to figure this out later on
|
||||
results['host'] = ''
|
||||
|
||||
# The From address is a must; either through the use of templates
|
||||
# from= entry and/or merging the user and hostname together, this
|
||||
# must be calculated or parse_url will fail.
|
||||
|
@ -1034,7 +1078,7 @@ class NotifyEmail(NotifyBase):
|
|||
|
||||
# Get our potential email targets; if none our found we'll just
|
||||
# add one to ourselves
|
||||
results['targets'] = NotifyEmail.split_path(results['fullpath'])
|
||||
results['targets'] += NotifyEmail.split_path(results['fullpath'])
|
||||
|
||||
# Attempt to detect 'to' email address
|
||||
if 'to' in results['qsd'] and len(results['qsd']['to']):
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -34,12 +34,12 @@ import hashlib
|
|||
from json import dumps
|
||||
from json import loads
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..URLBase import PrivacyMode
|
||||
from .base import NotifyBase
|
||||
from ..url import PrivacyMode
|
||||
from ..utils import parse_bool
|
||||
from ..common import NotifyType
|
||||
from .. import __version__ as VERSION
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
|
||||
class NotifyEmby(NotifyBase):
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -37,10 +37,10 @@
|
|||
import requests
|
||||
from json import loads
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..URLBase import PrivacyMode
|
||||
from .base import NotifyBase
|
||||
from ..url import PrivacyMode
|
||||
from ..common import NotifyType
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
|
||||
class Enigma2MessageType:
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -50,15 +50,15 @@
|
|||
# You will need this in order to send an apprise messag
|
||||
import requests
|
||||
from json import dumps
|
||||
from ..NotifyBase import NotifyBase
|
||||
from ..base import NotifyBase
|
||||
from ...common import NotifyType
|
||||
from ...utils import validate_regex
|
||||
from ...utils import parse_list
|
||||
from ...utils import parse_bool
|
||||
from ...utils import dict_full_update
|
||||
from ...common import NotifyImageSize
|
||||
from ...AppriseAttachment import AppriseAttachment
|
||||
from ...AppriseLocale import gettext_lazy as _
|
||||
from ...apprise_attachment import AppriseAttachment
|
||||
from ...locale import gettext_lazy as _
|
||||
from .common import (FCMMode, FCM_MODES)
|
||||
from .priority import (FCM_PRIORITIES, FCMPriorityManager)
|
||||
from .color import FCMColorManager
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -36,7 +36,7 @@
|
|||
import re
|
||||
from ...utils import parse_bool
|
||||
from ...common import NotifyType
|
||||
from ...AppriseAsset import AppriseAsset
|
||||
from ...asset import AppriseAsset
|
||||
|
||||
|
||||
class FCMColorManager:
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
231
lib/apprise/plugins/feishu.py
Normal file
231
lib/apprise/plugins/feishu.py
Normal file
|
@ -0,0 +1,231 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
#
|
||||
# 1. Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
#
|
||||
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
# Feishu
|
||||
# 1. Visit https://open.feishu.cn
|
||||
|
||||
# Custom Bot Setup
|
||||
# https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot
|
||||
#
|
||||
|
||||
import requests
|
||||
from json import dumps
|
||||
|
||||
from .base import NotifyBase
|
||||
from ..common import NotifyType
|
||||
from ..utils import validate_regex
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
|
||||
class NotifyFeishu(NotifyBase):
|
||||
"""
|
||||
A wrapper for Feishu Notifications
|
||||
"""
|
||||
|
||||
# The default descriptive name associated with the Notification
|
||||
service_name = _('Feishu')
|
||||
|
||||
# The services URL
|
||||
service_url = 'https://open.feishu.cn/'
|
||||
|
||||
# The default secure protocol
|
||||
secure_protocol = 'feishu'
|
||||
|
||||
# A URL that takes you to the setup/help of the specific protocol
|
||||
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_feishu'
|
||||
|
||||
# Notification URL
|
||||
notify_url = 'https://open.feishu.cn/open-apis/bot/v2/hook/{token}/'
|
||||
|
||||
# Define object templates
|
||||
templates = (
|
||||
'{schema}://{token}',
|
||||
)
|
||||
|
||||
# The title is not used
|
||||
title_maxlen = 0
|
||||
|
||||
# Limit is documented to be 20K message sizes. This number safely
|
||||
# allows padding around that size.
|
||||
body_maxlen = 19985
|
||||
|
||||
# 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, **{
|
||||
'token': {
|
||||
'name': _('Token'),
|
||||
'type': 'string',
|
||||
'private': True,
|
||||
'required': True,
|
||||
'regex': (r'^[A-Z0-9_-]+$', 'i'),
|
||||
},
|
||||
})
|
||||
|
||||
# Define our template arguments
|
||||
template_args = dict(NotifyBase.template_args, **{
|
||||
'token': {
|
||||
'alias_of': 'token',
|
||||
},
|
||||
})
|
||||
|
||||
def __init__(self, token, **kwargs):
|
||||
"""
|
||||
Initialize Feishu Object
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self.token = validate_regex(
|
||||
token, *self.template_tokens['token']['regex'])
|
||||
if not self.token:
|
||||
msg = 'The Feishu token specified ({}) is invalid.'\
|
||||
.format(token)
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
return
|
||||
|
||||
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
||||
"""
|
||||
Send our notification
|
||||
"""
|
||||
|
||||
# prepare our headers
|
||||
headers = {
|
||||
'User-Agent': self.app_id,
|
||||
'Content-Type': "application/json",
|
||||
}
|
||||
|
||||
# Our Message
|
||||
payload = {
|
||||
'msg_type': 'text',
|
||||
"content": {
|
||||
"text": body,
|
||||
}
|
||||
}
|
||||
|
||||
self.logger.debug('Feishu GET URL: %s (cert_verify=%r)' % (
|
||||
self.notify_url, self.verify_certificate))
|
||||
self.logger.debug('Feishu Payload: %s' % str(payload))
|
||||
|
||||
# Always call throttle before any remote server i/o is made
|
||||
self.throttle()
|
||||
|
||||
try:
|
||||
r = requests.post(
|
||||
self.notify_url.format(token=self.token),
|
||||
data=dumps(payload).encode('utf-8'),
|
||||
headers=headers,
|
||||
verify=self.verify_certificate,
|
||||
timeout=self.request_timeout,
|
||||
)
|
||||
|
||||
#
|
||||
# Sample Responses
|
||||
#
|
||||
# Valid:
|
||||
# {
|
||||
# "code": 0,
|
||||
# "data": {},
|
||||
# "msg": "success"
|
||||
# }
|
||||
|
||||
# Invalid (non 200 response):
|
||||
# {
|
||||
# "code": 9499,
|
||||
# "msg": "Bad Request",
|
||||
# "data": {}
|
||||
# }
|
||||
if r.status_code != requests.codes.ok:
|
||||
# We had a problem
|
||||
status_str = \
|
||||
NotifyFeishu.http_response_code_lookup(r.status_code)
|
||||
|
||||
self.logger.warning(
|
||||
'Failed to send Feishu 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 Feishu notification.')
|
||||
|
||||
except requests.RequestException as e:
|
||||
self.logger.warning(
|
||||
'A Connection error occurred sending Feishu '
|
||||
'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.
|
||||
"""
|
||||
|
||||
# Prepare our parameters
|
||||
params = self.url_parameters(privacy=privacy, *args, **kwargs)
|
||||
|
||||
return '{schema}://{token}/?{params}'.format(
|
||||
schema=self.secure_protocol,
|
||||
token=self.pprint(self.token, privacy, safe=''),
|
||||
params=NotifyFeishu.urlencode(params),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def parse_url(url):
|
||||
"""
|
||||
Parses the URL and returns enough arguments that can allow
|
||||
us to re-instantiate this object.
|
||||
|
||||
"""
|
||||
|
||||
# parse_url already handles getting the `user` and `password` fields
|
||||
# populated.
|
||||
results = NotifyBase.parse_url(url, verify_host=False)
|
||||
if not results:
|
||||
# We're done early as we couldn't load the results
|
||||
return results
|
||||
|
||||
# Allow over-ride
|
||||
if 'token' in results['qsd'] and len(results['qsd']['token']):
|
||||
results['token'] = NotifyFeishu.unquote(results['qsd']['token'])
|
||||
|
||||
else:
|
||||
results['token'] = NotifyFeishu.unquote(results['host'])
|
||||
|
||||
return results
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -44,14 +44,14 @@ import re
|
|||
import requests
|
||||
from json import dumps
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from .base import NotifyBase
|
||||
from ..common import NotifyType
|
||||
from ..common import NotifyFormat
|
||||
from ..common import NotifyImageSize
|
||||
from ..utils import parse_list
|
||||
from ..utils import parse_bool
|
||||
from ..utils import validate_regex
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
|
||||
# Extend HTTP Error Messages
|
205
lib/apprise/plugins/freemobile.py
Normal file
205
lib/apprise/plugins/freemobile.py
Normal file
|
@ -0,0 +1,205 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
#
|
||||
# 1. Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
#
|
||||
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
# Free Mobile
|
||||
# 1. Visit https://mobile.free.fr/
|
||||
|
||||
# the URL will look something like this:
|
||||
# https://smsapi.free-mobile.fr/sendmsg
|
||||
#
|
||||
|
||||
import requests
|
||||
from json import dumps
|
||||
|
||||
from .base import NotifyBase
|
||||
from ..common import NotifyType
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
|
||||
class NotifyFreeMobile(NotifyBase):
|
||||
"""
|
||||
A wrapper for Free-Mobile Notifications
|
||||
"""
|
||||
|
||||
# The default descriptive name associated with the Notification
|
||||
service_name = _('Free-Mobile')
|
||||
|
||||
# The services URL
|
||||
service_url = 'https://mobile.free.fr/'
|
||||
|
||||
# The default secure protocol
|
||||
secure_protocol = 'freemobile'
|
||||
|
||||
# A URL that takes you to the setup/help of the specific protocol
|
||||
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_freemobile'
|
||||
|
||||
# Plain Text Notification URL
|
||||
notify_url = 'https://smsapi.free-mobile.fr/sendmsg'
|
||||
|
||||
# Define object templates
|
||||
templates = (
|
||||
'{schema}://{user}@{password}',
|
||||
)
|
||||
|
||||
# The title is not used
|
||||
title_maxlen = 0
|
||||
|
||||
# SMS Messages are restricted in size
|
||||
body_maxlen = 160
|
||||
|
||||
# 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, **{
|
||||
'user': {
|
||||
'name': _('Username'),
|
||||
'type': 'string',
|
||||
'required': True,
|
||||
},
|
||||
'password': {
|
||||
'name': _('Password'),
|
||||
'type': 'string',
|
||||
'private': True,
|
||||
'required': True,
|
||||
},
|
||||
})
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""
|
||||
Initialize Free Mobile Object
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
|
||||
if not (self.user and self.password):
|
||||
msg = 'A FreeMobile user and password ' \
|
||||
'combination was not provided.'
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
return
|
||||
|
||||
def url(self, privacy=False, *args, **kwargs):
|
||||
"""
|
||||
Returns the URL built dynamically based on specified arguments.
|
||||
"""
|
||||
|
||||
# Prepare our parameters
|
||||
params = self.url_parameters(privacy=privacy, *args, **kwargs)
|
||||
|
||||
return '{schema}://{user}@{password}/?{params}'.format(
|
||||
schema=self.secure_protocol,
|
||||
user=self.user,
|
||||
password=self.pprint(self.password, privacy, safe=''),
|
||||
params=NotifyFreeMobile.urlencode(params),
|
||||
)
|
||||
|
||||
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
||||
"""
|
||||
Send our notification
|
||||
"""
|
||||
|
||||
# prepare our headers
|
||||
headers = {
|
||||
'User-Agent': self.app_id,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
# Prepare our payload
|
||||
payload = {
|
||||
'user': self.user,
|
||||
'pass': self.password,
|
||||
'msg': body
|
||||
}
|
||||
|
||||
self.logger.debug('Free Mobile GET URL: %s (cert_verify=%r)' % (
|
||||
self.notify_url, self.verify_certificate))
|
||||
self.logger.debug('Free Mobile 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).encode('utf-8'),
|
||||
headers=headers,
|
||||
verify=self.verify_certificate,
|
||||
timeout=self.request_timeout,
|
||||
)
|
||||
if r.status_code != requests.codes.ok:
|
||||
# We had a problem
|
||||
status_str = \
|
||||
NotifyFreeMobile.http_response_code_lookup(r.status_code)
|
||||
|
||||
self.logger.warning(
|
||||
'Failed to send Free Mobile 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 Free Mobile notification.')
|
||||
|
||||
except requests.RequestException as e:
|
||||
self.logger.warning(
|
||||
'A Connection error occurred sending Free Mobile '
|
||||
'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.
|
||||
|
||||
"""
|
||||
|
||||
# parse_url already handles getting the `user` and `password` fields
|
||||
# populated.
|
||||
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 can act as the password if specified and a password
|
||||
# was otherwise not (specified):
|
||||
if not results.get('password'):
|
||||
results['password'] = NotifyFreeMobile.unquote(results['host'])
|
||||
|
||||
return results
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -26,14 +26,11 @@
|
|||
# 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 .NotifyBase import NotifyBase
|
||||
from .base import NotifyBase
|
||||
from ..common import NotifyImageSize
|
||||
from ..common import NotifyType
|
||||
from ..utils import parse_bool
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
# Default our global support flag
|
||||
NOTIFY_GNOME_SUPPORT_ENABLED = False
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -58,11 +58,11 @@ import re
|
|||
import requests
|
||||
from json import dumps
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from .base import NotifyBase
|
||||
from ..common import NotifyFormat
|
||||
from ..common import NotifyType
|
||||
from ..utils import validate_regex
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
|
||||
class NotifyGoogleChat(NotifyBase):
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -34,10 +34,10 @@
|
|||
import requests
|
||||
from json import dumps
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from .base import NotifyBase
|
||||
from ..common import NotifyType, NotifyFormat
|
||||
from ..utils import validate_regex
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
|
||||
# Priorities
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -26,12 +26,12 @@
|
|||
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..URLBase import PrivacyMode
|
||||
from .base import NotifyBase
|
||||
from ..url import PrivacyMode
|
||||
from ..common import NotifyImageSize
|
||||
from ..common import NotifyType
|
||||
from ..utils import parse_bool
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
# Default our global support flag
|
||||
NOTIFY_GROWL_SUPPORT_ENABLED = False
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -45,10 +45,11 @@
|
|||
#
|
||||
|
||||
import re
|
||||
from .NotifyDiscord import NotifyDiscord
|
||||
# Import namespace so the class won't conflict with the actual Notify object
|
||||
from . import discord
|
||||
|
||||
|
||||
class NotifyGuilded(NotifyDiscord):
|
||||
class NotifyGuilded(discord.NotifyDiscord):
|
||||
"""
|
||||
A wrapper to Guilded Notifications
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -34,11 +34,11 @@ from json import dumps
|
|||
|
||||
from uuid import uuid4
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..URLBase import PrivacyMode
|
||||
from .base import NotifyBase
|
||||
from ..url import PrivacyMode
|
||||
from ..common import NotifyType
|
||||
from ..utils import validate_regex
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
|
||||
class NotifyHomeAssistant(NotifyBase):
|
330
lib/apprise/plugins/httpsms.py
Normal file
330
lib/apprise/plugins/httpsms.py
Normal file
|
@ -0,0 +1,330 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
#
|
||||
# 1. Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
#
|
||||
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
# To use this service you will need a httpSMS account
|
||||
# You will need credits (new accounts start with a few)
|
||||
# https://httpsms.com
|
||||
import requests
|
||||
import json
|
||||
from .base import NotifyBase
|
||||
from ..common import NotifyType
|
||||
from ..utils import is_phone_no
|
||||
from ..utils import parse_phone_no
|
||||
from ..utils import validate_regex
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
|
||||
class NotifyHttpSMS(NotifyBase):
|
||||
"""
|
||||
A wrapper for HttpSMS Notifications
|
||||
"""
|
||||
|
||||
# The default descriptive name associated with the Notification
|
||||
service_name = 'httpSMS'
|
||||
|
||||
# The services URL
|
||||
service_url = 'https://httpsms.com'
|
||||
|
||||
# All notification requests are secure
|
||||
secure_protocol = 'httpsms'
|
||||
|
||||
# A URL that takes you to the setup/help of the specific protocol
|
||||
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_httpsms'
|
||||
|
||||
# HttpSMS uses the http protocol with JSON requests
|
||||
notify_url = 'https://api.httpsms.com/v1/messages/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}://{apikey}@{from_phone}',
|
||||
'{schema}://{apikey}@{from_phone}/{targets}',
|
||||
)
|
||||
|
||||
# Define our template tokens
|
||||
template_tokens = dict(NotifyBase.template_tokens, **{
|
||||
'apikey': {
|
||||
'name': _('API Key'),
|
||||
'type': 'string',
|
||||
'private': True,
|
||||
'required': True,
|
||||
},
|
||||
'from_phone': {
|
||||
'name': _('From Phone No'),
|
||||
'type': 'string',
|
||||
'regex': (r'^\+?[0-9\s)(+-]+$', 'i'),
|
||||
'map_to': 'source',
|
||||
'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, **{
|
||||
'key': {
|
||||
'alias_of': 'apikey',
|
||||
},
|
||||
'to': {
|
||||
'alias_of': 'targets',
|
||||
},
|
||||
'from': {
|
||||
'name': _('From Phone No'),
|
||||
'type': 'string',
|
||||
'regex': (r'^\+?[0-9\s)(+-]+$', 'i'),
|
||||
'map_to': 'source',
|
||||
},
|
||||
})
|
||||
|
||||
def __init__(self, apikey=None, source=None, targets=None, **kwargs):
|
||||
"""
|
||||
Initialize HttpSMS Object
|
||||
"""
|
||||
super(NotifyHttpSMS, self).__init__(**kwargs)
|
||||
|
||||
self.apikey = validate_regex(apikey)
|
||||
if not self.apikey:
|
||||
msg = 'An invalid API Key ({}) was specified.'.format(apikey)
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
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 = result['full']
|
||||
|
||||
# Parse our targets
|
||||
self.targets = list()
|
||||
|
||||
has_error = False
|
||||
for target in parse_phone_no(targets):
|
||||
# Parse each phone number we found
|
||||
result = is_phone_no(target)
|
||||
if result:
|
||||
self.targets.append(result['full'])
|
||||
continue
|
||||
|
||||
has_error = True
|
||||
self.logger.warning(
|
||||
'Dropped invalid phone # ({}) specified.'.format(target),
|
||||
)
|
||||
|
||||
if not targets and not has_error:
|
||||
# Default the SMS Message to ourselves
|
||||
self.targets.append(self.source)
|
||||
|
||||
return
|
||||
|
||||
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
||||
"""
|
||||
Perform HttpSMS Notification
|
||||
"""
|
||||
|
||||
if not self.targets:
|
||||
# We have nothing to notify
|
||||
self.logger.warning('There are no HttpSMS targets to notify')
|
||||
return False
|
||||
|
||||
# error tracking (used for function return)
|
||||
has_error = False
|
||||
|
||||
# Prepare our headers
|
||||
headers = {
|
||||
'User-Agent': self.app_id,
|
||||
'x-api-key': self.apikey,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
# Prepare our payload
|
||||
payload = {
|
||||
# The To gets populated in the loop below
|
||||
'from': '+' + self.source,
|
||||
'to': None,
|
||||
'content': body,
|
||||
}
|
||||
|
||||
# Prepare our targets
|
||||
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('HttpSMS POST URL: {} (cert_verify={})'.format(
|
||||
self.notify_url, self.verify_certificate))
|
||||
self.logger.debug('HttpSMS 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,
|
||||
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
|
||||
|
||||
self.logger.warning(
|
||||
'Failed to send HttpSMS 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 HttpSMS notification to {}.'.format(target))
|
||||
|
||||
except requests.RequestException as e:
|
||||
self.logger.warning(
|
||||
'A Connection error occurred sending HttpSMS: to %s ',
|
||||
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.
|
||||
"""
|
||||
|
||||
# Prepare our parameters
|
||||
params = self.url_parameters(privacy=privacy, *args, **kwargs)
|
||||
|
||||
# A nice way of cleaning up the URL length a bit
|
||||
targets = [] if len(self.targets) == 1 \
|
||||
and self.targets[0] == self.source else self.targets
|
||||
|
||||
return '{schema}://{apikey}@{source}/{targets}' \
|
||||
'?{params}'.format(
|
||||
schema=self.secure_protocol,
|
||||
source=self.source,
|
||||
apikey=self.pprint(self.apikey, privacy, safe=''),
|
||||
targets='/'.join([
|
||||
NotifyHttpSMS.quote('{}'.format(x), safe='+')
|
||||
for x in targets]),
|
||||
params=NotifyHttpSMS.urlencode(params))
|
||||
|
||||
def __len__(self):
|
||||
"""
|
||||
Returns the number of targets associated with this notification
|
||||
"""
|
||||
|
||||
return len(self.targets) if self.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, verify_host=False)
|
||||
if not results:
|
||||
# We're done early as we couldn't load the results
|
||||
return results
|
||||
|
||||
# Get our API Key
|
||||
results['apikey'] = NotifyHttpSMS.unquote(results['user'])
|
||||
|
||||
# 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'] = \
|
||||
NotifyHttpSMS.unquote(results['qsd']['from'])
|
||||
|
||||
# hostname will also be a target in this case
|
||||
results['targets'] = [
|
||||
*NotifyHttpSMS.parse_phone_no(results['host']),
|
||||
*NotifyHttpSMS.split_path(results['fullpath'])]
|
||||
|
||||
else:
|
||||
# store our source
|
||||
results['source'] = NotifyHttpSMS.unquote(results['host'])
|
||||
|
||||
# store targets
|
||||
results['targets'] = NotifyHttpSMS.split_path(results['fullpath'])
|
||||
|
||||
# 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'] += \
|
||||
NotifyHttpSMS.parse_phone_no(results['qsd']['to'])
|
||||
|
||||
if 'key' in results['qsd'] and len(results['qsd']['key']):
|
||||
results['apikey'] = \
|
||||
NotifyHttpSMS.unquote(results['qsd']['key'])
|
||||
|
||||
return results
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -44,11 +44,11 @@ import re
|
|||
import requests
|
||||
from json import dumps
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from .base import NotifyBase
|
||||
from ..common import NotifyType
|
||||
from ..utils import parse_list
|
||||
from ..utils import validate_regex
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
|
||||
class NotifyIFTTT(NotifyBase):
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -39,13 +39,13 @@
|
|||
import re
|
||||
import requests
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from .base import NotifyBase
|
||||
from ..common import NotifyImageSize
|
||||
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 ..locale import gettext_lazy as _
|
||||
|
||||
# Extend HTTP Error Messages
|
||||
JOIN_HTTP_ERROR_MAP = {
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -38,12 +38,12 @@
|
|||
import requests
|
||||
from json import loads
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from .base 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 _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
# Extend HTTP Error Messages
|
||||
# Based on https://kavenegar.com/rest.html
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -39,10 +39,10 @@
|
|||
import requests
|
||||
from json import dumps
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from .base import NotifyBase
|
||||
from ..common import NotifyType
|
||||
from ..utils import validate_regex
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
# Extend HTTP Error Messages
|
||||
KUMULOS_HTTP_ERROR_MAP = {
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -90,10 +90,10 @@
|
|||
import re
|
||||
import requests
|
||||
from json import dumps
|
||||
from .NotifyBase import NotifyBase
|
||||
from .base import NotifyBase
|
||||
from ..common import NotifyType
|
||||
from ..utils import validate_regex
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..locale import gettext_lazy as _
|
||||
from ..utils import is_hostname
|
||||
from ..utils import is_ipaddr
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -33,14 +33,14 @@ import requests
|
|||
import re
|
||||
from json import dumps
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..URLBase import PrivacyMode
|
||||
from .base import NotifyBase
|
||||
from ..url import PrivacyMode
|
||||
from ..common import NotifyType
|
||||
from ..common import NotifyImageSize
|
||||
from ..utils import validate_regex
|
||||
from ..utils import parse_list
|
||||
from ..utils import parse_bool
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
|
||||
# Used to break path apart into list of streams
|
440
lib/apprise/plugins/lunasea.py
Normal file
440
lib/apprise/plugins/lunasea.py
Normal file
|
@ -0,0 +1,440 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
#
|
||||
# 1. Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
#
|
||||
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
#
|
||||
# API:
|
||||
# https://docs.lunasea.app/lunasea/notifications/custom-notifications
|
||||
#
|
||||
import re
|
||||
import requests
|
||||
from json import dumps
|
||||
|
||||
from .base import NotifyBase
|
||||
from ..common import NotifyType
|
||||
from ..common import NotifyImageSize
|
||||
from ..utils import parse_list
|
||||
from ..utils import is_hostname
|
||||
from ..utils import is_ipaddr
|
||||
from ..utils import parse_bool
|
||||
from ..locale import gettext_lazy as _
|
||||
from ..url import PrivacyMode
|
||||
|
||||
|
||||
class LunaSeaMode:
|
||||
"""
|
||||
Define LunaSea Notification Modes
|
||||
"""
|
||||
# App posts upstream to the developer API on LunaSea's website
|
||||
CLOUD = "cloud"
|
||||
|
||||
# Running a dedicated private ntfy Server
|
||||
PRIVATE = "private"
|
||||
|
||||
|
||||
LUNASEA_MODES = (
|
||||
LunaSeaMode.CLOUD,
|
||||
LunaSeaMode.PRIVATE,
|
||||
)
|
||||
|
||||
|
||||
class NotifyLunaSea(NotifyBase):
|
||||
"""
|
||||
A wrapper for LunaSea Notifications
|
||||
"""
|
||||
|
||||
# The default descriptive name associated with the Notification
|
||||
service_name = 'LunaSea'
|
||||
|
||||
# The services URL
|
||||
service_url = 'https://luasea.app'
|
||||
|
||||
# The default insecure protocol
|
||||
protocol = ('lunasea', 'lsea')
|
||||
|
||||
# The default secure protocol
|
||||
secure_protocol = ('lunaseas', 'lseas')
|
||||
|
||||
# A URL that takes you to the setup/help of the specific protocol
|
||||
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_lunasea'
|
||||
|
||||
# Allows the user to specify the NotifyImageSize object
|
||||
image_size = NotifyImageSize.XY_256
|
||||
|
||||
# LunaSea Notification Details
|
||||
cloud_notify_url = 'https://notify.lunasea.app'
|
||||
notify_user_path = '/v1/custom/user/{}'
|
||||
notify_device_path = '/v1/custom/device/{}'
|
||||
|
||||
# if our hostname matches the following we automatically enforce
|
||||
# cloud mode
|
||||
__auto_cloud_host = re.compile(r'(notify\.)?lunasea\.app', re.IGNORECASE)
|
||||
|
||||
# Define object templates
|
||||
templates = (
|
||||
'{schema}://{targets}',
|
||||
'{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}',
|
||||
)
|
||||
|
||||
# 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,
|
||||
},
|
||||
'target_user': {
|
||||
'name': _('Target User'),
|
||||
'type': 'string',
|
||||
'prefix': '@',
|
||||
'map_to': 'targets',
|
||||
},
|
||||
'target_device': {
|
||||
'name': _('Target Device'),
|
||||
'type': 'string',
|
||||
'prefix': '+',
|
||||
'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',
|
||||
},
|
||||
'image': {
|
||||
'name': _('Include Image'),
|
||||
'type': 'bool',
|
||||
'default': False,
|
||||
'map_to': 'include_image',
|
||||
},
|
||||
'mode': {
|
||||
'name': _('Mode'),
|
||||
'type': 'choice:string',
|
||||
'values': LUNASEA_MODES,
|
||||
'default': LunaSeaMode.PRIVATE,
|
||||
},
|
||||
})
|
||||
|
||||
def __init__(self, targets=None, mode=None, token=None,
|
||||
include_image=False, **kwargs):
|
||||
"""
|
||||
Initialize LunaSea Object
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
|
||||
# Show image associated with notification
|
||||
self.include_image = \
|
||||
self.template_args['image']['default'] \
|
||||
if include_image is None else include_image
|
||||
|
||||
# Prepare our mode
|
||||
self.mode = mode.strip().lower() \
|
||||
if isinstance(mode, str) \
|
||||
else self.template_args['mode']['default']
|
||||
|
||||
if self.mode not in LUNASEA_MODES:
|
||||
msg = 'An invalid LunaSea mode ({}) was specified.'.format(mode)
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
self.targets = []
|
||||
for target in parse_list(targets):
|
||||
if len(target) < 4:
|
||||
self.logger.warning(
|
||||
'A specified target ({}) is invalid and will be '
|
||||
'ignored'.format(target))
|
||||
continue
|
||||
|
||||
if target[0] == '+':
|
||||
# Device
|
||||
self.targets.append(('+', target[1:]))
|
||||
|
||||
elif target[0] == '@':
|
||||
# User
|
||||
self.targets.append(('@', target[1:]))
|
||||
|
||||
else:
|
||||
# User
|
||||
self.targets.append(('@', target))
|
||||
|
||||
return
|
||||
|
||||
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
||||
"""
|
||||
Perform LunaSea Notification
|
||||
"""
|
||||
|
||||
# error tracking (used for function return)
|
||||
has_error = False
|
||||
|
||||
if not len(self.targets):
|
||||
# We have nothing to notify; we're done
|
||||
self.logger.warning('There are no LunaSea targets to notify')
|
||||
return False
|
||||
|
||||
# Prepare our headers
|
||||
headers = {
|
||||
'User-Agent': self.app_id,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
|
||||
# prepare payload
|
||||
payload = {
|
||||
'title': title if title else self.app_desc,
|
||||
'body': body,
|
||||
}
|
||||
|
||||
# Acquire image_url
|
||||
image_url = None if not self.include_image \
|
||||
else self.image_url(notify_type)
|
||||
|
||||
if image_url:
|
||||
payload['image'] = image_url
|
||||
|
||||
# Prepare our Authentication (if defined)
|
||||
if self.user and self.password:
|
||||
auth = (self.user, self.password)
|
||||
|
||||
else:
|
||||
# No Auth
|
||||
auth = None
|
||||
|
||||
if self.mode == LunaSeaMode.CLOUD:
|
||||
# Cloud Service
|
||||
notify_url = self.cloud_notify_url
|
||||
|
||||
else:
|
||||
# Local Hosting
|
||||
schema = 'https' if self.secure else 'http'
|
||||
|
||||
notify_url = '%s://%s' % (schema, self.host)
|
||||
if isinstance(self.port, int):
|
||||
notify_url += ':%d' % self.port
|
||||
|
||||
# Create a copy of the targets list
|
||||
targets = list(self.targets)
|
||||
while len(targets):
|
||||
target = targets.pop(0)
|
||||
|
||||
if target[0] == '+':
|
||||
url = notify_url + self.notify_device_path.format(target[1])
|
||||
|
||||
else:
|
||||
url = notify_url + self.notify_user_path.format(target[1])
|
||||
|
||||
self.logger.debug('LunaSea POST URL: %s (cert_verify=%r)' % (
|
||||
url, self.verify_certificate,
|
||||
))
|
||||
self.logger.debug('LunaSea 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,
|
||||
auth=auth,
|
||||
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 = \
|
||||
NotifyLunaSea.http_response_code_lookup(r.status_code)
|
||||
|
||||
self.logger.warning(
|
||||
'Failed to deliver payload to LunaSea:'
|
||||
'{}{}error={}.'.format(
|
||||
status_str,
|
||||
', ' if status_str else '',
|
||||
r.status_code))
|
||||
|
||||
self.logger.debug(
|
||||
'Response Details:\r\n{}'.format(r.content))
|
||||
|
||||
has_error = True
|
||||
|
||||
# otherwise we were successful
|
||||
continue
|
||||
|
||||
except requests.RequestException as e:
|
||||
self.logger.warning(
|
||||
'A Connection error occurred communicating with LunaSea.')
|
||||
self.logger.debug('Socket Exception: %s' % str(e))
|
||||
|
||||
has_error = True
|
||||
|
||||
return not has_error
|
||||
|
||||
def url(self, privacy=False, *args, **kwargs):
|
||||
"""
|
||||
Returns the URL built dynamically based on specified arguments.
|
||||
"""
|
||||
|
||||
params = {
|
||||
'mode': self.mode,
|
||||
'image': 'yes' if self.include_image else 'no',
|
||||
}
|
||||
|
||||
# Our URL parameters
|
||||
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
|
||||
|
||||
auth = ''
|
||||
if self.user and self.password:
|
||||
auth = '{user}:{password}@'.format(
|
||||
user=NotifyLunaSea.quote(self.user, safe=''),
|
||||
password=self.pprint(
|
||||
self.password, privacy, mode=PrivacyMode.Secret,
|
||||
safe=''),
|
||||
)
|
||||
elif self.user:
|
||||
auth = '{user}@'.format(
|
||||
user=NotifyLunaSea.quote(self.user, safe=''),
|
||||
)
|
||||
|
||||
if self.mode == LunaSeaMode.PRIVATE:
|
||||
default_port = 443 if self.secure else 80
|
||||
return '{schema}://{auth}{host}{port}/{targets}?{params}'.format(
|
||||
schema=self.secure_protocol[0]
|
||||
if self.secure else self.protocol[0],
|
||||
auth=auth,
|
||||
host=self.host,
|
||||
port='' if self.port is None or self.port == default_port
|
||||
else ':{}'.format(self.port),
|
||||
targets='/'.join(
|
||||
[NotifyLunaSea.quote(x[0] + x[1], safe='@+')
|
||||
for x in self.targets]),
|
||||
params=NotifyLunaSea.urlencode(params)
|
||||
)
|
||||
|
||||
else: # Cloud mode
|
||||
return '{schema}://{auth}{targets}?{params}'.format(
|
||||
schema=self.protocol[0],
|
||||
auth=auth,
|
||||
targets='/'.join(
|
||||
[NotifyLunaSea.quote(x[0] + x[1], safe='@+')
|
||||
for x in self.targets]),
|
||||
params=NotifyLunaSea.urlencode(params)
|
||||
)
|
||||
|
||||
def __len__(self):
|
||||
"""
|
||||
Returns the number of targets associated with this notification
|
||||
"""
|
||||
# always return 1
|
||||
return 1 if not self.targets else len(self.targets)
|
||||
|
||||
@staticmethod
|
||||
def parse_url(url):
|
||||
"""
|
||||
Parses the URL and returns enough arguments that can allow
|
||||
us to re-instantiate this object.
|
||||
|
||||
"""
|
||||
results = NotifyBase.parse_url(url, verify_host=False)
|
||||
if not results:
|
||||
# We're done early as we couldn't load the results
|
||||
return results
|
||||
|
||||
# Fetch our targets
|
||||
results['targets'] = NotifyLunaSea.split_path(results['fullpath'])
|
||||
|
||||
# Boolean to include an image or not
|
||||
results['include_image'] = parse_bool(results['qsd'].get(
|
||||
'image', NotifyLunaSea.template_args['image']['default']))
|
||||
|
||||
# The 'to' makes it easier to use yaml configuration
|
||||
if 'to' in results['qsd'] and len(results['qsd']['to']):
|
||||
results['targets'] += \
|
||||
NotifyLunaSea.parse_list(results['qsd']['to'])
|
||||
|
||||
# Mode override
|
||||
if 'mode' in results['qsd'] and results['qsd']['mode']:
|
||||
results['mode'] = NotifyLunaSea.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'] = LunaSeaMode.PRIVATE \
|
||||
if ((is_hostname(results['host'])
|
||||
or is_ipaddr(results['host'])) and results['targets']) \
|
||||
else LunaSeaMode.CLOUD
|
||||
|
||||
if results['mode'] == LunaSeaMode.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
|
||||
# lunasea.app itself, something that starts wiht an non-alpha
|
||||
# numeric character:
|
||||
if not NotifyLunaSea.__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'] == LunaSeaMode.PRIVATE and \
|
||||
not (is_hostname(results['host'] or
|
||||
is_ipaddr(results['host']))):
|
||||
# Invalid Host for LunaSeaMode.PRIVATE
|
||||
return None
|
||||
|
||||
return results
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -26,24 +26,20 @@
|
|||
# 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 platform
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from .base import NotifyBase
|
||||
from ..common import NotifyImageSize
|
||||
from ..common import NotifyType
|
||||
from ..utils import parse_bool
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
# Default our global support flag
|
||||
NOTIFY_MACOSX_SUPPORT_ENABLED = False
|
||||
|
||||
|
||||
# TODO: The module will be easier to test without module-level code.
|
||||
if platform.system() == 'Darwin':
|
||||
# Check this is Mac OS X 10.8, or higher
|
||||
major, minor = platform.mac_ver()[0].split('.')[:2]
|
||||
|
@ -102,6 +98,7 @@ class NotifyMacOSX(NotifyBase):
|
|||
'/usr/local/bin/terminal-notifier',
|
||||
'/usr/bin/terminal-notifier',
|
||||
'/bin/terminal-notifier',
|
||||
'/opt/local/bin/terminal-notifier',
|
||||
)
|
||||
|
||||
# Define object templates
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -56,7 +56,7 @@
|
|||
#
|
||||
import requests
|
||||
from email.utils import formataddr
|
||||
from .NotifyBase import NotifyBase
|
||||
from .base import NotifyBase
|
||||
from ..common import NotifyType
|
||||
from ..common import NotifyFormat
|
||||
from ..utils import parse_emails
|
||||
|
@ -64,7 +64,7 @@ from ..utils import parse_bool
|
|||
from ..utils import is_email
|
||||
from ..utils import validate_regex
|
||||
from ..logger import logger
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..locale import gettext_lazy as _
|
||||
|
||||
# Provide some known codes Mailgun uses and what they translate to:
|
||||
# Based on https://documentation.mailgun.com/en/latest/api-intro.html#errors
|
|
@ -2,7 +2,7 @@
|
|||
# BSD 2-Clause License
|
||||
#
|
||||
# Apprise - Push Notification Library.
|
||||
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
|
@ -33,16 +33,16 @@ from json import dumps, loads
|
|||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..URLBase import PrivacyMode
|
||||
from .base import NotifyBase
|
||||
from ..url 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 ..utils import validate_regex
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..attachment.AttachBase import AttachBase
|
||||
from ..locale import gettext_lazy as _
|
||||
from ..attachment.base import AttachBase
|
||||
|
||||
# Accept:
|
||||
# - @username
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue