-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathnotifications.py
More file actions
511 lines (447 loc) · 26.8 KB
/
Copy pathnotifications.py
File metadata and controls
511 lines (447 loc) · 26.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
"""Proactive admin notifications to Telegram and/or Discord.
The panel already lets you configure a game's own LinuxGSM alerts; this is the panel telling YOU, the
admin, when something needs attention — a server dropped, a host went unreachable, a backup failed, a
super admin signed in, an IP was banned, a disk is filling up.
Best-effort and non-blocking: a send happens on a background thread and a failure is logged and
swallowed, never propagated to the caller (an alert must never break the action that triggered it).
Secrets (the bot token, the webhook URL) are Fernet-encrypted at rest via config.encrypt_secret.
"""
import json
import logging
import re
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from config import load_config, update_config, encrypt_secret, decrypt_secret
_log = logging.getLogger("notifications")
# Events an admin can toggle, in display order: key -> (label, default_on).
EVENTS = {
"server_down": ("A game server goes offline unexpectedly", True),
"server_up": ("A game server comes back online", False),
"server_empty": ("A server you flagged has emptied (per-server, set on its page)", True),
"server_full": ("A game server hits its player cap", False),
"server_peak": ("A game server sets a new player-count record", False),
"remote_unreachable": ("A remote host becomes unreachable", True),
"remote_recovered": ("A remote host comes back", True),
"high_load": ("A host's CPU or memory is sustained high", True),
"disk_low": ("A host's disk is running low", True),
"auto_reboot": ("A host auto-reboots once empty (reboot-when-empty)", True),
"backup_failed": ("A backup fails", True),
"update_available": ("A panel update is available", True),
"cert_expiring": ("The panel's TLS certificate is expiring soon", True),
"admin_login": ("A super admin signs in", True),
"admin_bruteforce": ("A super admin account is being brute-forced", True),
"account_change": ("A user is created or a group's permissions change", True),
"ip_banned": ("fail2ban bans an IP on the panel login", False),
"ban_spike": ("A burst of fail2ban bans (attack wave)", True),
}
# A Discord webhook MUST live on Discord — never let an admin-set (or tampered) URL become an SSRF
# probe into internal services. Telegram uses the fixed api.telegram.org host, so it needs no such
# host check, but its token is format-validated so it can't rewrite the request path.
# A Discord webhook is parsed into its <id>/<token> and the request URL is then rebuilt from a
# CONSTANT host, so the host the panel connects to is never taken from user input (no SSRF). The id
# and token are charset-bounded, so the path can't traverse either.
_DISCORD_WEBHOOK_RE = re.compile(
r"^https://(?:ptb\.|canary\.)?discord(?:app)?\.com/api/webhooks/(\d{5,25})/([\w-]{1,120})$")
_TG_TOKEN_RE = re.compile(r"^(\d{5,}):([A-Za-z0-9_-]{20,})$") # (bot id):(secret) — captured for the URL rebuild
# A Discord *bot* token (for the Gateway command bot). It only ever goes into an `Authorization: Bot`
# HTTP header, never a URL — the charset here forbids whitespace/control chars so it can't inject a
# header, and it's deliberately loose on the internal `.`-separated shape so future token formats still
# validate. A Discord channel id is a snowflake (digits only) and lands only in a charset-checked path.
_DISCORD_BOT_TOKEN_RE = re.compile(r"^[A-Za-z0-9_.\-]{40,120}$")
_DISCORD_CHANNEL_RE = re.compile(r"^\d{5,25}$")
# ── config read/write ──────────────────────────────────────────
def _cfg():
return load_config().get("notifications") or {}
def event_enabled(cfg, key):
events = cfg.get("events") or {}
return bool(events.get(key, EVENTS.get(key, ("", False))[1]))
_DEFAULT_THRESHOLDS = {"disk_pct": 90, "load_pct": 200, "mem_pct": 90, "load_mins": 5}
# Per-key clamp ranges. disk % and mem % are real percentages (<=99). CPU 'load' is the 1-minute load
# average per core x100, so it legitimately exceeds 100% (100% = one core fully busy) and gets a much
# higher ceiling. load_mins is how long CPU/RAM must STAY over the line before it pages you (minutes).
_THRESHOLD_BOUNDS = {"disk_pct": (50, 99), "load_pct": (50, 800), "mem_pct": (50, 99), "load_mins": (1, 120)}
def get_thresholds():
"""Configured alert thresholds, each clamped to its own range, falling back to the defaults:
disk % and mem % (50-99), CPU load-average per-core % (50-800, so it can be >100), and load_mins
— the minutes CPU/RAM must stay high before alerting. The monitor reads these for disk_low /
high_load."""
t = _cfg().get("thresholds") or {}
out = {}
for k, dflt in _DEFAULT_THRESHOLDS.items():
lo, hi = _THRESHOLD_BOUNDS[k]
try:
out[k] = min(hi, max(lo, int(t.get(k, dflt))))
except (TypeError, ValueError):
out[k] = dflt
return out
def settings_for_form():
"""Current settings with secrets masked (so the token/webhook are never re-sent to the browser)."""
cfg = _cfg()
tg = cfg.get("telegram") or {}
dc = cfg.get("discord") or {}
return {
"telegram": {"enabled": bool(tg.get("enabled")), "chat_id": tg.get("chat_id") or "",
"has_token": bool(tg.get("token")), "accept_commands": bool(tg.get("accept_commands"))},
"discord": {"enabled": bool(dc.get("enabled")), "has_webhook": bool(dc.get("webhook")),
"has_bot_token": bool(dc.get("bot_token")), "channel_id": dc.get("channel_id") or "",
"accept_commands": bool(dc.get("accept_commands"))},
"events": {k: event_enabled(cfg, k) for k in EVENTS},
"thresholds": get_thresholds(),
}
def save_settings(*, telegram, discord, events, thresholds=None):
"""Persist settings, encrypting secrets. `telegram`/`discord` secrets that come in as None mean
'keep the stored value' (the form never round-trips the real secret back). There is no global
master switch — a channel's own enable toggle is what turns its alerts on/off."""
cur = _cfg()
cur_tg = cur.get("telegram") or {}
cur_dc = cur.get("discord") or {}
tg_token = cur_tg.get("token") if telegram.get("token") is None else encrypt_secret(telegram["token"])
dc_webhook = cur_dc.get("webhook") if discord.get("webhook") is None else encrypt_secret(discord["webhook"])
dc_bot_token = cur_dc.get("bot_token") if discord.get("bot_token") is None \
else encrypt_secret(discord["bot_token"])
th = get_thresholds() # start from current/defaults; only overwrite fields that were submitted
for k in _DEFAULT_THRESHOLDS:
if thresholds and thresholds.get(k) not in (None, ""):
lo, hi = _THRESHOLD_BOUNDS[k]
try:
th[k] = min(hi, max(lo, int(thresholds[k])))
except (TypeError, ValueError):
_log.debug("ignoring non-numeric threshold %r; keeping %r", k, th[k])
notif = {
"telegram": {"enabled": bool(telegram.get("enabled")),
"chat_id": (telegram.get("chat_id") or "").strip()[:64], "token": tg_token or "",
"accept_commands": bool(telegram.get("accept_commands"))},
"discord": {"enabled": bool(discord.get("enabled")), "webhook": dc_webhook or "",
"bot_token": dc_bot_token or "",
"channel_id": (discord.get("channel_id") or "").strip()[:32],
"accept_commands": bool(discord.get("accept_commands"))},
"events": {k: bool(events.get(k, EVENTS[k][1])) for k in EVENTS},
"thresholds": th,
}
# update_config: this can race with the Telegram poller thread's pending-update write.
update_config(lambda cfg: cfg.update({"notifications": notif}))
def _drop_master_switch(notif):
"""Pure, in-place migration of a notifications-config dict: the global 'enabled' master switch
was removed. If it was explicitly OFF that meant 'no notifications' — preserve that by disabling
both channels (so dropping the gate can't start sending), then remove the key. Returns True if
the old key was present (i.e. something changed)."""
if not isinstance(notif, dict) or "enabled" not in notif:
return False
if notif.get("enabled") is False:
for ch in ("telegram", "discord"):
if isinstance(notif.get(ch), dict):
notif[ch]["enabled"] = False
notif.pop("enabled", None)
return True
def migrate_master_switch():
"""One-time on startup: strip the removed global 'enabled' master switch from the stored config
(preserving a muted state via the channel toggles). No-op once the key is gone."""
if "enabled" not in _cfg():
return
update_config(lambda cfg: _drop_master_switch(cfg.get("notifications")) if isinstance(cfg.get("notifications"), dict) else None)
# ── senders ────────────────────────────────────────────────────
# Every request URL the panel builds uses one of these CONSTANT-host prefixes (Telegram's is a fixed
# literal; Discord webhook + bot-API URLs are rebuilt onto the discord.com host below). _post re-checks
# the URL against them right before the request as an SSRF barrier — no user/admin-supplied value
# decides the host. The Discord prefix covers both /api/webhooks/… (alerts) and /api/v10/channels/…
# (the command bot's replies); both are on the same constant host with charset-checked path parts.
_ALLOWED_PREFIXES = ("https://api.telegram.org/", "https://discord.com/api/")
def _discord_api_url(webhook):
"""Canonical https://discord.com/api/webhooks/<id>/<token> rebuilt from a validated webhook URL,
or None if it isn't one. The host is a constant literal and the id/token are charset-checked, so
nothing user-supplied controls where the request goes."""
m = _DISCORD_WEBHOOK_RE.match(webhook or "")
return "https://discord.com/api/webhooks/%s/%s" % (m.group(1), m.group(2)) if m else None
def _valid_discord_webhook(url):
return _DISCORD_WEBHOOK_RE.match(url or "") is not None
# The only Bot API methods the panel calls — pinning `method` to this set makes the URL path
# provably not user-controlled (add here to use a new one).
_TG_METHODS = ("sendMessage", "getUpdates", "setMyCommands")
def _tg_api_url(token, method):
"""A Telegram Bot API URL on the CONSTANT api.telegram.org host, with the bot token REBUILT from
its regex-captured id/secret groups and `method` restricted to _TG_METHODS — so no request-tainted
value reaches the request path. Same shape as the (un-flagged) Discord builder: token + fixed
method only. A query string is a separate concern appended by the one caller that needs it — it is
deliberately NOT a parameter here, so this function's return can't be tainted by one. None if the
token is malformed or the method is unknown."""
m = _TG_TOKEN_RE.match(token or "")
if not m or method not in _TG_METHODS:
return None
return "https://api.telegram.org/bot%s:%s/%s" % (m.group(1), m.group(2), method)
def _post(url, data, headers):
"""POST to a validated https URL. Returns (ok, reason): ok is True on a 2xx. `reason` is a FIXED
word describing the outcome — 'sent' / 'rejected' (the provider answered with an error status) /
'unreachable' (couldn't connect) / 'blocked' (host not allow-listed). It carries no data read
back from the response, so this can never become an SSRF exfiltration sink. Never raises."""
# SSRF barrier at the sink: the URL must start with one of our known-provider prefixes, so a
# user/admin-supplied URL can never make this request hit an internal or arbitrary host. Every
# caller builds `url` on a CONSTANT host with the id/token rebuilt from regex-captured groups
# (_tg_api_url / _discord_api_url), so no request-tainted value reaches the host OR the path.
if not (url or "").startswith(_ALLOWED_PREFIXES):
return False, "blocked"
req = urllib.request.Request(url, data=data, method="POST",
headers={"User-Agent": "linuxgsm-panel", **headers})
try:
with urllib.request.urlopen(req, timeout=8) as resp: # nosec B310 - https, host-allowlisted
return (200 <= resp.getcode() < 300), "sent"
except urllib.error.HTTPError: # the provider answered with a 4xx/5xx
return False, "rejected"
except (urllib.error.URLError, OSError, ValueError):
_log.debug("notification POST failed", exc_info=True)
return False, "unreachable"
def send_telegram(token, chat_id, text):
"""Send a Telegram message. Returns (ok, detail). The token is format-validated so it can't
rewrite the request path; chat_id + text are urlencoded into the body."""
url = _tg_api_url(token, "sendMessage")
if not url:
return False, "the bot token is missing or malformed"
if not chat_id:
return False, "the chat ID is missing"
body = urllib.parse.urlencode({"chat_id": chat_id, "text": text[:4000],
"disable_web_page_preview": "true"}).encode()
ok, reason = _post(url, body, {"Content-Type": "application/x-www-form-urlencoded"})
if ok:
return True, ""
if reason == "unreachable":
return False, "couldn't reach api.telegram.org — check the host's outbound network / firewall."
return False, ("Telegram rejected it — the bot token or chat ID is wrong, or you haven't messaged "
"the bot yet. Re-copy the token from @BotFather, use your numeric ID from "
"@userinfobot, and press Start in the bot's chat.")
def telegram_get_updates(token, offset=None, timeout=25):
"""Long-poll Telegram for incoming messages (bot command input). Returns a list of update dicts
(possibly empty) or None on error/timeout/conflict. SSRF-safe: the URL is built by _tg_api_url on
the constant api.telegram.org host with the token rebuilt from its regex-captured groups, so
nothing user-supplied reaches the request path — only the JSON `result` array is read back.
Never raises."""
params = {"timeout": int(timeout)}
if offset is not None:
params["offset"] = int(offset)
url = _tg_api_url(token, "getUpdates")
if not url:
return None
url += "?" + urllib.parse.urlencode(params) # only int-coerced params; kept out of _tg_api_url
req = urllib.request.Request(url, headers={"User-Agent": "linuxgsm-panel"})
try:
with urllib.request.urlopen(req, timeout=timeout + 10) as resp: # nosec B310 - https, host-literal
data = json.loads(resp.read(2_000_000).decode("utf-8", "replace"))
return (data.get("result") or []) if data.get("ok") else None
except (urllib.error.URLError, OSError, ValueError):
_log.debug("telegram getUpdates failed", exc_info=True)
return None
# The bot's command menu — what Telegram shows when you type '/'. Keep in sync with the commands
# _handle_telegram_command actually handles.
TG_COMMANDS = [
("status", "Panel version + server counts"),
("servers", "List servers with player counts"),
("hosts", "List hosts and their status"),
("players", "Who's on a server: /players <name>"),
("start", "Start a server: /start <name>"),
("stop", "Stop a server: /stop <name>"),
("restart", "Restart a server: /restart <name>"),
("update", "Update the panel to the latest version"),
("help", "Show the command list"),
]
def telegram_set_commands(token, clear=False):
"""Register the bot's command list with Telegram (setMyCommands) so typing '/' pops the command
menu — or clear it when commands are turned off. Best-effort; returns True on success. SSRF-safe:
the URL is built by _tg_api_url (constant host, token rebuilt from its regex groups)."""
url = _tg_api_url(token, "setMyCommands")
if not url:
return False
cmds = [] if clear else [{"command": c, "description": d} for c, d in TG_COMMANDS]
body = json.dumps({"commands": cmds}).encode()
ok, _reason = _post(url, body, {"Content-Type": "application/json"})
return ok
def send_discord(webhook, text):
"""Send a Discord webhook message. Returns (ok, detail). The URL is rebuilt onto a constant host
from the validated webhook id/token, so the request can only ever go to Discord."""
url = _discord_api_url(webhook)
if not url:
return False, "that isn't a valid discord.com webhook URL"
ok, reason = _post(url, json.dumps({"content": text[:1900]}).encode(),
{"Content-Type": "application/json"})
if ok:
return True, ""
if reason == "unreachable":
return False, "couldn't reach discord.com — check the host's outbound network."
return False, "Discord rejected it — the webhook URL is wrong or was deleted."
# ── Discord command bot (Gateway) ──────────────────────────────
# Discord offers no outbound long-poll like Telegram's getUpdates, so the two-way command bot keeps a
# persistent Gateway WebSocket open (in a background greenlet) and posts replies over the bot REST API.
# The Gateway host is a fixed literal; replies go to /channels/<id>/messages on the constant discord.com
# host with a digits-only channel id — so, like the webhook sender, nothing user-supplied picks the host.
DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json"
# GUILD_MESSAGES (1<<9) | DIRECT_MESSAGES (1<<12) | MESSAGE_CONTENT (1<<15). MESSAGE_CONTENT is a
# privileged intent the bot owner must enable in the Discord Developer Portal to read command text.
_DISCORD_INTENTS = (1 << 9) | (1 << 12) | (1 << 15)
_ws_warned = [False] # so a missing websocket-client dep is logged once, not every reconnect tick
def _valid_discord_bot_token(token):
return _DISCORD_BOT_TOKEN_RE.match(token or "") is not None
def _discord_bot_message_url(channel_id):
"""Canonical https://discord.com/api/v10/channels/<id>/messages, or None if the channel id isn't a
plain snowflake. Constant host + digits-only id, so the request path is never user-controlled."""
return ("https://discord.com/api/v10/channels/%s/messages" % channel_id
if _DISCORD_CHANNEL_RE.match(channel_id or "") else None)
def discord_bot_send(bot_token, channel_id, text):
"""Post a message to a channel as the bot (the command bot's reply path). Returns (ok, detail). The
token rides only in the Authorization header; the URL is built on the constant discord.com host with
a charset-checked channel id, so this can't become an SSRF sink."""
url = _discord_bot_message_url((channel_id or "").strip())
if not url:
return False, "the channel ID is missing or malformed"
if not _valid_discord_bot_token(bot_token):
return False, "the bot token is missing or malformed"
body = json.dumps({"content": text[:1900]}).encode()
ok, reason = _post(url, body, {"Content-Type": "application/json",
"Authorization": "Bot %s" % bot_token})
if ok:
return True, ""
if reason == "unreachable":
return False, "couldn't reach discord.com — check the host's outbound network."
return False, ("Discord rejected it — check the bot token, that the bot is in the server, and that "
"it can see/send in that channel.")
def discord_gateway_run(bot_token, on_message, _connect=None):
"""Open ONE Discord Gateway session and pump MESSAGE_CREATE events to `on_message(channel_id,
author_is_bot, content)` until the socket drops; then return so the caller can reconnect after a
backoff. A fresh IDENTIFY each time (no RESUME) means anything sent while we were down is skipped —
the same 'no backlog replay' the Telegram poller gets, so the /update that restarted us is never
re-run. Degrades to a no-op (logged) if websocket-client isn't installed. Never raises.
`_connect` is a seam for tests to inject a fake socket; production leaves it None."""
if _connect is None:
try:
import websocket # optional dependency; command bot is off if it's absent
except Exception:
if not _ws_warned[0]: # warn once, not every reconnect tick, if the dep is missing
_ws_warned[0] = True
_log.warning("discord command bot: the 'websocket-client' package isn't installed — "
"skipping (webhook alerts are unaffected).")
return
def _connect():
return websocket.create_connection(DISCORD_GATEWAY_URL, timeout=40, enable_multithread=True)
ws = None
stop = {"v": False}
try:
ws = _connect()
hello = json.loads(ws.recv())
interval = float((hello.get("d") or {}).get("heartbeat_interval", 41250)) / 1000.0
ws.send(json.dumps({"op": 2, "d": {
"token": bot_token, "intents": _DISCORD_INTENTS,
"properties": {"os": "linux", "browser": "linuxgsm-panel", "device": "linuxgsm-panel"},
}}))
state = {"seq": None, "acked": True}
def _heartbeat():
# Zombied-connection guard: if the previous heartbeat wasn't ACKed (op 11) by the next tick,
# the link is dead — close it so recv() below unblocks and the caller reconnects.
while not stop["v"]:
time.sleep(interval)
if stop["v"]:
break
if not state["acked"]:
try:
ws.close()
except Exception:
_log.debug("discord heartbeat close failed", exc_info=True)
break
state["acked"] = False
try:
ws.send(json.dumps({"op": 1, "d": state["seq"]}))
except Exception:
break
threading.Thread(target=_heartbeat, daemon=True).start()
while True:
raw = ws.recv()
if not raw:
break
data = json.loads(raw)
if data.get("s") is not None:
state["seq"] = data["s"]
op = data.get("op")
if op == 11: # heartbeat ACK
state["acked"] = True
elif op == 1: # server demands an immediate heartbeat (don't touch the
ws.send(json.dumps({"op": 1, "d": state["seq"]})) # periodic ACK tracking → no races
elif op in (7, 9): # reconnect / invalid-session → drop and re-identify
break
elif op == 0 and data.get("t") == "MESSAGE_CREATE":
d = data.get("d") or {}
author = d.get("author") or {}
try:
on_message(str(d.get("channel_id") or ""), bool(author.get("bot")), d.get("content") or "")
except Exception:
_log.debug("discord on_message handler failed", exc_info=True)
except Exception:
_log.debug("discord gateway session ended", exc_info=True)
finally:
stop["v"] = True
try:
if ws is not None:
ws.close()
except Exception:
_log.debug("discord gateway close failed", exc_info=True)
# ── public API ─────────────────────────────────────────────────
def notify(event_key, title, body=""):
"""Fire an alert for `event_key` to every enabled channel, in the background. No-op when
notifications (or this event) are off, or no channel is configured. Never raises."""
try:
cfg = _cfg()
if not event_enabled(cfg, event_key):
return
text = "🎮 LinuxGSM Panel — %s" % title + (("\n%s" % body) if body else "")
tg = cfg.get("telegram") or {}
dc = cfg.get("discord") or {}
def _go():
try:
if tg.get("enabled"):
send_telegram(decrypt_secret(tg.get("token") or ""), (tg.get("chat_id") or "").strip(), text)
if dc.get("enabled"):
send_discord(decrypt_secret(dc.get("webhook") or ""), text)
except Exception:
_log.debug("notify send failed", exc_info=True)
threading.Thread(target=_go, daemon=True).start()
except Exception:
_log.debug("notify failed to dispatch", exc_info=True)
def test_send(kind, token=None, chat_id=None, webhook=None):
"""Synchronously send a test message to one channel. Uses the values passed from the form when
given (so you can test BEFORE saving), else the saved config. (ok, message) — message carries the
provider's actual error on failure."""
cfg = _cfg()
text = "🎮 LinuxGSM Panel — test alert. If you can read this, notifications are working."
if kind == "telegram":
tg = cfg.get("telegram") or {}
tok = (token or "").strip() or decrypt_secret(tg.get("token") or "")
chat = ((chat_id or "").strip() or (tg.get("chat_id") or "")).strip()
if not tok:
return False, "Enter the bot token first."
if not _TG_TOKEN_RE.match(tok):
return False, "That bot token isn't in the expected format (like 123456789:AA…)."
if not chat:
return False, "Enter the chat ID first. Message your bot once, then use your numeric chat ID."
ok, detail = send_telegram(tok, chat, text)
return (True, "Test message sent — check Telegram.") if ok \
else (False, "Telegram error: %s" % (detail or "unknown"))
if kind == "discord":
wh = (webhook or "").strip() or decrypt_secret((cfg.get("discord") or {}).get("webhook") or "")
if not wh:
return False, "Enter the webhook URL first."
if not _valid_discord_webhook(wh):
return False, "That doesn't look like a Discord webhook URL."
ok, detail = send_discord(wh, text)
return (True, "Test message sent — check Discord.") if ok \
else (False, "Discord error: %s" % (detail or "unknown"))
if kind == "discord_bot":
dc = cfg.get("discord") or {}
tok = (token or "").strip() or decrypt_secret(dc.get("bot_token") or "")
chan = ((chat_id or "").strip() or (dc.get("channel_id") or "")).strip()
if not tok:
return False, "Enter the bot token first."
if not _valid_discord_bot_token(tok):
return False, "That bot token isn't in the expected format."
if not _DISCORD_CHANNEL_RE.match(chan):
return False, "Enter the numeric channel ID first (right-click the channel → Copy Channel ID)."
ok, detail = discord_bot_send(tok, chan, text)
return (True, "Test message sent — check the Discord channel.") if ok \
else (False, "Discord error: %s" % (detail or "unknown"))
return False, "Unknown channel."