""" tui_events.py — Thread-safe event bus between the bot backend and the TUI. The bot backend runs in a dedicated thread with its own asyncio event loop (completely isolated from Textual's loop). Events are posted via a standard queue.Queue (thread-safe), and the TUI consumer polls it from Textual's loop using asyncio.get_event_loop().run_in_executor() bridging. post() is safe to call from any thread or any asyncio loop. Web frontend: call subscribe() to get a private queue that receives every event post() delivers. Call unsubscribe() on disconnect. """ import asyncio import queue import threading from dataclasses import dataclass, field from typing import Any # Thread-safe queue — works across the bot thread and Textual's thread. _queue: queue.Queue | None = None _queue_lock = threading.Lock() # Set to True when the TUI is running so tdl pipes output instead of # writing directly to the terminal. tui_active: bool = False # ─── Web subscriber broadcast ───────────────────────────────────────────────── _subscribers: list[queue.Queue] = [] _subscribers_lock = threading.Lock() def subscribe() -> queue.Queue: """Return a new Queue that receives every future post(). Thread-safe.""" q: queue.Queue = queue.Queue() with _subscribers_lock: _subscribers.append(q) return q def unsubscribe(q: queue.Queue) -> None: """Remove a subscriber queue. Safe to call even if already removed.""" with _subscribers_lock: try: _subscribers.remove(q) except ValueError: pass # ─── Bot-loop channel-change signal (for web routes) ───────────────────────── # The TUI sets this via set_bot_context() after the bot asyncio loop starts. # Web config routes call signal_channel_changed() to wake _watch_channels(). _bot_loop: asyncio.AbstractEventLoop | None = None _bot_ch_ev: asyncio.Event | None = None def set_bot_context(loop: asyncio.AbstractEventLoop, event: asyncio.Event) -> None: """Called by _bot_main() once the bot loop and channel event exist.""" global _bot_loop, _bot_ch_ev _bot_loop = loop _bot_ch_ev = event def signal_channel_changed() -> None: """Wake the bot's _watch_channels() coroutine from any thread.""" if _bot_loop is not None and _bot_ch_ev is not None: _bot_loop.call_soon_threadsafe(_bot_ch_ev.set) def init_bus() -> queue.Queue: """Call once from MonitorApp.on_mount() to create the queue.""" global _queue, tui_active _queue = queue.Queue() tui_active = True return _queue def get_bus() -> queue.Queue | None: return _queue def post(event: Any) -> None: """Fire-and-forget from any thread. Broadcasts to TUI queue + all subscribers.""" if _queue is not None: try: _queue.put_nowait(event) except queue.Full: pass with _subscribers_lock: subs = list(_subscribers) for q in subs: try: q.put_nowait(event) except queue.Full: pass # ─── Event types ────────────────────────────────────────────────────────────── @dataclass class EvDownloadQueued: """A file has been accepted and is waiting for tdl.""" batch_id: str filename: str size_mb: float source: str password: str | None @dataclass class EvDownloadStarted: """tdl has begun transferring this file.""" batch_id: str filename: str @dataclass class EvDownloadDone: """File fully downloaded (tdl or Telethon fallback).""" batch_id: str filename: str via: str # "tdl" | "telethon" @dataclass class EvDownloadFailed: """All download attempts failed.""" batch_id: str filename: str reason: str @dataclass class EvTdlOutput: """A line of output from tdl's stdout/stderr (TUI mode only).""" line: str @dataclass class EvHit: """A scored credential hit to display in the hits panel.""" severity: str raw: str source: str filename: str reasons: list[str] = field(default_factory=list) @dataclass class EvChannelAdded: """A channel was added to the live watch list.""" channel: str | int @dataclass class EvChannelRemoved: """A channel was removed from the live watch list.""" channel: str | int @dataclass class EvStatus: """Generic one-line status message (startup, errors, etc.).""" text: str level: str = "info" # "info" | "warning" | "error"