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.
- ping_shield/__init__.py +12 -0
- ping_shield/__main__.py +8 -0
- ping_shield/bot.py +467 -0
- ping_shield/config.py +446 -0
- ping_shield/intel.py +580 -0
- ping_shield/main.py +398 -0
- ping_shield/monitor.py +596 -0
- ping_shield/notify.py +380 -0
- ping_shield/service.py +208 -0
- ping_shield/sources.py +230 -0
- ping_shield/tester.py +348 -0
- ping_shield/watch.py +242 -0
- ping_shield-1.1.0.dist-info/METADATA +458 -0
- ping_shield-1.1.0.dist-info/RECORD +17 -0
- ping_shield-1.1.0.dist-info/WHEEL +4 -0
- ping_shield-1.1.0.dist-info/entry_points.txt +2 -0
- ping_shield-1.1.0.dist-info/licenses/LICENSE +21 -0
ping_shield/__init__.py
ADDED
|
@@ -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__"]
|
ping_shield/__main__.py
ADDED
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
|