pushraker 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pushraker/__init__.py +7 -0
- pushraker/component.py +3718 -0
- pushraker/discord.py +240 -0
- pushraker/email_html.py +121 -0
- pushraker/images.py +395 -0
- pushraker-0.1.0.dev0.dist-info/METADATA +49 -0
- pushraker-0.1.0.dev0.dist-info/RECORD +10 -0
- pushraker-0.1.0.dev0.dist-info/WHEEL +5 -0
- pushraker-0.1.0.dev0.dist-info/licenses/LICENSE +674 -0
- pushraker-0.1.0.dev0.dist-info/top_level.txt +1 -0
pushraker/discord.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
# SPDX-PackageHomePage: https://github.com/noon92/pushraker
|
|
3
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 @noon92
|
|
4
|
+
|
|
5
|
+
# Discord senders for push_notifications, registered as Apprise plugins so
|
|
6
|
+
# they get the same queues, retries and fallbacks as the other services:
|
|
7
|
+
#
|
|
8
|
+
# discordhook://<webhook id>/<webhook token>?name=U1&ping=<mention>
|
|
9
|
+
# A server channel, through a channel webhook.
|
|
10
|
+
# discorddm://<bot token>@<user id>
|
|
11
|
+
#
|
|
12
|
+
# silent=yes sends without a phone notification sound (Discord's @silent).
|
|
13
|
+
# A direct message from the user's own bot. Discord only lets a bot
|
|
14
|
+
# message someone it shares a server with.
|
|
15
|
+
#
|
|
16
|
+
# Each notification is one embed: the title, the text (Markdown), a colour
|
|
17
|
+
# from the notification type, and the image inside the embed.
|
|
18
|
+
#
|
|
19
|
+
# This file may be distributed under the terms of the GNU GPLv3 license.
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
from typing import Any, Dict, Optional, Tuple
|
|
26
|
+
|
|
27
|
+
import requests
|
|
28
|
+
from apprise.decorators import notify
|
|
29
|
+
|
|
30
|
+
API = "https://discord.com/api/v10"
|
|
31
|
+
USER_AGENT = ("DiscordBot (https://github.com/paxx12-snapmaker-u1/"
|
|
32
|
+
"SnapmakerU1-Extended-Firmware, 1)")
|
|
33
|
+
TIMEOUT = (10., 60.) # connect, read: same as push_notifications
|
|
34
|
+
# Embed bar colours, matching the spool pictures
|
|
35
|
+
COLOURS = {"info": 0x5F6269, "success": 0x24743A, "warning": 0xC46800,
|
|
36
|
+
"failure": 0xB02626}
|
|
37
|
+
TITLE_MAX = 256
|
|
38
|
+
TEXT_MAX = 4096
|
|
39
|
+
# Error codes: https://discord.com/developers/docs/topics/opcodes-and-status-codes
|
|
40
|
+
CANNOT_DM = 50007
|
|
41
|
+
UNKNOWN_WEBHOOK = 10015
|
|
42
|
+
SUPPRESS_NOTIFICATIONS = 1 << 12
|
|
43
|
+
|
|
44
|
+
_dm_channels: Dict[Tuple[str, str], str] = {}
|
|
45
|
+
log = logging.getLogger("apprise")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def escape(text: str) -> str:
|
|
49
|
+
"""Markdown special characters shown as typed (file names, spool
|
|
50
|
+
names)."""
|
|
51
|
+
for c in "\\*_~`|>[]#":
|
|
52
|
+
text = text.replace(c, "\\" + c)
|
|
53
|
+
return text
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def link(name: str, url: str) -> str:
|
|
57
|
+
return f"[{escape(name)}]({url.replace(')', '%29').replace(' ', '%20')})"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _why(e: Exception) -> str:
|
|
61
|
+
"""A failed request, without its URL: requests puts the URL (which
|
|
62
|
+
holds the webhook token) in the exception's text, so only the kind of
|
|
63
|
+
failure is logged."""
|
|
64
|
+
if isinstance(e, requests.Timeout):
|
|
65
|
+
return "timed out"
|
|
66
|
+
if isinstance(e, requests.ConnectionError):
|
|
67
|
+
return "connection failed"
|
|
68
|
+
return type(e).__name__
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _error(r: requests.Response) -> Tuple[int, str]:
|
|
72
|
+
try:
|
|
73
|
+
data = r.json()
|
|
74
|
+
return int(data.get("code") or 0), str(data.get("message") or "")
|
|
75
|
+
except (ValueError, AttributeError, TypeError):
|
|
76
|
+
return 0, (r.text or "")[:200]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _post(url: str, headers: Dict[str, str], payload: Dict[str, Any],
|
|
80
|
+
attach: Any) -> requests.Response:
|
|
81
|
+
"""Post a message, with the first attachment shown inside the embed."""
|
|
82
|
+
path = None
|
|
83
|
+
for a in attach or []:
|
|
84
|
+
path = a.path if hasattr(a, "path") else str(a)
|
|
85
|
+
if path and os.path.exists(path):
|
|
86
|
+
break
|
|
87
|
+
path = None
|
|
88
|
+
headers = dict(headers, **{"User-Agent": USER_AGENT})
|
|
89
|
+
if not path:
|
|
90
|
+
return requests.post(url, headers=headers, json=payload,
|
|
91
|
+
timeout=TIMEOUT)
|
|
92
|
+
name = "image" + (os.path.splitext(path)[1].lower() or ".jpg")
|
|
93
|
+
payload["embeds"][0]["image"] = {"url": f"attachment://{name}"}
|
|
94
|
+
payload["attachments"] = [{"id": 0, "filename": name}]
|
|
95
|
+
with open(path, "rb") as f:
|
|
96
|
+
return requests.post(
|
|
97
|
+
url, headers=headers, timeout=TIMEOUT,
|
|
98
|
+
data={"payload_json": json.dumps(payload)},
|
|
99
|
+
files={"files[0]": (name, f, "image/jpeg")})
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _payload(body: str, title: str, notify_type: Any, qsd: Dict[str, str],
|
|
103
|
+
content: str = "") -> Dict[str, Any]:
|
|
104
|
+
embed = {"title": title[:TITLE_MAX], "description": body[:TEXT_MAX],
|
|
105
|
+
"color": COLOURS.get(str(notify_type), COLOURS["info"])}
|
|
106
|
+
payload: Dict[str, Any] = {"embeds": [embed]}
|
|
107
|
+
if qsd.get("silent") == "yes":
|
|
108
|
+
payload["flags"] = SUPPRESS_NOTIFICATIONS
|
|
109
|
+
if content:
|
|
110
|
+
payload["content"] = content
|
|
111
|
+
# Only the mention in the settings may ping
|
|
112
|
+
payload["allowed_mentions"] = {"parse": ["everyone", "roles",
|
|
113
|
+
"users"]}
|
|
114
|
+
else:
|
|
115
|
+
payload["allowed_mentions"] = {"parse": []}
|
|
116
|
+
return payload
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def mention(value: str) -> str:
|
|
120
|
+
"""Settings value to a Discord mention: a user ID, role:ID,
|
|
121
|
+
everyone or here."""
|
|
122
|
+
v = (value or "").strip()
|
|
123
|
+
if v in ("everyone", "here"):
|
|
124
|
+
return "@" + v
|
|
125
|
+
if v.startswith("role:"):
|
|
126
|
+
return f"<@&{v[5:]}>"
|
|
127
|
+
return f"<@{v}>" if v else ""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@notify(on="discordhook", name="Discord channel")
|
|
131
|
+
def send_webhook(body: str, title: str, notify_type: Any, attach: Any,
|
|
132
|
+
meta: Dict[str, Any], *args: Any, **kwargs: Any) -> bool:
|
|
133
|
+
hook_id = meta.get("host") or ""
|
|
134
|
+
token = (meta.get("fullpath") or meta.get("path") or "").strip("/")
|
|
135
|
+
qsd = meta.get("qsd") or {}
|
|
136
|
+
payload = _payload(body, title, notify_type, qsd,
|
|
137
|
+
mention(qsd.get("ping", "")))
|
|
138
|
+
if qsd.get("name"):
|
|
139
|
+
payload["username"] = qsd["name"][:80]
|
|
140
|
+
try:
|
|
141
|
+
r = _post(f"{API}/webhooks/{hook_id}/{token}?wait=true", {},
|
|
142
|
+
payload, attach)
|
|
143
|
+
except requests.RequestException as e:
|
|
144
|
+
log.warning("Discord: could not connect (%s)", _why(e))
|
|
145
|
+
return False
|
|
146
|
+
if r.status_code in (200, 204):
|
|
147
|
+
return True
|
|
148
|
+
code, msg = _error(r)
|
|
149
|
+
if code == UNKNOWN_WEBHOOK or r.status_code == 401:
|
|
150
|
+
log.warning("Discord: the webhook no longer exists or the URL is "
|
|
151
|
+
"wrong; copy it again from the channel's Integrations "
|
|
152
|
+
"settings")
|
|
153
|
+
else:
|
|
154
|
+
log.warning("Discord: HTTP %d, %s", r.status_code, msg or "error")
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def bot_info(token: str) -> Tuple[Optional[Dict[str, Any]], str]:
|
|
159
|
+
"""The bot's application (id, owner), or (None, reason)."""
|
|
160
|
+
try:
|
|
161
|
+
r = requests.get(f"{API}/applications/@me", timeout=TIMEOUT,
|
|
162
|
+
headers={"Authorization": f"Bot {token}",
|
|
163
|
+
"User-Agent": USER_AGENT})
|
|
164
|
+
except requests.RequestException as e:
|
|
165
|
+
return None, f"could not connect to Discord ({_why(e)})"
|
|
166
|
+
if r.status_code == 401:
|
|
167
|
+
return None, "Discord doesn't accept this bot token"
|
|
168
|
+
if r.status_code != 200:
|
|
169
|
+
return None, "Discord: HTTP %d, %s" % (r.status_code, _error(r)[1])
|
|
170
|
+
return r.json(), ""
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def owner_id(app: Dict[str, Any]) -> str:
|
|
174
|
+
"""The user who made the bot (a team's owner, for a team's bot)."""
|
|
175
|
+
team = app.get("team") or {}
|
|
176
|
+
if team.get("owner_user_id"):
|
|
177
|
+
return str(team["owner_user_id"])
|
|
178
|
+
return str((app.get("owner") or {}).get("id") or "")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def bot_servers(token: str) -> Optional[int]:
|
|
182
|
+
"""How many servers the bot is in (None if unknown)."""
|
|
183
|
+
try:
|
|
184
|
+
r = requests.get(f"{API}/users/@me/guilds", timeout=TIMEOUT,
|
|
185
|
+
headers={"Authorization": f"Bot {token}",
|
|
186
|
+
"User-Agent": USER_AGENT})
|
|
187
|
+
return len(r.json()) if r.status_code == 200 else None
|
|
188
|
+
except (requests.RequestException, ValueError, TypeError):
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def invite_url(app_id: str) -> str:
|
|
193
|
+
return (f"https://discord.com/oauth2/authorize?client_id={app_id}"
|
|
194
|
+
"&scope=bot&permissions=0")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@notify(on="discorddm", name="Discord DM")
|
|
198
|
+
def send_dm(body: str, title: str, notify_type: Any, attach: Any,
|
|
199
|
+
meta: Dict[str, Any], *args: Any, **kwargs: Any) -> bool:
|
|
200
|
+
token = meta.get("user") or ""
|
|
201
|
+
user_id = meta.get("host") or ""
|
|
202
|
+
auth = {"Authorization": f"Bot {token}"}
|
|
203
|
+
try:
|
|
204
|
+
channel = _dm_channels.get((token, user_id))
|
|
205
|
+
if channel is None:
|
|
206
|
+
r = requests.post(f"{API}/users/@me/channels", timeout=TIMEOUT,
|
|
207
|
+
headers=dict(auth, **{"User-Agent": USER_AGENT}),
|
|
208
|
+
json={"recipient_id": user_id})
|
|
209
|
+
if r.status_code != 200:
|
|
210
|
+
code, msg = _error(r)
|
|
211
|
+
if r.status_code == 401:
|
|
212
|
+
log.warning("Discord: the bot token is wrong or was "
|
|
213
|
+
"reset; copy a new one from the Developer "
|
|
214
|
+
"Portal (Bot > Reset Token)")
|
|
215
|
+
else:
|
|
216
|
+
log.warning("Discord: could not open the DM: HTTP %d, %s",
|
|
217
|
+
r.status_code, msg or "error")
|
|
218
|
+
return False
|
|
219
|
+
channel = str(r.json()["id"])
|
|
220
|
+
_dm_channels[(token, user_id)] = channel
|
|
221
|
+
r = _post(f"{API}/channels/{channel}/messages", auth,
|
|
222
|
+
_payload(body, title, notify_type, meta.get("qsd") or {}),
|
|
223
|
+
attach)
|
|
224
|
+
except (requests.RequestException, KeyError, ValueError) as e:
|
|
225
|
+
log.warning("Discord: could not send (%s)", _why(e))
|
|
226
|
+
return False
|
|
227
|
+
if r.status_code == 200:
|
|
228
|
+
return True
|
|
229
|
+
code, msg = _error(r)
|
|
230
|
+
if code == CANNOT_DM:
|
|
231
|
+
app, _ = bot_info(token)
|
|
232
|
+
how = (f" Add the bot to a server you are in: "
|
|
233
|
+
f"{invite_url(app['id'])}" if app else "")
|
|
234
|
+
log.warning("Discord: the bot can't message you. Discord only "
|
|
235
|
+
"allows it when you and the bot share a server, and "
|
|
236
|
+
"your Privacy settings allow DMs from members of that "
|
|
237
|
+
"server.%s", how)
|
|
238
|
+
else:
|
|
239
|
+
log.warning("Discord: HTTP %d, %s", r.status_code, msg or "error")
|
|
240
|
+
return False
|
pushraker/email_html.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
# SPDX-PackageHomePage: https://github.com/noon92/pushraker
|
|
3
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026 @noon92
|
|
4
|
+
|
|
5
|
+
# HTML emails for push_notifications (and the Firmware Config test): a card
|
|
6
|
+
# with a colour bar for the kind of notification, the title, the text, the
|
|
7
|
+
# image inline, and the links as buttons. Inline styles and tables only, so
|
|
8
|
+
# it looks the same in Gmail, Outlook and phone mail apps. Apprise adds a
|
|
9
|
+
# plain-text version for mail apps that don't show HTML.
|
|
10
|
+
#
|
|
11
|
+
# This file may be distributed under the terms of the GNU GPLv3 license.
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
import datetime
|
|
15
|
+
import html
|
|
16
|
+
from typing import List, Optional, Sequence, Tuple
|
|
17
|
+
|
|
18
|
+
# Bar colour per Apprise notification type (as in the Discord embeds)
|
|
19
|
+
COLOURS = {"info": "#3D7BD9", "success": "#24743A", "warning": "#C46800",
|
|
20
|
+
"failure": "#B02626"}
|
|
21
|
+
FONT = ("-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,"
|
|
22
|
+
"Arial,sans-serif")
|
|
23
|
+
IMAGE_MARK = ("<!--image-->", "<!--/image-->")
|
|
24
|
+
NO_IMAGE = ('<p style="margin:0;color:#8a8f98;font-size:13px">'
|
|
25
|
+
'(image could not be attached)</p>')
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _esc(text: str) -> str:
|
|
29
|
+
return html.escape(text, quote=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _paragraphs(body: str) -> str:
|
|
33
|
+
"""Blank lines separate paragraphs; the first line of each is bold
|
|
34
|
+
(the file name, or the toolhead in filament notifications)."""
|
|
35
|
+
out = []
|
|
36
|
+
for block in (b for b in body.strip().split("\n\n") if b.strip()):
|
|
37
|
+
lines = [_esc(x) for x in block.strip().split("\n")]
|
|
38
|
+
if len(lines) > 1:
|
|
39
|
+
lines[0] = f'<b style="color:#202226">{lines[0]}</b>'
|
|
40
|
+
out.append('<p style="margin:0 0 12px 0">' + "<br>".join(lines)
|
|
41
|
+
+ "</p>")
|
|
42
|
+
return "".join(out)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _button(title: str, url: str, primary: bool) -> str:
|
|
46
|
+
bg, fg = ("#202226", "#ffffff") if primary else ("#eceef1", "#202226")
|
|
47
|
+
return (f'<a href="{_esc(url)}" style="display:inline-block;'
|
|
48
|
+
f'background:{bg};color:{fg};text-decoration:none;'
|
|
49
|
+
f'font-weight:600;font-size:14px;padding:9px 16px;'
|
|
50
|
+
f'border-radius:8px;margin:0 8px 8px 0">{_esc(title)}</a>')
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def render(title: str, body: str, links: Sequence[Tuple[str, str]],
|
|
54
|
+
kind: str = "info", image: Optional[str] = None,
|
|
55
|
+
when: Optional[datetime.datetime] = None,
|
|
56
|
+
sender: str = "") -> str:
|
|
57
|
+
"""The email's HTML. `image` is the attachment's file name (shown
|
|
58
|
+
inline through Apprise's cid: support), `when` the time shown at the
|
|
59
|
+
top and bottom (it also keeps Gmail from hiding repeated emails in a
|
|
60
|
+
thread)."""
|
|
61
|
+
when = when or datetime.datetime.now()
|
|
62
|
+
colour = COLOURS.get(str(kind), COLOURS["info"])
|
|
63
|
+
img = ""
|
|
64
|
+
if image:
|
|
65
|
+
img = (IMAGE_MARK[0] +
|
|
66
|
+
f'<tr><td style="padding:4px 24px 8px 24px">'
|
|
67
|
+
f'<img src="cid:{_esc(image)}" alt="" width="552" '
|
|
68
|
+
f'style="display:block;width:100%;max-width:552px;height:auto;'
|
|
69
|
+
f'border-radius:8px;border:1px solid #e3e5e8"></td></tr>'
|
|
70
|
+
+ IMAGE_MARK[1])
|
|
71
|
+
buttons = " ".join(_button(t, u, n == 0) for n, (t, u) in enumerate(links))
|
|
72
|
+
if buttons:
|
|
73
|
+
buttons = (f'<tr><td style="padding:12px 24px 8px 24px">{buttons}'
|
|
74
|
+
'</td></tr>')
|
|
75
|
+
footer = f"Sent {when:%Y-%m-%d %H:%M:%S}"
|
|
76
|
+
if sender:
|
|
77
|
+
footer += f" by {_esc(sender)}"
|
|
78
|
+
return (
|
|
79
|
+
'<!doctype html><html><head><meta charset="utf-8">'
|
|
80
|
+
'<meta name="viewport" content="width=device-width">'
|
|
81
|
+
'<meta name="color-scheme" content="light"></head>'
|
|
82
|
+
f'<body style="margin:0;padding:0;background:#f2f3f5">'
|
|
83
|
+
f'<table role="presentation" width="100%" cellpadding="0" '
|
|
84
|
+
f'cellspacing="0" style="background:#f2f3f5"><tr><td align="center" '
|
|
85
|
+
f'style="padding:24px 12px">'
|
|
86
|
+
f'<table role="presentation" width="100%" cellpadding="0" '
|
|
87
|
+
f'cellspacing="0" style="max-width:600px;background:#ffffff;'
|
|
88
|
+
f'border:1px solid #e3e5e8;border-radius:12px;font-family:{FONT};'
|
|
89
|
+
f'border-collapse:separate;overflow:hidden">'
|
|
90
|
+
f'<tr><td style="height:6px;line-height:6px;font-size:0;'
|
|
91
|
+
f'background:{colour};border-radius:12px 12px 0 0"> </td></tr>'
|
|
92
|
+
f'<tr><td style="padding:18px 24px 0 24px;font-size:12px;'
|
|
93
|
+
f'color:#8a8f98;letter-spacing:.02em">'
|
|
94
|
+
f'{when:%a %d %b %Y · %H:%M}</td></tr>'
|
|
95
|
+
f'<tr><td style="padding:4px 24px 12px 24px;font-size:21px;'
|
|
96
|
+
f'line-height:1.3;font-weight:700;color:#202226">{_esc(title)}'
|
|
97
|
+
f'</td></tr>'
|
|
98
|
+
f'<tr><td style="padding:0 24px 4px 24px;font-size:15px;'
|
|
99
|
+
f'line-height:1.5;color:#3a3d42">{_paragraphs(body)}</td></tr>'
|
|
100
|
+
f'{img}{buttons}'
|
|
101
|
+
f'<tr><td style="padding:14px 24px 18px 24px;font-size:12px;'
|
|
102
|
+
f'color:#8a8f98;border-top:1px solid #eceef1">{footer}</td></tr>'
|
|
103
|
+
'</table></td></tr></table></body></html>')
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def without_image(page: str) -> str:
|
|
107
|
+
"""The page with the image replaced by a note (the last retry is sent
|
|
108
|
+
without the image)."""
|
|
109
|
+
start, end = IMAGE_MARK
|
|
110
|
+
a, b = page.find(start), page.find(end)
|
|
111
|
+
if a < 0 or b < 0:
|
|
112
|
+
return page
|
|
113
|
+
note = f'<tr><td style="padding:4px 24px 8px 24px">{NO_IMAGE}</td></tr>'
|
|
114
|
+
return page[:a] + note + page[b + len(end):]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def is_page(text: str) -> bool:
|
|
118
|
+
return text.startswith("<!doctype html>")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
__all__: List[str] = ["render", "without_image", "is_page", "COLOURS"]
|