|
|
|
|
@@ -1,5 +1,5 @@
|
|
|
|
|
"""
|
|
|
|
|
tui.py — Textual TUI for the ULP credential monitor.
|
|
|
|
|
tui.py - Textual TUI for the ULP credential monitor.
|
|
|
|
|
|
|
|
|
|
Layout (main screen):
|
|
|
|
|
┌──────────────────────────────────┬──────────────────────────────────┐
|
|
|
|
|
@@ -14,13 +14,13 @@ Layout (main screen):
|
|
|
|
|
└─────────────────────────────────────────────────────────────────────┘
|
|
|
|
|
|
|
|
|
|
Additional screens (push/pop via keybindings):
|
|
|
|
|
• SearchScreen — full-text search across hits DB [s]
|
|
|
|
|
• HitsDBScreen — paginated recent / severity viewer [h]
|
|
|
|
|
• KeywordsScreen — live-edit TARGET_KEYWORDS regex list [k]
|
|
|
|
|
• SearchScreen - full-text search across hits DB [s]
|
|
|
|
|
• HitsDBScreen - paginated recent / severity viewer [h]
|
|
|
|
|
• KeywordsScreen - live-edit TARGET_KEYWORDS regex list [k]
|
|
|
|
|
|
|
|
|
|
Architecture:
|
|
|
|
|
- The entire bot backend runs as a Textual Worker (asyncio task inside the
|
|
|
|
|
TUI event loop — no threading needed).
|
|
|
|
|
TUI event loop - no threading needed).
|
|
|
|
|
- A second Worker runs _bus_consumer(), reading events from tui_events.queue
|
|
|
|
|
and dispatching to the right panel.
|
|
|
|
|
- Channel add/remove from the UI immediately re-registers Telethon handlers
|
|
|
|
|
@@ -29,7 +29,7 @@ Architecture:
|
|
|
|
|
into the download panel's RichLog.
|
|
|
|
|
- StatsPanel polls database.stats() every 10 s via set_interval().
|
|
|
|
|
- Keyword changes are applied in-memory immediately (scorer caches rebuilt);
|
|
|
|
|
NOT auto-persisted to config.py — a notice banner reminds the user.
|
|
|
|
|
NOT auto-persisted to config.py - a notice banner reminds the user.
|
|
|
|
|
- Live patterns are recompiled from config.TARGET_KEYWORDS on every message
|
|
|
|
|
so keyword changes take effect without a handler restart.
|
|
|
|
|
"""
|
|
|
|
|
@@ -88,7 +88,7 @@ def _now() -> str:
|
|
|
|
|
|
|
|
|
|
class DownloadPanel(Vertical):
|
|
|
|
|
"""
|
|
|
|
|
Left panel — two sub-logs stacked vertically:
|
|
|
|
|
Left panel - two sub-logs stacked vertically:
|
|
|
|
|
• top: tdl raw output (stripped ANSI), scrolling
|
|
|
|
|
• bottom: our own structured status entries
|
|
|
|
|
"""
|
|
|
|
|
@@ -158,7 +158,7 @@ class DownloadPanel(Vertical):
|
|
|
|
|
# ─── Hits panel ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
class HitsPanel(Vertical):
|
|
|
|
|
"""Right panel — scrollable color-coded hit log with live counter badge."""
|
|
|
|
|
"""Right panel - scrollable color-coded hit log with live counter badge."""
|
|
|
|
|
|
|
|
|
|
hit_count: reactive[int] = reactive(0)
|
|
|
|
|
|
|
|
|
|
@@ -208,7 +208,7 @@ class HitsPanel(Vertical):
|
|
|
|
|
|
|
|
|
|
class StatsPanel(Horizontal):
|
|
|
|
|
"""
|
|
|
|
|
Slim bar — shows live DB stats, refreshed every 10 s.
|
|
|
|
|
Slim bar - shows live DB stats, refreshed every 10 s.
|
|
|
|
|
Also refreshed immediately whenever a new hit arrives.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
@@ -233,14 +233,14 @@ class StatsPanel(Horizontal):
|
|
|
|
|
|
|
|
|
|
def compose(self) -> ComposeResult:
|
|
|
|
|
yield Static("📊 DB Stats", id="stat-label")
|
|
|
|
|
yield Static("🔴 —", classes="stat-critical", id="stat-critical")
|
|
|
|
|
yield Static("🟠 —", classes="stat-high", id="stat-high")
|
|
|
|
|
yield Static("🟡 —", classes="stat-medium", id="stat-medium")
|
|
|
|
|
yield Static("🟢 —", classes="stat-low", id="stat-low")
|
|
|
|
|
yield Static("total: —", id="stat-total")
|
|
|
|
|
yield Static("unique: —", id="stat-unique")
|
|
|
|
|
yield Static("dupes: —", id="stat-dupes")
|
|
|
|
|
yield Static("sources: —", id="stat-sources")
|
|
|
|
|
yield Static("🔴 - ", classes="stat-critical", id="stat-critical")
|
|
|
|
|
yield Static("🟠 - ", classes="stat-high", id="stat-high")
|
|
|
|
|
yield Static("🟡 - ", classes="stat-medium", id="stat-medium")
|
|
|
|
|
yield Static("🟢 - ", classes="stat-low", id="stat-low")
|
|
|
|
|
yield Static("total: - ", id="stat-total")
|
|
|
|
|
yield Static("unique: - ", id="stat-unique")
|
|
|
|
|
yield Static("dupes: - ", id="stat-dupes")
|
|
|
|
|
yield Static("sources: - ", id="stat-sources")
|
|
|
|
|
|
|
|
|
|
def on_mount(self) -> None:
|
|
|
|
|
self.set_interval(10, self.refresh_stats)
|
|
|
|
|
@@ -266,7 +266,7 @@ class StatsPanel(Horizontal):
|
|
|
|
|
|
|
|
|
|
class ChannelPanel(Vertical):
|
|
|
|
|
"""
|
|
|
|
|
Bottom panel — live-editable channel list.
|
|
|
|
|
Bottom panel - live-editable channel list.
|
|
|
|
|
|
|
|
|
|
Changes are applied immediately (Telethon handlers are re-registered).
|
|
|
|
|
To make them permanent, edit config.py's WATCHED_CHANNELS manually.
|
|
|
|
|
@@ -314,7 +314,7 @@ class ChannelPanel(Vertical):
|
|
|
|
|
|
|
|
|
|
def compose(self) -> ComposeResult:
|
|
|
|
|
yield Label(
|
|
|
|
|
"📡 Channels — changes apply immediately | edit config.py to persist",
|
|
|
|
|
"📡 Channels - changes apply immediately | edit config.py to persist",
|
|
|
|
|
classes="panel-title",
|
|
|
|
|
)
|
|
|
|
|
with Horizontal(classes="controls"):
|
|
|
|
|
@@ -524,7 +524,7 @@ class HitsDBScreen(Screen):
|
|
|
|
|
status,
|
|
|
|
|
)
|
|
|
|
|
self.query_one("#db-status", Label).update(
|
|
|
|
|
f" {len(rows)} row(s) — {label}"
|
|
|
|
|
f" {len(rows)} row(s) - {label}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _load_recent(self) -> None:
|
|
|
|
|
@@ -560,7 +560,7 @@ class KeywordsScreen(Screen):
|
|
|
|
|
• scorer's domain caches are rebuilt
|
|
|
|
|
• The bot handler recompiles patterns on the next message automatically
|
|
|
|
|
|
|
|
|
|
Changes are NOT written back to config.py — a notice banner says so.
|
|
|
|
|
Changes are NOT written back to config.py - a notice banner says so.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
BINDINGS = [Binding("escape", "dismiss", "Back")]
|
|
|
|
|
@@ -601,7 +601,7 @@ class KeywordsScreen(Screen):
|
|
|
|
|
yield Header()
|
|
|
|
|
yield Label("🔑 Keyword / Pattern Editor", classes="screen-title")
|
|
|
|
|
yield Label(
|
|
|
|
|
"⚠ Changes are in-memory only — copy patterns to config.py to persist across restarts.",
|
|
|
|
|
"⚠ Changes are in-memory only - copy patterns to config.py to persist across restarts.",
|
|
|
|
|
classes="notice",
|
|
|
|
|
)
|
|
|
|
|
with Horizontal(id="kw-controls"):
|
|
|
|
|
@@ -671,7 +671,7 @@ class KeywordsScreen(Screen):
|
|
|
|
|
except Exception as e:
|
|
|
|
|
log.warning(f"Could not rebuild scorer caches: {e}")
|
|
|
|
|
bus.post(bus.EvStatus(
|
|
|
|
|
f"Keywords updated — {len(config.TARGET_KEYWORDS)} pattern(s) active"
|
|
|
|
|
f"Keywords updated - {len(config.TARGET_KEYWORDS)} pattern(s) active"
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
def action_dismiss(self) -> None:
|
|
|
|
|
@@ -721,7 +721,7 @@ class MonitorApp(App):
|
|
|
|
|
# The bot backend runs in its own thread with its own asyncio event
|
|
|
|
|
# loop, completely isolated from Textual. Telethon spawns background
|
|
|
|
|
# tasks via asyncio.ensure_future() and calls connect() which returns
|
|
|
|
|
# only after its receiver loop is scheduled — both of these deadlock
|
|
|
|
|
# only after its receiver loop is scheduled - both of these deadlock
|
|
|
|
|
# inside Textual's managed loop. Running in a dedicated thread
|
|
|
|
|
# sidesteps all of that.
|
|
|
|
|
#
|
|
|
|
|
@@ -767,7 +767,7 @@ class MonitorApp(App):
|
|
|
|
|
"""
|
|
|
|
|
Called every 100 ms by set_interval(). Drains all pending events
|
|
|
|
|
from the thread-safe queue and dispatches them to the right widget.
|
|
|
|
|
Runs on Textual's event loop — safe to call widget methods directly.
|
|
|
|
|
Runs on Textual's event loop - safe to call widget methods directly.
|
|
|
|
|
"""
|
|
|
|
|
q = bus.get_bus()
|
|
|
|
|
if q is None:
|
|
|
|
|
@@ -854,7 +854,7 @@ class MonitorApp(App):
|
|
|
|
|
|
|
|
|
|
async def _bot_main(self) -> None:
|
|
|
|
|
"""
|
|
|
|
|
Full bot backend — runs inside the bot thread's own event loop.
|
|
|
|
|
Full bot backend - runs inside the bot thread's own event loop.
|
|
|
|
|
Telethon is free to schedule background tasks without interfering
|
|
|
|
|
with Textual's loop.
|
|
|
|
|
"""
|
|
|
|
|
@@ -870,7 +870,7 @@ class MonitorApp(App):
|
|
|
|
|
patterns = compile_patterns(config.TARGET_KEYWORDS)
|
|
|
|
|
|
|
|
|
|
bus.post(bus.EvStatus(
|
|
|
|
|
f"Starting — {len(config.WATCHED_CHANNELS)} channel(s), "
|
|
|
|
|
f"Starting - {len(config.WATCHED_CHANNELS)} channel(s), "
|
|
|
|
|
f"{len(patterns)} pattern(s)"
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
@@ -894,9 +894,9 @@ class MonitorApp(App):
|
|
|
|
|
await user_client.connect()
|
|
|
|
|
log.info("[bot] user_client connected, checking auth...")
|
|
|
|
|
if not await user_client.is_user_authorized():
|
|
|
|
|
log.error("[bot] user_client not authorized — run: python main.py --no-tui")
|
|
|
|
|
log.error("[bot] user_client not authorized - run: python main.py --no-tui")
|
|
|
|
|
bus.post(bus.EvStatus(
|
|
|
|
|
"Not authorized — run --no-tui once to complete login",
|
|
|
|
|
"Not authorized - run --no-tui once to complete login",
|
|
|
|
|
level="error",
|
|
|
|
|
))
|
|
|
|
|
return
|
|
|
|
|
@@ -962,7 +962,7 @@ class MonitorApp(App):
|
|
|
|
|
log.info(f"[bot] Handler registered for {len(channels)} channel(s)")
|
|
|
|
|
bus.post(bus.EvStatus(f"Watching {len(channels)} channel(s)"))
|
|
|
|
|
|
|
|
|
|
# Channel-change event — lives on this (bot) loop.
|
|
|
|
|
# Channel-change event - lives on this (bot) loop.
|
|
|
|
|
# Textual signals it thread-safely via _signal_channel_changed().
|
|
|
|
|
_ch_changed = asyncio.Event()
|
|
|
|
|
self._bot_loop_channel_event = _ch_changed
|
|
|
|
|
@@ -971,7 +971,7 @@ class MonitorApp(App):
|
|
|
|
|
bus.post(bus.EvStatus("Live listener active"))
|
|
|
|
|
|
|
|
|
|
await backfill_all(user_client, bot_client, patterns)
|
|
|
|
|
bus.post(bus.EvStatus("Backfill complete — monitoring live"))
|
|
|
|
|
bus.post(bus.EvStatus("Backfill complete - monitoring live"))
|
|
|
|
|
|
|
|
|
|
async def _watch_channels():
|
|
|
|
|
while True:
|
|
|
|
|
@@ -1009,7 +1009,7 @@ class MonitorApp(App):
|
|
|
|
|
# ─── Entry point ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def run_tui() -> None:
|
|
|
|
|
# Do NOT call bus.init_bus() here — the Queue must be created inside
|
|
|
|
|
# Do NOT call bus.init_bus() here - the Queue must be created inside
|
|
|
|
|
# Textual's event loop (see MonitorApp.on_mount). Calling it here
|
|
|
|
|
# would bind the Queue to the outer loop which is discarded when
|
|
|
|
|
# App.run() creates a new one.
|
|
|
|
|
|