Skip to content

Commit 1ba14a7

Browse files
authored
Added support for 46elks:// (#1438)
1 parent eea03d1 commit 1ba14a7

File tree

6 files changed

+528
-8
lines changed

6 files changed

+528
-8
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ SMS Notifications for the most part do not have a both a `title` and `body`. Th
152152

153153
| Notification Service | Service ID | Default Port | Example Syntax |
154154
| -------------------- | ---------- | ------------ | -------------- |
155+
| [46elks](https://github.com/caronc/apprise/wiki/Notify_46elks) | 46elks:// | (TCP) 443 | 46elks://user:password@FromPhoneNo<br/>46elks://user:password@FromPhoneNo/ToPhoneNo<br/>46elks://user:password@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
155156
| [Africas Talking](https://github.com/caronc/apprise/wiki/Notify_africas_talking) | atalk:// | (TCP) 443 | atalk://AppUser@ApiKey/ToPhoneNo<br/>atalk://AppUser@ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
156157
| [Automated Packet Reporting System (ARPS)](https://github.com/caronc/apprise/wiki/Notify_aprs) | aprs:// | (TCP) 10152 | aprs://user:pass@callsign<br/>aprs://user:pass@callsign1/callsign2/callsignN
157158
| [AWS SNS](https://github.com/caronc/apprise/wiki/Notify_sns) | sns:// | (TCP) 443 | sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo<br/>sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo1/+PhoneNo2/+PhoneNoN<br/>sns://AccessKeyID/AccessSecretKey/RegionName/Topic<br/>sns://AccessKeyID/AccessSecretKey/RegionName/Topic1/Topic2/TopicN

apprise/plugins/fortysixelks.py

Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
1+
# BSD 2-Clause License
2+
#
3+
# Apprise - Push Notification Library.
4+
# Copyright (c) 2025, Chris Caron <[email protected]>
5+
#
6+
# Redistribution and use in source and binary forms, with or without
7+
# modification, are permitted provided that the following conditions are met:
8+
#
9+
# 1. Redistributions of source code must retain the above copyright notice,
10+
# this list of conditions and the following disclaimer.
11+
#
12+
# 2. Redistributions in binary form must reproduce the above copyright notice,
13+
# this list of conditions and the following disclaimer in the documentation
14+
# and/or other materials provided with the distribution.
15+
#
16+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
19+
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
20+
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
21+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
22+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
23+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
24+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
25+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
26+
# POSSIBILITY OF SUCH DAMAGE.
27+
"""
28+
46elks SMS Notification Service.
29+
30+
Minimal URL formats (source ends up being target):
31+
- 46elks://user:pass@/+15551234567
32+
- 46elks://user:pass@/+15551234567/+46701234567
33+
- 46elks://user:pass@/+15551234567?from=Acme
34+
"""
35+
36+
from __future__ import annotations
37+
38+
from collections.abc import Iterable
39+
import re
40+
from typing import Any, Optional
41+
42+
import requests
43+
44+
from ..common import NotifyType
45+
from ..locale import gettext_lazy as _
46+
from ..url import PrivacyMode
47+
from ..utils.parse import (
48+
is_phone_no,
49+
parse_phone_no,
50+
)
51+
from .base import NotifyBase
52+
53+
54+
class Notify46Elks(NotifyBase):
55+
"""A wrapper for 46elks Notifications."""
56+
57+
# The default descriptive name associated with the Notification
58+
service_name = _("46elks")
59+
60+
# The services URL
61+
service_url = "https://46elks.com"
62+
63+
# The default secure protocol
64+
secure_protocol = ("46elks", "elks")
65+
66+
# A URL that takes you to the setup/help of the specific protocol
67+
setup_url = "https://github.com/caronc/apprise/wiki/Notify_46elks"
68+
69+
# 46elksAPI Request URLs
70+
notify_url = "https://api.46elks.com/a1/sms"
71+
72+
# The maximum allowable characters allowed in the title per message
73+
title_maxlen = 0
74+
75+
# The maximum allowable characters allowed in the body per message
76+
body_maxlen = 160
77+
78+
# Define object templates
79+
templates = (
80+
"{schema}://{user}:{password}@/{from_phone}",
81+
"{schema}://{user}:{password}@/{from_phone}/{targets}",
82+
)
83+
84+
# Define our template tokens
85+
template_tokens = dict(
86+
NotifyBase.template_tokens,
87+
**{
88+
"user": {
89+
"name": _("API Username"),
90+
"type": "string",
91+
"required": True,
92+
},
93+
"password": {
94+
"name": _("API Password"),
95+
"type": "string",
96+
"private": True,
97+
"required": True,
98+
},
99+
"from_phone": {
100+
"name": _("From Phone No"),
101+
"type": "string",
102+
"required": True,
103+
"map_to": "source",
104+
},
105+
"target_phone": {
106+
"name": _("Target Phone"),
107+
"type": "string",
108+
"map_to": "targets",
109+
},
110+
"targets": {
111+
"name": _("Targets"),
112+
"type": "list:string",
113+
},
114+
},
115+
)
116+
117+
# Define our template arguments
118+
template_args = dict(
119+
NotifyBase.template_args,
120+
**{
121+
"to": {
122+
"alias_of": "targets",
123+
},
124+
"from": {
125+
"alias_of": "from_phone",
126+
},
127+
},
128+
)
129+
130+
def __init__(
131+
self,
132+
targets: Optional[Iterable[str]] = None,
133+
source: Optional[str] = None,
134+
**kwargs: Any,
135+
) -> None:
136+
"""
137+
Initialise 46elks notifier.
138+
139+
:param targets: Iterable of phone numbers. E.164 is recommended.
140+
:param source: Optional source ID or E.164 number.
141+
"""
142+
super().__init__(**kwargs)
143+
144+
# Prepare our source
145+
self.source: Optional[str] = (source or "").strip() or None
146+
147+
if not self.password:
148+
msg = "No 46elks password was specified."
149+
self.logger.warning(msg)
150+
raise TypeError(msg)
151+
152+
elif not self.user:
153+
msg = "No 46elks user was specified."
154+
self.logger.warning(msg)
155+
raise TypeError(msg)
156+
157+
# Parse our targets
158+
self.targets = []
159+
160+
if not targets and is_phone_no(self.source):
161+
targets = [self.source]
162+
163+
for target in parse_phone_no(targets):
164+
# Validate targets and drop bad ones:
165+
result = is_phone_no(target)
166+
if not result:
167+
self.logger.warning(
168+
f"Dropped invalid phone # ({target}) specified.",
169+
)
170+
continue
171+
172+
# store valid phone number
173+
# Carry forward '+' if defined, otherwise do not...
174+
self.targets.append(
175+
("+" + result["full"])
176+
if target.lstrip()[0] == "+"
177+
else result["full"]
178+
)
179+
180+
def send(
181+
self,
182+
body: str,
183+
title: str = "",
184+
notify_type: NotifyType = NotifyType.INFO,
185+
**kwargs: Any,
186+
) -> bool:
187+
"""Perform 46elks Notification."""
188+
189+
if not self.targets:
190+
# There is no one to email; we're done
191+
self.logger.warning(
192+
"There are no 46elks recipients to notify"
193+
)
194+
return False
195+
196+
headers = {
197+
"User-Agent": self.app_id,
198+
}
199+
200+
# error tracking (used for function return)
201+
has_error = False
202+
203+
targets = list(self.targets)
204+
while targets:
205+
target = targets.pop(0)
206+
207+
# Prepare our payload
208+
payload = {
209+
"to": target,
210+
"from": self.source,
211+
"message": body,
212+
}
213+
214+
self.logger.debug(
215+
"46elks POST URL:"
216+
f" {self.notify_url} (cert_verify={self.verify_certificate!r})"
217+
)
218+
self.logger.debug(f"46elks Payload: {payload!s}")
219+
220+
# Always call throttle before any remote server i/o is made
221+
self.throttle()
222+
try:
223+
r = requests.post(
224+
self.notify_url,
225+
data=payload,
226+
headers=headers,
227+
auth=(self.user, self.password),
228+
verify=self.verify_certificate,
229+
timeout=self.request_timeout,
230+
)
231+
if r.status_code != requests.codes.ok:
232+
# We had a problem
233+
status_str = (
234+
Notify46Elks.http_response_code_lookup(
235+
r.status_code
236+
)
237+
)
238+
239+
self.logger.warning(
240+
"Failed to send 46elks notification to {}: "
241+
"{}{}error={}.".format(
242+
target,
243+
status_str,
244+
", " if status_str else "",
245+
r.status_code,
246+
)
247+
)
248+
249+
self.logger.debug(f"Response Details:\r\n{r.content}")
250+
251+
# Mark our failure
252+
has_error = True
253+
continue
254+
255+
else:
256+
self.logger.info(
257+
f"Sent 46elks notification to {target}."
258+
)
259+
260+
except requests.RequestException as e:
261+
self.logger.warning(
262+
"A Connection error occurred sending 46elks"
263+
f" notification to {target}."
264+
)
265+
self.logger.debug(f"Socket Exception: {e!s}")
266+
267+
# Mark our failure
268+
has_error = True
269+
continue
270+
271+
return not has_error
272+
273+
@property
274+
def url_identifier(self):
275+
"""Returns all of the identifiers that make this URL unique from
276+
another similar one.
277+
278+
Targets or end points should never be identified here.
279+
"""
280+
return (self.secure_protocol[0], self.user, self.password, self.source)
281+
282+
def url(self, privacy: bool = False, *args: Any, **kwargs: Any) -> str:
283+
"""Returns the URL built dynamically based on specified arguments."""
284+
285+
# Initialize our parameters
286+
params = self.url_parameters(privacy=privacy, *args, **kwargs)
287+
288+
# Apprise URL can be condensed and target can be eliminated if its
289+
# our source phone no
290+
targets = (
291+
[] if len(self.targets) == 1 and
292+
self.source in self.targets else self.targets)
293+
294+
return "{schema}://{user}:{pw}@{source}/{targets}?{params}".format(
295+
schema=self.secure_protocol[0],
296+
user=self.quote(self.user, safe=""),
297+
source=self.source if self.source else "",
298+
pw=self.pprint(
299+
self.password, privacy, mode=PrivacyMode.Secret, safe=""),
300+
targets="/".join(
301+
[Notify46Elks.quote(x, safe="+") for x in targets]
302+
),
303+
params=Notify46Elks.urlencode(params),
304+
)
305+
306+
def __len__(self):
307+
"""Returns the number of targets associated with this notification."""
308+
targets = len(self.targets)
309+
return targets if targets > 0 else 1
310+
311+
@staticmethod
312+
def parse_native_url(url):
313+
"""
314+
Support https://user:[email protected]/a1/sms?to=+15551234567&from=Acme
315+
"""
316+
317+
result = re.match(
318+
r"^https?://(?P<credentials>[^@]+)@"
319+
r"api\.46elks\.com/a1/sms/?"
320+
r"(?P<params>\?.+)$",
321+
url,
322+
re.I,
323+
)
324+
325+
if result:
326+
return Notify46Elks.parse_url(
327+
"{schema}://{credentials}@/{params}".format(
328+
schema=Notify46Elks.secure_protocol[0],
329+
credentials=result.group("credentials"),
330+
params=result.group("params"),
331+
)
332+
)
333+
334+
return None
335+
336+
@staticmethod
337+
def parse_url(url):
338+
"""Parses the URL and returns enough arguments that can allow us to re-
339+
instantiate this object."""
340+
results = NotifyBase.parse_url(url, verify_host=False)
341+
if not results:
342+
# We're done early as we couldn't load the results
343+
return results
344+
345+
# Prepare our targets
346+
results["targets"] = []
347+
348+
# The 'from' makes it easier to use yaml configuration
349+
if "from" in results["qsd"] and len(results["qsd"]["from"]):
350+
results["source"] = Notify46Elks.unquote(
351+
results["qsd"]["from"]
352+
)
353+
354+
elif results["host"]:
355+
results["source"] = Notify46Elks.unquote(results["host"])
356+
357+
# Store our remaining targets found on path
358+
results["targets"].extend(
359+
Notify46Elks.split_path(results["fullpath"])
360+
)
361+
362+
# Support the 'to' variable so that we can support targets this way too
363+
# The 'to' makes it easier to use yaml configuration
364+
if "to" in results["qsd"] and len(results["qsd"]["to"]):
365+
results["targets"] += Notify46Elks.parse_phone_no(
366+
results["qsd"]["to"]
367+
)
368+
369+
return results

packaging/redhat/python-apprise.spec

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@
5656
Apprise is a Python package that simplifies access to many popular \
5757
notification services. It supports sending alerts to platforms such as: \
5858
\
59-
`AfricasTalking`, `Apprise API`, `APRS`, `AWS SES`, `AWS SNS`, `Bark`, \
60-
`BlueSky`, `Burst SMS`, `BulkSMS`, `BulkVS`, `Chanify`, `Clickatell`, \
59+
`46elks`, `AfricasTalking`, `Apprise API`, `APRS`, `AWS SES`, `AWS SNS`,
60+
`Bark`, `BlueSky`, `Burst SMS`, `BulkSMS`, `BulkVS`, `Chanify`, `Clickatell`, \
6161
`ClickSend`, `DAPNET`, `DingTalk`, `Discord`, `E-Mail`, `Emby`, `FCM`, \
6262
`Feishu`, `Flock`, `Free Mobile`, `Google Chat`, `Gotify`, `Growl`, \
6363
`Guilded`, `Home Assistant`, `httpSMS`, `IFTTT`, `Join`, `Kavenegar`, `KODI`, \

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ dependencies = [
5151

5252
# Identifies all of the supported plugins
5353
keywords = [
54+
"46elks",
5455
"Africas Talking",
5556
"Alerts",
5657
"Apprise API",

0 commit comments

Comments
 (0)