From 7b3c358b05737bf427c7a6ac87c2e4bd364e55cd Mon Sep 17 00:00:00 2001 From: Minz Date: Sat, 1 Aug 2026 18:53:48 +0200 Subject: [PATCH] Add a live status line to the stats panel Shows what's happening right now underneath the table (connecting, resolving the friend, discovering targets, fetching channel history, waiting out a jittered delay before reacting, sending a reaction, removing a reaction during --undo, etc). Status updates are cheap and DB-free, using the last fetched stats for re-rendering. --- bot/backlog.py | 12 +++++++++++- bot/display.py | 29 +++++++++++++++++++++-------- bot/reactor.py | 8 +++++++- bot/undo.py | 11 ++++++++++- main.py | 8 ++++++++ 5 files changed, 57 insertions(+), 11 deletions(-) diff --git a/bot/backlog.py b/bot/backlog.py index 7bc28c9..c3e88e6 100644 --- a/bot/backlog.py +++ b/bot/backlog.py @@ -42,6 +42,10 @@ async def scan_channel_backlog( channel_id = getattr(channel, "id", None) if channel_id is None: return + chan_label = getattr(channel, "name", None) or target.name + + if display is not None: + display.set_status(f"Checking {chan_label} for previously found, unreacted message(s)...") found_messages = [] pending_ids = await asyncio.to_thread(storage.get_pending_found_message_ids, channel_id, emoji) @@ -57,6 +61,9 @@ async def scan_channel_backlog( newest_id_seen = int(state["newest_id_seen"]) if state and state.get("newest_id_seen") else None after = discord.Object(id=newest_id_seen) if newest_id_seen else cutoff + if display is not None: + display.set_status(f"Fetching message history for {chan_label}...") + try: async for message in channel.history(after=after, oldest_first=True, limit=None): if newest_id_seen is None or message.id > newest_id_seen: @@ -68,6 +75,7 @@ async def scan_channel_backlog( storage.record_message_found, message.id, channel_id, target.target_id, friend_id ) if display is not None: + display.set_status(f"Scanning {chan_label} — {len(found_messages)} found so far...") await display.refresh() await asyncio.to_thread(storage.set_scan_state, channel_id, target.target_id, newest_id_seen, True) @@ -78,5 +86,7 @@ async def scan_channel_backlog( log.exception("HTTP error scanning channel %s", channel_id) return - for message in found_messages: + for i, message in enumerate(found_messages, start=1): + if display is not None: + display.set_status(f"Reacting to message {i}/{len(found_messages)} in {chan_label}...") await react_fn(message, target) diff --git a/bot/display.py b/bot/display.py index cdfa529..296adee 100644 --- a/bot/display.py +++ b/bot/display.py @@ -1,18 +1,19 @@ import asyncio from typing import List, Optional -from rich.console import Console +from rich.console import Console, Group from rich.live import Live from rich.panel import Panel from rich.progress_bar import ProgressBar from rich.table import Table +from rich.text import Text from bot.storage import Storage _TYPE_LABELS = {"guild": "server", "group_dm": "group", "dm": "DM"} -def _render(stats: List[dict], friend_label: Optional[str] = None) -> Panel: +def _render(stats: List[dict], friend_label: Optional[str] = None, status: Optional[str] = None) -> Panel: table = Table(expand=True) table.add_column("Target") table.add_column("Type") @@ -40,8 +41,12 @@ def _render(stats: List[dict], friend_label: Optional[str] = None) -> Panel: if not stats: table.add_row("(no targets discovered yet)", "-", "-", "-", "-") + content = table + if status: + content = Group(table, Text(f"▸ {status}", style="dim italic")) + who = f" for {friend_label}" if friend_label else "" - return Panel(table, title=f"Reaction stats{who} — total: {total_reacted}/{total_found}", border_style="cyan") + return Panel(content, title=f"Reaction stats{who} — total: {total_reacted}/{total_found}", border_style="cyan") class StatsDisplay: @@ -52,8 +57,10 @@ class StatsDisplay: self.console = console self.storage = storage self.friend_label: Optional[str] = None + self.status: str = "Starting..." + self._last_stats: List[dict] = [] self._live = Live( - _render([]), + _render([], None, self.status), console=console, refresh_per_second=4, transient=False, @@ -69,10 +76,16 @@ class StatsDisplay: def set_friend(self, label: str) -> None: self.friend_label = label + def set_status(self, text: str) -> None: + """Update the "what's happening right now" line without hitting the + DB — cheap enough to call for every jittered wait / API call.""" + self.status = text + self._live.update(_render(self._last_stats, self.friend_label, self.status)) + def refresh_sync(self) -> None: - stats = self.storage.get_stats() - self._live.update(_render(stats, self.friend_label)) + self._last_stats = self.storage.get_stats() + self._live.update(_render(self._last_stats, self.friend_label, self.status)) async def refresh(self) -> None: - stats = await asyncio.to_thread(self.storage.get_stats) - self._live.update(_render(stats, self.friend_label)) + self._last_stats = await asyncio.to_thread(self.storage.get_stats) + self._live.update(_render(self._last_stats, self.friend_label, self.status)) diff --git a/bot/reactor.py b/bot/reactor.py index 219e24a..582b6fc 100644 --- a/bot/reactor.py +++ b/bot/reactor.py @@ -46,8 +46,14 @@ async def react_to_message( if already_reacted: return False - await asyncio.sleep(human_jitter()) + chan_label = getattr(message.channel, "name", None) or target.name + delay = human_jitter() + if display is not None: + display.set_status(f"Waiting {delay:.1f}s before reacting in {chan_label}...") + await asyncio.sleep(delay) + if display is not None: + display.set_status(f"Sending reaction in {chan_label}...") try: await message.add_reaction(emoji) except discord.HTTPException: diff --git a/bot/undo.py b/bot/undo.py index 3d12d3c..2f2b5e9 100644 --- a/bot/undo.py +++ b/bot/undo.py @@ -24,6 +24,9 @@ async def undo_all_reactions(client: discord.Client, storage: Storage, display: emoji = row["emoji"] should_clear_log = True + if display is not None: + display.set_status(f"Removing reaction from message {message_id} ({i}/{total})...") + try: channel = client.get_channel(channel_id) or await client.fetch_channel(channel_id) message = await channel.fetch_message(message_id) @@ -41,11 +44,17 @@ async def undo_all_reactions(client: discord.Client, storage: Storage, display: if display is not None: await display.refresh() - await asyncio.sleep(human_jitter()) + delay = human_jitter() + if display is not None: + display.set_status(f"Waiting {delay:.1f}s before next removal...") + await asyncio.sleep(delay) + if display is not None: + display.set_status("Clearing channel resume points and found-message log...") cleared = await asyncio.to_thread(storage.clear_scan_state) await asyncio.to_thread(storage.reset_message_counts) if display is not None: + display.set_status("Undo complete.") await display.refresh() log.info( "Undo complete: removed %d/%d reaction(s), cleared %d channel resume point(s) — " diff --git a/main.py b/main.py index 18b7f8e..34197e3 100644 --- a/main.py +++ b/main.py @@ -44,6 +44,7 @@ class ReactorClient(discord.Client): await self.close() async def _run_backlog_then_watch(self): + self.display.set_status(f"Resolving friend id {self.cfg.friend_id}...") try: friend = await resolve_friend(self, self.cfg.friend_id) except discord.NotFound: @@ -51,10 +52,12 @@ class ReactorClient(discord.Client): "friend_id %s does not resolve to any Discord user. Check config.json and fix it.", self.cfg.friend_id, ) + self.display.set_status("Failed: friend_id does not resolve to a Discord user.") return log.info("Targeting friend: %s (%s)", friend, friend.id) self.display.set_friend(str(friend)) + self.display.set_status(f"Discovering mutual servers/groups with {friend}...") await self.display.refresh() targets = await discover_targets(self, friend, self.cfg.manual_guild_ids) @@ -65,6 +68,7 @@ class ReactorClient(discord.Client): pruned = await asyncio.to_thread(self.storage.prune_targets, [t.target_id for t in targets]) if pruned: log.info("Pruned %d stale target(s) no longer mutual/discovered", pruned) + self.display.set_status(f"Starting backlog scan across {len(targets)} target(s)...") await self.display.refresh() cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=self.cfg.days_back) @@ -95,6 +99,7 @@ class ReactorClient(discord.Client): ) log.info("Backlog scan complete. Now watching live.") + self.display.set_status("Backlog scan complete — watching live for new messages...") async def on_message(self, message: discord.Message): if self.undo: @@ -104,6 +109,8 @@ class ReactorClient(discord.Client): return if message.author.id != self.cfg.friend_id: return + chan_label = getattr(message.channel, "name", None) or target.name + self.display.set_status(f"New message from friend in {chan_label}...") await react_to_message( self.storage, message, target, self.cfg.friend_id, self.cfg.emoji, source="live", display=self.display, @@ -133,6 +140,7 @@ def main(): storage = Storage(cfg.db_path) display = StatsDisplay(console, storage) + display.set_status("Connecting to Discord...") with display: client = ReactorClient(cfg, storage, display, undo=args.undo)