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/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
@@ -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">&nbsp;</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"]