ping-shield 1.1.0__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.
@@ -0,0 +1,12 @@
1
+ """ping-shield: supply-chain backdoor, persistence and threat-intel monitor for Linux.
2
+
3
+ ping-shield watches the places attackers use to keep access to a developer
4
+ workstation (SSH trust files, crontabs, systemd user units, shell start-up
5
+ files, IDE extensions), watches public security news for high-severity
6
+ threats relevant to the local machine, and reports changes to Telegram exactly
7
+ once.
8
+ """
9
+
10
+ __version__ = "1.1.0"
11
+
12
+ __all__ = ["__version__"]
@@ -0,0 +1,8 @@
1
+ """Allow ``python -m ping_shield`` (used by the generated systemd unit)."""
2
+
3
+ import sys
4
+
5
+ from .main import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
ping_shield/bot.py ADDED
@@ -0,0 +1,467 @@
1
+ """Interactive Telegram bot (``ping-shield --bot``).
2
+
3
+ Long-polls the Bot API and handles:
4
+
5
+ * alert buttons: "โœ… It was me", "๐Ÿšจ Not me" (replies with a response
6
+ checklist), "๐Ÿ”• Mute <keyword>";
7
+ * commands: /status, /scan, /sources, /catalog, /addsource, /removesource,
8
+ /mute, /unmute, /help.
9
+
10
+ Only the chat configured in ``config.json`` is served; everyone else gets a
11
+ refusal, so a stranger who finds the bot cannot run scans or change sources.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import contextlib
17
+ import hashlib
18
+ import html
19
+ import logging
20
+ import re
21
+ import subprocess
22
+ import sys
23
+ import threading
24
+ import time
25
+ from typing import Any, Callable, Dict, List, Optional, Tuple
26
+
27
+ from . import sources
28
+ from .config import Config, load_config, save_config, state_path
29
+ from .intel import add_sources, detect_distro_ids, remove_sources
30
+ from .notify import BRAND, NotifyError, TelegramNotifier, esc, local_time
31
+
32
+ log = logging.getLogger(__name__)
33
+
34
+ COMMANDS: List[Tuple[str, str]] = [
35
+ ("status", "Monitored targets, sources and timer status"),
36
+ ("scan", "Run a scan right now"),
37
+ ("sources", "List your news sources"),
38
+ ("catalog", "Browse official sources and channels to add"),
39
+ ("addsource", "Add a source: URL, t.me/channel or catalog id"),
40
+ ("removesource", "Remove a source"),
41
+ ("mute", "Stop news alerts that only match a keyword"),
42
+ ("unmute", "Re-enable a muted keyword"),
43
+ ("help", "Show what this bot can do"),
44
+ ]
45
+
46
+ PLAYBOOKS: Dict[str, Tuple[str, List[str]]] = {
47
+ "ssh": ("SSH key you didn't add", [
48
+ "Remove the unknown line: <code>nano ~/.ssh/authorized_keys</code>",
49
+ "Check recent logins: <code>last -a | head -20</code> and "
50
+ "<code>journalctl -u sshd -u ssh --since -7d | grep Accepted</code>",
51
+ "Look for other persistence: <code>crontab -l</code> and "
52
+ "<code>systemctl --user list-unit-files --state=enabled</code>",
53
+ "Rotate your SSH keys and any secrets stored on this machine.",
54
+ "Run <code>ping-shield --run</code> to confirm the file is clean.",
55
+ ]),
56
+ "persistence": ("Auto-start entry you didn't create", [
57
+ "Open the file named in the alert and note every command and URL it runs.",
58
+ "Disable it: <code>systemctl --user disable --now NAME</code>, <code>crontab -e</code>, "
59
+ "or delete the <code>.desktop</code> / rc-file line.",
60
+ "Look for what it started: <code>ps -ef --forest</code> and <code>ss -tunap</code>",
61
+ "Check <code>~/.ssh/authorized_keys</code> and your shell rc files for related changes.",
62
+ "If the code ran, rotate credentials stored on this machine (cloud CLIs, tokens, .env files).",
63
+ ]),
64
+ "extensions": ("IDE extension you didn't expect", [
65
+ "Uninstall it: <code>code --uninstall-extension ID</code> or "
66
+ "<code>cursor --uninstall-extension ID</code>",
67
+ "Check the publisher and recent reports about it on the marketplace.",
68
+ "Extensions often drop persistence: check SSH keys, systemd user units and crontab.",
69
+ "Rotate tokens your IDE could read: GitHub, cloud CLIs, <code>.env</code> files.",
70
+ ]),
71
+ }
72
+
73
+ HELP = f"""<b>{BRAND}</b> ยท interactive controls
74
+
75
+ <b>Alerts</b>
76
+ โœ… <b>It was me</b> marks a change as expected
77
+ ๐Ÿšจ <b>Not me</b> sends a step-by-step response checklist
78
+ ๐Ÿ”• <b>Mute</b> stops news that only matches that keyword
79
+
80
+ <b>Commands</b>
81
+ """ + "\n".join(f"/{c} ยท {esc(d)}" for c, d in COMMANDS)
82
+
83
+
84
+ def _source_hash(url: str) -> str:
85
+ return hashlib.sha256(url.encode()).hexdigest()[:10]
86
+
87
+
88
+ def _pre(text: str) -> str:
89
+ text = text.strip() or "(no output)"
90
+ if len(text) > 3500:
91
+ text = text[:3500] + "\nโ€ฆ"
92
+ return f"<pre>{esc(text)}</pre>"
93
+
94
+
95
+ class Progress:
96
+ """One Telegram message that animates while slow work runs, then turns into the result.
97
+
98
+ Shows a spinner, the current step with a progress bar and the elapsed time,
99
+ and keeps Telegram's "typingโ€ฆ" indicator alive.
100
+ """
101
+
102
+ INTERVAL = 2.0
103
+ SPINNER = ("โ—", "โ—“", "โ—‘", "โ—’")
104
+
105
+ def __init__(self, notifier: TelegramNotifier, title: str, reply_to: Optional[int] = None):
106
+ self.notifier = notifier
107
+ self.title = title
108
+ self.step = ""
109
+ self.index = 0
110
+ self.total = 0
111
+ self.started = time.monotonic()
112
+ self._frame = 0
113
+ self._lock = threading.Lock()
114
+ self._stop = threading.Event()
115
+ with contextlib.suppress(NotifyError):
116
+ notifier.send_chat_action()
117
+ self.message_id = notifier.send_message(self._text(), reply_to=reply_to)
118
+ self._thread = threading.Thread(target=self._animate, daemon=True)
119
+ self._thread.start()
120
+
121
+ @property
122
+ def elapsed(self) -> int:
123
+ return int(time.monotonic() - self.started)
124
+
125
+ def _text(self) -> str:
126
+ lines = [f"{self.SPINNER[self._frame % len(self.SPINNER)]} <b>{esc(self.title)}โ€ฆ</b>"]
127
+ if self.total:
128
+ filled = round(10 * self.index / (self.total + 1))
129
+ lines.append(f"{'โ–ฐ' * filled}{'โ–ฑ' * (10 - filled)} step {self.index} of {self.total}")
130
+ if self.step:
131
+ lines.append(f"<i>{esc(self.step)}</i>")
132
+ lines.append(f"โฑ {self.elapsed}s")
133
+ return "\n".join(lines)
134
+
135
+ def _refresh(self) -> None:
136
+ with self._lock:
137
+ if self._stop.is_set():
138
+ return
139
+ try:
140
+ self.notifier.edit_message_text(self.message_id, self._text())
141
+ except NotifyError as exc:
142
+ log.debug("progress update skipped: %s", exc)
143
+
144
+ def set_step(self, step: str, index: int = 0, total: int = 0) -> None:
145
+ self.step, self.index, self.total = step, index, total
146
+ self._refresh()
147
+
148
+ def _animate(self) -> None:
149
+ while not self._stop.wait(self.INTERVAL):
150
+ self._frame += 1
151
+ with contextlib.suppress(NotifyError):
152
+ self.notifier.send_chat_action()
153
+ self._refresh()
154
+
155
+ def finish(self, text: str, markup: Optional[Dict[str, Any]] = None) -> None:
156
+ with self._lock:
157
+ self._stop.set()
158
+ self._thread.join(timeout=5)
159
+ try:
160
+ self.notifier.edit_message_text(self.message_id, text, reply_markup=markup)
161
+ except NotifyError:
162
+ self.notifier.send_message(text, reply_markup=markup)
163
+
164
+
165
+ _MONITOR_RE = re.compile(r"monitor: (\d+) baselined, (\d+) unchanged, (\d+) change\(s\), (\d+) error\(s\)")
166
+ _INTEL_RE = re.compile(r"intel: (\d+) item\(s\), (\d+) alerted, (\d+) duplicate, (\d+) not relevant, "
167
+ r"(\d+) too old, (\d+) deferred")
168
+ _BUSY = "another ping-shield instance is running"
169
+
170
+
171
+ def _plural(n: int, word: str) -> str:
172
+ return f"{n} {word}{'' if n == 1 else 's'}"
173
+
174
+
175
+ def scan_summary(monitor_out: str, intel_out: str, seconds: int) -> str:
176
+ """Turn the CLI's scan output into a compact result card."""
177
+ m, n = _MONITOR_RE.search(monitor_out), _INTEL_RE.search(intel_out)
178
+ if not m or not n:
179
+ return "๐Ÿ” <b>Scan finished</b>\n" + _pre(monitor_out + "\n" + intel_out)
180
+ baselined, unchanged, changes, errors = map(int, m.groups())
181
+ items, alerted, _dup, _irrelevant, _old, deferred = map(int, n.groups())
182
+ checks = baselined + unchanged + changes
183
+ system = f"โš ๏ธ {_plural(changes, 'change')}, see the alerts above" if changes else "no changes โœ“"
184
+ news = f"๐Ÿ”” {_plural(alerted, 'new alert')}" if alerted else "no new threats โœ“"
185
+ if deferred:
186
+ news += f" ยท {deferred} queued for the next run"
187
+ headline = "โš ๏ธ <b>Scan complete: action needed</b>" if changes else "โœ… <b>Scan complete</b>"
188
+ lines = [f"{headline} ยท {seconds}s", "",
189
+ f"๐Ÿ›ก <b>System:</b> {_plural(checks, 'check')} ยท {system}",
190
+ f"๐Ÿ“ฐ <b>News:</b> {_plural(items, 'item')} read ยท {news}"]
191
+ problems = [l.strip().split(": ", 1)[-1] for l in (monitor_out + "\n" + intel_out).splitlines()
192
+ if l.strip().startswith(("error:", "warning:"))]
193
+ if problems or errors:
194
+ lines += ["", "<b>Problems</b>"] + [f"โ€ข {esc(p)}" for p in problems[:5]]
195
+ lines += ["", f"<i>{BRAND} ยท {esc(local_time())}</i>"]
196
+ return "\n".join(lines)
197
+
198
+
199
+ class Bot:
200
+ def __init__(
201
+ self,
202
+ notifier: TelegramNotifier,
203
+ config_loader: Callable[[], Config] = load_config,
204
+ cli: Optional[List[str]] = None,
205
+ ):
206
+ self.notifier = notifier
207
+ self.config_loader = config_loader
208
+ self.cli = cli or [sys.executable, "-m", "ping_shield"]
209
+
210
+ # -- helpers --------------------------------------------------------------
211
+ def _authorized(self, chat: Dict[str, Any], config: Config) -> bool:
212
+ wanted = config.telegram_chat_id.strip()
213
+ if str(chat.get("id")) == wanted:
214
+ return True
215
+ username = chat.get("username")
216
+ return bool(username) and wanted.lower() == f"@{username}".lower()
217
+
218
+ def _reply(self, text: str, reply_to: Optional[int] = None,
219
+ markup: Optional[Dict[str, Any]] = None) -> None:
220
+ self.notifier.send_message(text, reply_markup=markup, reply_to=reply_to)
221
+
222
+ def _run_cli(self, *args: str, timeout: int = 600) -> str:
223
+ proc = subprocess.run([*self.cli, *args], capture_output=True, text=True, timeout=timeout, check=False)
224
+ return proc.stdout + ("\n" + proc.stderr if proc.stderr else "")
225
+
226
+ @staticmethod
227
+ def _replace_button(markup: Dict[str, Any], data: str, new: Dict[str, Any],
228
+ whole_row: bool = False) -> Dict[str, Any]:
229
+ rows = []
230
+ for row in (markup or {}).get("inline_keyboard", []):
231
+ if any(b.get("callback_data") == data for b in row):
232
+ rows.append([new] if whole_row else [new if b.get("callback_data") == data else b for b in row])
233
+ else:
234
+ rows.append(row)
235
+ return {"inline_keyboard": rows}
236
+
237
+ # -- update dispatch ------------------------------------------------------
238
+ def handle(self, update: Dict[str, Any]) -> None:
239
+ config = self.config_loader()
240
+ if "callback_query" in update:
241
+ self._handle_callback(update["callback_query"], config)
242
+ return
243
+ message = update.get("message") or {}
244
+ text = (message.get("text") or "").strip()
245
+ if not text.startswith("/"):
246
+ return
247
+ if not self._authorized(message.get("chat", {}), config):
248
+ log.warning("ignored command from unauthorized chat %s", message.get("chat", {}).get("id"))
249
+ self.notifier.send_message(
250
+ "โ›” This is a private ping-shield bot. Run your own: <code>pip install ping-shield</code>",
251
+ chat_id=message["chat"]["id"],
252
+ )
253
+ return
254
+ command, _, arg = text.partition(" ")
255
+ command = command[1:].split("@", 1)[0].lower()
256
+ handler = getattr(self, f"cmd_{command}", None)
257
+ if handler is None:
258
+ self._reply(f"Unknown command /{esc(command)}. Send /help for the list.")
259
+ return
260
+ handler(arg.strip(), config, message.get("message_id"))
261
+
262
+ def _handle_callback(self, query: Dict[str, Any], config: Config) -> None:
263
+ message = query.get("message") or {}
264
+ chat = message.get("chat", {})
265
+ data = query.get("data", "")
266
+ if not self._authorized(chat, config):
267
+ self.notifier.answer_callback(query["id"], "โ›” Not authorized", alert=True)
268
+ return
269
+ who = query.get("from", {}).get("first_name", "you")
270
+ when = local_time().split(", ", 1)[-1]
271
+ markup = message.get("reply_markup", {})
272
+ message_id = message.get("message_id")
273
+ action, _, value = data.partition(":")
274
+
275
+ if action == "noop":
276
+ self.notifier.answer_callback(query["id"], "Already handled โœ”๏ธ")
277
+ return
278
+ if action == "ack":
279
+ done = {"text": f"โœ… Confirmed by {who} ยท {when}", "callback_data": "noop"}
280
+ self.notifier.edit_reply_markup(chat["id"], message_id, self._replace_button(markup, data, done, True))
281
+ self.notifier.answer_callback(query["id"], "Marked as expected โœ…")
282
+ return
283
+ if action == "sus":
284
+ flagged = {"text": f"๐Ÿšจ Flagged by {who} ยท {when}", "callback_data": "noop"}
285
+ self.notifier.edit_reply_markup(chat["id"], message_id, self._replace_button(markup, data, flagged, True))
286
+ title, steps = PLAYBOOKS.get(value, PLAYBOOKS["persistence"])
287
+ body = "\n".join(f"{i}. {s}" for i, s in enumerate(steps, 1))
288
+ self._reply(f"๐Ÿงฏ <b>Response checklist ยท {esc(title)}</b>\n\n{body}\n\n<i>{BRAND}</i>",
289
+ reply_to=message_id)
290
+ self.notifier.answer_callback(query["id"], "Response checklist sent ๐Ÿงฏ")
291
+ return
292
+ if action == "mute":
293
+ fresh = load_config(apply_env=False)
294
+ if value.lower() not in (k.lower() for k in fresh.muted_keywords):
295
+ fresh.muted_keywords.append(value)
296
+ save_config(fresh)
297
+ muted = {"text": f"๐Ÿ”• Muted โ€œ{value}โ€", "callback_data": "noop"}
298
+ self.notifier.edit_reply_markup(chat["id"], message_id, self._replace_button(markup, data, muted))
299
+ self.notifier.answer_callback(query["id"], f"๐Ÿ”• โ€œ{value}โ€ muted. Undo with /unmute {value}")
300
+ return
301
+ if action == "add":
302
+ name = getattr(sources.lookup(sources.normalize(value)), "name", value) if value else value
303
+ self.notifier.edit_reply_markup(chat["id"], message_id, self._replace_button(
304
+ markup, data, {"text": f"โณ Adding {name}โ€ฆ", "callback_data": "noop"}))
305
+ result = add_sources(load_config(apply_env=False), [value], state_path())[0]
306
+ label = f"โœ… {result.name or value}" if result.ok else f"โš ๏ธ {result.name or value}"
307
+ self.notifier.edit_reply_markup(chat["id"], message_id,
308
+ self._replace_button(markup, data, {"text": label, "callback_data": "noop"}))
309
+ self.notifier.answer_callback(query["id"], f"{result.name or value}: {result.message}", alert=not result.ok)
310
+ return
311
+ if action == "rm":
312
+ fresh = load_config(apply_env=False)
313
+ url = next((f for f in fresh.feeds if _source_hash(f) == value), None)
314
+ if url:
315
+ remove_sources(fresh, [url])
316
+ label = f"๐Ÿ—‘ Removed {sources.display_name(url)}" if url else "Already removed"
317
+ self.notifier.edit_reply_markup(chat["id"], message_id,
318
+ self._replace_button(markup, data, {"text": label, "callback_data": "noop"}))
319
+ self.notifier.answer_callback(query["id"], label)
320
+ return
321
+ self.notifier.answer_callback(query["id"], "This button has expired.")
322
+
323
+ # -- commands -------------------------------------------------------------
324
+ def cmd_start(self, arg: str, config: Config, message_id: Optional[int]) -> None:
325
+ self.cmd_help(arg, config, message_id)
326
+
327
+ def cmd_help(self, arg: str, config: Config, message_id: Optional[int]) -> None:
328
+ self._reply(HELP)
329
+
330
+ def cmd_status(self, arg: str, config: Config, message_id: Optional[int]) -> None:
331
+ progress = Progress(self.notifier, "Collecting status", reply_to=message_id)
332
+ progress.finish("๐Ÿ“‹ <b>Status</b>\n" + _pre(self._run_cli("--status", timeout=60)))
333
+
334
+ def cmd_scan(self, arg: str, config: Config, message_id: Optional[int]) -> None:
335
+ progress = Progress(self.notifier, "Scanning this machine", reply_to=message_id)
336
+ progress.set_step("Checking SSH keys, auto-start entries and IDE extensions", 1, 2)
337
+ monitor_out = self._run_cli("--run", "--only", "monitor")
338
+ if _BUSY in monitor_out:
339
+ progress.finish("โณ A scheduled scan is running right now. Try again in a minute.")
340
+ return
341
+ progress.set_step(f"Reading {_plural(len(config.feeds), 'news source')}", 2, 2)
342
+ intel_out = self._run_cli("--run", "--only", "intel")
343
+ progress.finish(scan_summary(monitor_out, intel_out, progress.elapsed))
344
+
345
+ def cmd_sources(self, arg: str, config: Config, message_id: Optional[int]) -> None:
346
+ if not config.feeds:
347
+ self._reply("No sources yet. Browse /catalog or use /addsource.")
348
+ return
349
+ lines = [f"{i}. {esc(sources.display_name(u))}" for i, u in enumerate(config.feeds, 1)]
350
+ rows = [[{"text": f"๐Ÿ—‘ {sources.display_name(u)}"[:60], "callback_data": f"rm:{_source_hash(u)}"}]
351
+ for u in config.feeds]
352
+ self._reply("๐Ÿ“ก <b>Your sources</b>\n\n" + "\n".join(lines)
353
+ + "\n\nTap a button to remove a source, or browse /catalog to add more.",
354
+ markup={"inline_keyboard": rows})
355
+
356
+ def cmd_catalog(self, arg: str, config: Config, message_id: Optional[int]) -> None:
357
+ recommended = {s.id for s in sources.recommended(detect_distro_ids())}
358
+ available = [s for s in sources.CATALOG if s.url not in config.feeds]
359
+ if not available:
360
+ self._reply("You're subscribed to every catalog source. Add your own with /addsource.")
361
+ return
362
+ blocks, rows = [], []
363
+ for group, title in sources.GROUP_TITLES.items():
364
+ entries = [s for s in available if s.group == group]
365
+ if not entries:
366
+ continue
367
+ blocks.append(f"<b>{title}</b>")
368
+ for s in entries:
369
+ star = " โญ" if s.id in recommended else ""
370
+ blocks.append(f"โ€ข {esc(s.name)}{star}: <i>{esc(s.description)}</i>")
371
+ rows.append([{"text": f"โž• {s.name}{star}"[:60], "callback_data": f"add:{s.id}"}])
372
+ blocks.append("")
373
+ self._reply("๐Ÿ—‚ <b>Source catalog</b>\nโญ recommended for this machine\n\n" + "\n".join(blocks)
374
+ + "Tap to subscribe. Any public channel works too: <code>/addsource t.me/name</code>",
375
+ markup={"inline_keyboard": rows})
376
+
377
+ def cmd_addsource(self, arg: str, config: Config, message_id: Optional[int]) -> None:
378
+ if not arg:
379
+ self._reply("Usage: <code>/addsource https://example.com/feed.xml</code>, "
380
+ "<code>/addsource t.me/channel</code> or a /catalog id")
381
+ return
382
+ values = arg.split()
383
+ progress = Progress(self.notifier, "Checking source" if len(values) == 1 else "Checking sources",
384
+ reply_to=message_id)
385
+ lines = []
386
+ for i, value in enumerate(values, 1):
387
+ progress.set_step(f"Fetching {value}", i, len(values))
388
+ r = add_sources(load_config(apply_env=False), [value], state_path())[0]
389
+ icon = "โœ…" if r.ok else "โš ๏ธ"
390
+ lines.append(f"{icon} <b>{esc(r.name or r.value)}</b>: {esc(r.message)}")
391
+ progress.finish("\n".join(lines))
392
+
393
+ def cmd_removesource(self, arg: str, config: Config, message_id: Optional[int]) -> None:
394
+ if not arg:
395
+ self.cmd_sources(arg, config, message_id)
396
+ return
397
+ lines = []
398
+ for r in remove_sources(load_config(apply_env=False), arg.split()):
399
+ icon = "๐Ÿ—‘" if r.ok else "โš ๏ธ"
400
+ lines.append(f"{icon} <b>{esc(r.name or r.value)}</b>: {esc(r.message)}")
401
+ self._reply("\n".join(lines), reply_to=message_id)
402
+
403
+ def cmd_mute(self, arg: str, config: Config, message_id: Optional[int]) -> None:
404
+ fresh = load_config(apply_env=False)
405
+ if not arg:
406
+ muted = ", ".join(fresh.muted_keywords) or "nothing"
407
+ self._reply(f"๐Ÿ”• Muted: {esc(muted)}\nUsage: <code>/mute keyword</code>")
408
+ return
409
+ if arg.lower() not in (k.lower() for k in fresh.muted_keywords):
410
+ fresh.muted_keywords.append(arg)
411
+ save_config(fresh)
412
+ self._reply(f"๐Ÿ”• News that only matches โ€œ{esc(arg)}โ€ won't alert anymore.", reply_to=message_id)
413
+
414
+ def cmd_unmute(self, arg: str, config: Config, message_id: Optional[int]) -> None:
415
+ fresh = load_config(apply_env=False)
416
+ before = len(fresh.muted_keywords)
417
+ fresh.muted_keywords = [k for k in fresh.muted_keywords if k.lower() != arg.lower()]
418
+ if len(fresh.muted_keywords) != before:
419
+ save_config(fresh)
420
+ self._reply(f"๐Ÿ”” โ€œ{esc(arg)}โ€ is active again.", reply_to=message_id)
421
+ else:
422
+ self._reply(f"โ€œ{esc(arg)}โ€ wasn't muted. Muted: {esc(', '.join(fresh.muted_keywords) or 'nothing')}")
423
+
424
+ # -- main loop ------------------------------------------------------------
425
+ def _start_watcher(self) -> None:
426
+ """Run the real-time file watcher in a background thread, restarting it if it crashes."""
427
+ from .watch import Watcher
428
+
429
+ def loop() -> None:
430
+ while True:
431
+ try:
432
+ Watcher(self.notifier, self.config_loader).run_forever()
433
+ except Exception: # noqa: BLE001 - log and retry; the timer still covers us
434
+ log.exception("real-time watcher stopped; restarting in 30s")
435
+ time.sleep(30)
436
+
437
+ threading.Thread(target=loop, name="ping-shield-watch", daemon=True).start()
438
+
439
+ def run_forever(self) -> None:
440
+ if self.config_loader().realtime:
441
+ self._start_watcher()
442
+ try:
443
+ self.notifier.set_commands(COMMANDS)
444
+ except NotifyError as exc:
445
+ log.warning("could not register bot commands: %s", exc)
446
+ offset: Optional[int] = None
447
+ log.info("bot listening for commands and button presses")
448
+ while True:
449
+ try:
450
+ updates = self.notifier.get_updates(offset)
451
+ except NotifyError as exc:
452
+ delay = 30 if "conflict" in str(exc).lower() else 10
453
+ log.warning("getUpdates failed (%s); retrying in %ss", exc, delay)
454
+ time.sleep(delay)
455
+ continue
456
+ for update in updates:
457
+ offset = update["update_id"] + 1
458
+ try:
459
+ self.handle(update)
460
+ except Exception as exc: # noqa: BLE001 - one bad update must not kill the bot
461
+ log.exception("failed to handle update %s", update.get("update_id"))
462
+ query = update.get("callback_query")
463
+ if query:
464
+ try:
465
+ self.notifier.answer_callback(query["id"], f"โš ๏ธ {html.unescape(str(exc))[:150]}")
466
+ except NotifyError:
467
+ pass