Merge branch 'release/3.32.0'

This commit is contained in:
JackDandy 2024-06-25 21:15:59 +01:00
commit 283a9258d5
324 changed files with 22244 additions and 11049 deletions

View file

@ -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

View file

@ -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 # &dagger;#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>

View file

@ -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

View file

@ -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>>&nbsp;#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>>&nbsp;#else#>#end if#% Rating > Votes</option>
<option value="by_rating_votes"#if 'by_rating_votes' in $saved_showsort_sortby#$selected>>&nbsp;#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#">
&nbsp;<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>&nbsp;</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;"

View file

@ -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

View file

@ -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,

View file

@ -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)

View file

@ -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'),

View file

@ -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',

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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):
"""

View file

@ -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

View file

@ -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',
]

View file

@ -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()

View file

@ -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):

View 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

View 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()

View file

@ -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:
"""

View file

@ -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',
]

View file

@ -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()):

View file

@ -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):

View file

@ -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

View file

@ -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):

View file

@ -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):

View file

@ -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:

View file

@ -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,
)

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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 ""

View file

@ -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

View file

@ -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
View 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

View 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]+)$')

View 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]+)$')

View 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]+)$')

View file

@ -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

View file

@ -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()])))

View file

@ -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
View 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

View file

@ -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

View file

@ -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

View file

@ -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'

View file

@ -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

View 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

View file

@ -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:

View file

@ -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

View file

@ -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']):

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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 = {

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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))

View file

@ -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']):

View file

@ -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):

View file

@ -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:

View file

@ -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

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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:

View 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

View file

@ -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

View 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

View file

@ -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

View file

@ -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):

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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):

View 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

View file

@ -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):

View file

@ -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 = {

View file

@ -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

View file

@ -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 = {

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View file

@ -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