From ef90ee6a0b737e8221efd0380f96f89a0367fefa Mon Sep 17 00:00:00 2001 From: Minz Date: Sat, 1 Aug 2026 19:24:20 +0200 Subject: [PATCH] Add spinner/countdown-bar to stats panel; backfill on widened days_back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stats panel: a persistent spinner marks whichever target is currently being scanned/reacted to, and jittered waits show a live-ticking countdown bar instead of a static "waiting Xs" line. Both the status line and countdown row are always rendered (blank when idle) so their appearing/disappearing no longer shifts the table's height. Backlog: channel_scan_state now tracks oldest_covered_at alongside newest_id_seen. If days_back is widened after a channel already has a resume checkpoint, the forward-only diff would never look back far enough to notice — now the newly-exposed gap gets backfilled first. --- bot/backlog.py | 45 ++++++++++++++++++----- bot/display.py | 98 +++++++++++++++++++++++++++++++++++++++++++++----- bot/reactor.py | 3 +- bot/storage.py | 34 ++++++++++++------ bot/undo.py | 6 +++- main.py | 4 +++ 6 files changed, 159 insertions(+), 31 deletions(-) diff --git a/bot/backlog.py b/bot/backlog.py index c3e88e6..51a4fa3 100644 --- a/bot/backlog.py +++ b/bot/backlog.py @@ -24,7 +24,7 @@ async def scan_channel_backlog( react_fn: ReactFn, display: Optional[StatsDisplay] = None, ) -> None: - """Scan a channel's history once, resumably, in two phases. + """Scan a channel's history, resumably, in two phases. Phase 1 finds every message from the friend in the window first (so the "found" count settles before any reacting starts, rather than climbing @@ -38,6 +38,13 @@ async def scan_channel_backlog( up again — this is independent of the newest_id_seen checkpoint below, so nothing found is ever silently abandoned even if the checkpoint has already moved past it. + + The channel's resume state tracks not just newest_id_seen (for the + forward diff) but also oldest_covered_at — the earliest point in time + already fully scanned. If days_back has been widened since the last run + (cutoff is now older than oldest_covered_at), the newly-exposed gap + [cutoff, oldest_covered_at) is backfilled first — otherwise the forward- + only diff would silently never look back far enough to notice. """ channel_id = getattr(channel, "id", None) if channel_id is None: @@ -59,13 +66,16 @@ async def scan_channel_backlog( state = await asyncio.to_thread(storage.get_scan_state, channel_id) 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 + oldest_covered_at = ( + datetime.datetime.fromisoformat(state["oldest_covered_at"]) + if state and state.get("oldest_covered_at") else None + ) - 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): + async def scan_range(after, before, status_verb: str) -> None: + nonlocal newest_id_seen + if display is not None: + display.set_status(f"{status_verb} {chan_label}...") + async for message in channel.history(after=after, before=before, oldest_first=True, limit=None): if newest_id_seen is None or message.id > newest_id_seen: newest_id_seen = message.id @@ -75,10 +85,27 @@ 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...") + display.set_status(f"{status_verb} {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) + try: + if oldest_covered_at is None: + # Never scanned before: one pass across the whole configured window. + await scan_range(cutoff, None, "Fetching message history for") + oldest_covered_at = cutoff + else: + if cutoff < oldest_covered_at: + # days_back was widened since the last run — the forward-only + # diff below would never notice the newly-exposed older range, + # so backfill exactly that gap first. + await scan_range(cutoff, oldest_covered_at, "Backfilling older history in") + oldest_covered_at = cutoff + after = discord.Object(id=newest_id_seen) if newest_id_seen else cutoff + await scan_range(after, None, "Fetching new messages in") + + await asyncio.to_thread( + storage.set_scan_state, channel_id, target.target_id, newest_id_seen, oldest_covered_at, True + ) except discord.Forbidden: log.warning("No access to channel %s (%s), skipping", channel_id, getattr(channel, "name", "")) return diff --git a/bot/display.py b/bot/display.py index 296adee..86c915c 100644 --- a/bot/display.py +++ b/bot/display.py @@ -1,10 +1,12 @@ import asyncio +import time from typing import List, Optional from rich.console import Console, Group from rich.live import Live from rich.panel import Panel from rich.progress_bar import ProgressBar +from rich.spinner import Spinner from rich.table import Table from rich.text import Text @@ -13,7 +15,36 @@ from bot.storage import Storage _TYPE_LABELS = {"guild": "server", "group_dm": "group", "dm": "DM"} -def _render(stats: List[dict], friend_label: Optional[str] = None, status: Optional[str] = None) -> Panel: +class CountdownBar: + """A Rich renderable that computes its own remaining time at render + time (same trick Spinner uses to animate for free under a Live display) + so it ticks down smoothly without us needing an explicit polling loop.""" + + def __init__(self, duration: float, label: str): + self.duration = max(duration, 0.001) + self.label = label + self.start_time = time.monotonic() + + def __rich_console__(self, console, options): + elapsed = time.monotonic() - self.start_time + remaining = max(0.0, self.duration - elapsed) + bar = ProgressBar(total=self.duration, completed=min(elapsed, self.duration), width=30) + grid = Table.grid(padding=(0, 1)) + grid.add_column() + grid.add_column(ratio=1) + grid.add_column(justify="right") + grid.add_row(Text(f"⏳ {self.label}", style="dim"), bar, Text(f"{remaining:0.1f}s", style="dim")) + yield grid + + +def _render( + stats: List[dict], + friend_label: Optional[str] = None, + status: Optional[str] = None, + countdown: Optional[CountdownBar] = None, + active_spinner: Optional[Spinner] = None, + active_target_id: Optional[str] = None, +) -> Panel: table = Table(expand=True) table.add_column("Target") table.add_column("Type") @@ -29,9 +60,15 @@ def _render(stats: List[dict], friend_label: Optional[str] = None, status: Optio total_reacted += reacted total_found += found + name = row["name"] or row["target_id"] + if active_spinner is not None and active_target_id is not None and str(row["target_id"]) == str(active_target_id): + target_cell = _spinner_row(active_spinner, name) + else: + target_cell = Text(name) + bar = ProgressBar(total=max(found, reacted, 1), completed=reacted, width=None) table.add_row( - row["name"] or row["target_id"], + target_cell, _TYPE_LABELS.get(row["target_type"], row["target_type"]), bar, f"{reacted}/{found}", @@ -41,14 +78,25 @@ def _render(stats: List[dict], friend_label: Optional[str] = None, status: Optio if not stats: table.add_row("(no targets discovered yet)", "-", "-", "-", "-") - content = table - if status: - content = Group(table, Text(f"▸ {status}", style="dim italic")) + # Status/countdown rows are always present (even blank) so the panel's + # height never changes as they toggle on/off — that jumping is jarring + # under a Live display that also has scrolling log output above it. + status_line = Text(f"▸ {status}", style="dim italic") if status else Text(" ") + countdown_line = countdown if countdown is not None else Text(" ") + content = Group(table, status_line, countdown_line) who = f" for {friend_label}" if friend_label else "" return Panel(content, title=f"Reaction stats{who} — total: {total_reacted}/{total_found}", border_style="cyan") +def _spinner_row(spinner: Spinner, name: str): + grid = Table.grid(padding=(0, 1)) + grid.add_column() + grid.add_column() + grid.add_row(spinner, Text(name)) + return grid + + class StatsDisplay: """A stats panel pinned to the bottom of the terminal, with normal log/print output scrolling above it (via rich.live.Live).""" @@ -58,11 +106,14 @@ class StatsDisplay: self.storage = storage self.friend_label: Optional[str] = None self.status: str = "Starting..." + self.active_target_id: Optional[str] = None + self._spinner = Spinner("dots") + self._countdown: Optional[CountdownBar] = None self._last_stats: List[dict] = [] self._live = Live( _render([], None, self.status), console=console, - refresh_per_second=4, + refresh_per_second=8, transient=False, ) @@ -73,19 +124,48 @@ class StatsDisplay: def __exit__(self, *exc_info): return self._live.__exit__(*exc_info) + def _redraw(self) -> None: + self._live.update( + _render( + self._last_stats, + self.friend_label, + self.status, + self._countdown, + self._spinner, + self.active_target_id, + ) + ) + def set_friend(self, label: str) -> None: self.friend_label = label + def set_active_target(self, target_id: Optional[object]) -> None: + """Show a little spinner next to whichever target row is currently + being scanned/reacted to. Pass None when idle (e.g. once backlog + scanning finishes and we're just watching for live messages).""" + self.active_target_id = str(target_id) if target_id is not None else None + self._redraw() + 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)) + self._redraw() + + def start_countdown(self, duration: float, label: str) -> None: + """Show a live-ticking countdown bar for a jittered wait, replacing + the status line for the duration of the wait.""" + self._countdown = CountdownBar(duration, label) + self._redraw() + + def clear_countdown(self) -> None: + self._countdown = None + self._redraw() def refresh_sync(self) -> None: self._last_stats = self.storage.get_stats() - self._live.update(_render(self._last_stats, self.friend_label, self.status)) + self._redraw() async def refresh(self) -> None: self._last_stats = await asyncio.to_thread(self.storage.get_stats) - self._live.update(_render(self._last_stats, self.friend_label, self.status)) + self._redraw() diff --git a/bot/reactor.py b/bot/reactor.py index 582b6fc..b8c55fa 100644 --- a/bot/reactor.py +++ b/bot/reactor.py @@ -49,10 +49,11 @@ async def react_to_message( 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}...") + display.start_countdown(delay, f"reacting in {chan_label}") await asyncio.sleep(delay) if display is not None: + display.clear_countdown() display.set_status(f"Sending reaction in {chan_label}...") try: await message.add_reaction(emoji) diff --git a/bot/storage.py b/bot/storage.py index 16be0e2..df810f0 100644 --- a/bot/storage.py +++ b/bot/storage.py @@ -27,9 +27,10 @@ CREATE TABLE IF NOT EXISTS reactions_log ( CREATE INDEX IF NOT EXISTS idx_reactions_log_target ON reactions_log(target_id); CREATE TABLE IF NOT EXISTS channel_scan_state ( - channel_id TEXT PRIMARY KEY, - target_id TEXT NOT NULL, + channel_id TEXT PRIMARY KEY, + target_id TEXT NOT NULL, newest_id_seen TEXT, + oldest_covered_at TEXT, backlog_complete INTEGER NOT NULL DEFAULT 0, last_scanned_at TEXT ); @@ -64,6 +65,12 @@ class Storage: if "message_created_at" not in columns: self._conn.execute("ALTER TABLE reactions_log ADD COLUMN message_created_at TEXT") + # Older DBs created before oldest_covered_at existed (widening days_back + # couldn't be detected/backfilled without it — see scan_channel_backlog). + scan_columns = {row["name"] for row in self._conn.execute("PRAGMA table_info(channel_scan_state)")} + if "oldest_covered_at" not in scan_columns: + self._conn.execute("ALTER TABLE channel_scan_state ADD COLUMN oldest_covered_at TEXT") + # Older DBs created before 'dm' targets (1:1 DMs) existed have a CHECK # constraint that only allows 'guild'/'group_dm' — rebuild the table # with the wider constraint, preserving all existing rows. @@ -185,11 +192,10 @@ class Storage: return inserted def clear_scan_state(self) -> int: - """Wipe all per-channel resume points. The backlog scan only ever - consults days_back on a channel's very first scan and otherwise just - diffs forward from the last-seen message, so this must be cleared - whenever reactions are undone (or days_back is widened) to force a - fresh full backlog scan instead of silently skipping everything.""" + """Wipe all per-channel resume points, forcing a full fresh backlog + scan on the next run (widening days_back alone no longer requires + this — see oldest_covered_at in scan_channel_backlog — but --undo + still wants a clean slate since it also removes the reactions log).""" with self._lock, self._conn: cur = self._conn.execute("DELETE FROM channel_scan_state") return cur.rowcount @@ -203,19 +209,25 @@ class Storage: row = cur.fetchone() return dict(row) if row else None - def set_scan_state(self, channel_id, target_id, newest_id_seen, backlog_complete: bool) -> None: + def set_scan_state(self, channel_id, target_id, newest_id_seen, oldest_covered_at, backlog_complete: bool) -> None: with self._lock, self._conn: self._conn.execute( """ INSERT INTO channel_scan_state - (channel_id, target_id, newest_id_seen, backlog_complete, last_scanned_at) - VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) + (channel_id, target_id, newest_id_seen, oldest_covered_at, backlog_complete, last_scanned_at) + VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) ON CONFLICT(channel_id) DO UPDATE SET newest_id_seen = excluded.newest_id_seen, + oldest_covered_at = excluded.oldest_covered_at, backlog_complete = excluded.backlog_complete, last_scanned_at = excluded.last_scanned_at """, - (str(channel_id), str(target_id), str(newest_id_seen) if newest_id_seen else None, int(backlog_complete)), + ( + str(channel_id), str(target_id), + str(newest_id_seen) if newest_id_seen else None, + oldest_covered_at.isoformat() if oldest_covered_at else None, + int(backlog_complete), + ), ) def get_all_reactions(self) -> list: diff --git a/bot/undo.py b/bot/undo.py index 2f2b5e9..25c72d8 100644 --- a/bot/undo.py +++ b/bot/undo.py @@ -25,6 +25,7 @@ async def undo_all_reactions(client: discord.Client, storage: Storage, display: should_clear_log = True if display is not None: + display.set_active_target(row["target_id"]) display.set_status(f"Removing reaction from message {message_id} ({i}/{total})...") try: @@ -46,10 +47,13 @@ async def undo_all_reactions(client: discord.Client, storage: Storage, display: delay = human_jitter() if display is not None: - display.set_status(f"Waiting {delay:.1f}s before next removal...") + display.start_countdown(delay, "next removal") await asyncio.sleep(delay) + if display is not None: + display.clear_countdown() if display is not None: + display.set_active_target(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) diff --git a/main.py b/main.py index 34197e3..812d086 100644 --- a/main.py +++ b/main.py @@ -80,6 +80,7 @@ class ReactorClient(discord.Client): ) for target in targets: + self.display.set_active_target(target.target_id) if target.target_type in ("group_dm", "dm"): channel = self.get_channel(target.target_id) if channel is None: @@ -98,6 +99,7 @@ class ReactorClient(discord.Client): cutoff, react_fn, display=self.display, ) + self.display.set_active_target(None) log.info("Backlog scan complete. Now watching live.") self.display.set_status("Backlog scan complete — watching live for new messages...") @@ -110,11 +112,13 @@ class ReactorClient(discord.Client): if message.author.id != self.cfg.friend_id: return chan_label = getattr(message.channel, "name", None) or target.name + self.display.set_active_target(target.target_id) 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, ) + self.display.set_active_target(None) def parse_args():