Files
stealergram/tui/events.py
anti 741e6bb0d3 Rename to stealergram, add pyproject.toml, purge em-dashes
- Rename project to stealergram throughout
- Add pyproject.toml (replaces requirements.txt split, folds pytest.ini)
- Replace all em-dashes with hyphens across all source files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 10:06:30 -04:00

169 lines
4.6 KiB
Python

"""
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"