Add spinner/countdown-bar to stats panel; backfill on widened days_back

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.
This commit is contained in:
2026-08-01 19:24:20 +02:00
parent 7b3c358b05
commit ef90ee6a0b
6 changed files with 159 additions and 31 deletions

View File

@@ -24,7 +24,7 @@ async def scan_channel_backlog(
react_fn: ReactFn, react_fn: ReactFn,
display: Optional[StatsDisplay] = None, display: Optional[StatsDisplay] = None,
) -> 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 Phase 1 finds every message from the friend in the window first (so the
"found" count settles before any reacting starts, rather than climbing "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, up again — this is independent of the newest_id_seen checkpoint below,
so nothing found is ever silently abandoned even if the checkpoint has so nothing found is ever silently abandoned even if the checkpoint has
already moved past it. 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) channel_id = getattr(channel, "id", None)
if channel_id is 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) 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 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
)
async def scan_range(after, before, status_verb: str) -> None:
nonlocal newest_id_seen
if display is not None: if display is not None:
display.set_status(f"Fetching message history for {chan_label}...") display.set_status(f"{status_verb} {chan_label}...")
async for message in channel.history(after=after, before=before, oldest_first=True, limit=None):
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: if newest_id_seen is None or message.id > newest_id_seen:
newest_id_seen = message.id 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 storage.record_message_found, message.id, channel_id, target.target_id, friend_id
) )
if display is not None: 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 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: except discord.Forbidden:
log.warning("No access to channel %s (%s), skipping", channel_id, getattr(channel, "name", "")) log.warning("No access to channel %s (%s), skipping", channel_id, getattr(channel, "name", ""))
return return

View File

@@ -1,10 +1,12 @@
import asyncio import asyncio
import time
from typing import List, Optional from typing import List, Optional
from rich.console import Console, Group from rich.console import Console, Group
from rich.live import Live from rich.live import Live
from rich.panel import Panel from rich.panel import Panel
from rich.progress_bar import ProgressBar from rich.progress_bar import ProgressBar
from rich.spinner import Spinner
from rich.table import Table from rich.table import Table
from rich.text import Text from rich.text import Text
@@ -13,7 +15,36 @@ from bot.storage import Storage
_TYPE_LABELS = {"guild": "server", "group_dm": "group", "dm": "DM"} _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 = Table(expand=True)
table.add_column("Target") table.add_column("Target")
table.add_column("Type") table.add_column("Type")
@@ -29,9 +60,15 @@ def _render(stats: List[dict], friend_label: Optional[str] = None, status: Optio
total_reacted += reacted total_reacted += reacted
total_found += found 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) bar = ProgressBar(total=max(found, reacted, 1), completed=reacted, width=None)
table.add_row( table.add_row(
row["name"] or row["target_id"], target_cell,
_TYPE_LABELS.get(row["target_type"], row["target_type"]), _TYPE_LABELS.get(row["target_type"], row["target_type"]),
bar, bar,
f"{reacted}/{found}", f"{reacted}/{found}",
@@ -41,14 +78,25 @@ def _render(stats: List[dict], friend_label: Optional[str] = None, status: Optio
if not stats: if not stats:
table.add_row("(no targets discovered yet)", "-", "-", "-", "-") table.add_row("(no targets discovered yet)", "-", "-", "-", "-")
content = table # Status/countdown rows are always present (even blank) so the panel's
if status: # height never changes as they toggle on/off — that jumping is jarring
content = Group(table, Text(f"{status}", style="dim italic")) # 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 "" 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") 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: class StatsDisplay:
"""A stats panel pinned to the bottom of the terminal, with normal """A stats panel pinned to the bottom of the terminal, with normal
log/print output scrolling above it (via rich.live.Live).""" log/print output scrolling above it (via rich.live.Live)."""
@@ -58,11 +106,14 @@ class StatsDisplay:
self.storage = storage self.storage = storage
self.friend_label: Optional[str] = None self.friend_label: Optional[str] = None
self.status: str = "Starting..." 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._last_stats: List[dict] = []
self._live = Live( self._live = Live(
_render([], None, self.status), _render([], None, self.status),
console=console, console=console,
refresh_per_second=4, refresh_per_second=8,
transient=False, transient=False,
) )
@@ -73,19 +124,48 @@ class StatsDisplay:
def __exit__(self, *exc_info): def __exit__(self, *exc_info):
return self._live.__exit__(*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: def set_friend(self, label: str) -> None:
self.friend_label = label 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: def set_status(self, text: str) -> None:
"""Update the "what's happening right now" line without hitting the """Update the "what's happening right now" line without hitting the
DB — cheap enough to call for every jittered wait / API call.""" DB — cheap enough to call for every jittered wait / API call."""
self.status = text 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: def refresh_sync(self) -> None:
self._last_stats = self.storage.get_stats() 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: async def refresh(self) -> None:
self._last_stats = await asyncio.to_thread(self.storage.get_stats) 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()

View File

@@ -49,10 +49,11 @@ async def react_to_message(
chan_label = getattr(message.channel, "name", None) or target.name chan_label = getattr(message.channel, "name", None) or target.name
delay = human_jitter() delay = human_jitter()
if display is not None: 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) await asyncio.sleep(delay)
if display is not None: if display is not None:
display.clear_countdown()
display.set_status(f"Sending reaction in {chan_label}...") display.set_status(f"Sending reaction in {chan_label}...")
try: try:
await message.add_reaction(emoji) await message.add_reaction(emoji)

View File

@@ -30,6 +30,7 @@ CREATE TABLE IF NOT EXISTS channel_scan_state (
channel_id TEXT PRIMARY KEY, channel_id TEXT PRIMARY KEY,
target_id TEXT NOT NULL, target_id TEXT NOT NULL,
newest_id_seen TEXT, newest_id_seen TEXT,
oldest_covered_at TEXT,
backlog_complete INTEGER NOT NULL DEFAULT 0, backlog_complete INTEGER NOT NULL DEFAULT 0,
last_scanned_at TEXT last_scanned_at TEXT
); );
@@ -64,6 +65,12 @@ class Storage:
if "message_created_at" not in columns: if "message_created_at" not in columns:
self._conn.execute("ALTER TABLE reactions_log ADD COLUMN message_created_at TEXT") 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 # Older DBs created before 'dm' targets (1:1 DMs) existed have a CHECK
# constraint that only allows 'guild'/'group_dm' — rebuild the table # constraint that only allows 'guild'/'group_dm' — rebuild the table
# with the wider constraint, preserving all existing rows. # with the wider constraint, preserving all existing rows.
@@ -185,11 +192,10 @@ class Storage:
return inserted return inserted
def clear_scan_state(self) -> int: def clear_scan_state(self) -> int:
"""Wipe all per-channel resume points. The backlog scan only ever """Wipe all per-channel resume points, forcing a full fresh backlog
consults days_back on a channel's very first scan and otherwise just scan on the next run (widening days_back alone no longer requires
diffs forward from the last-seen message, so this must be cleared this — see oldest_covered_at in scan_channel_backlog — but --undo
whenever reactions are undone (or days_back is widened) to force a still wants a clean slate since it also removes the reactions log)."""
fresh full backlog scan instead of silently skipping everything."""
with self._lock, self._conn: with self._lock, self._conn:
cur = self._conn.execute("DELETE FROM channel_scan_state") cur = self._conn.execute("DELETE FROM channel_scan_state")
return cur.rowcount return cur.rowcount
@@ -203,19 +209,25 @@ class Storage:
row = cur.fetchone() row = cur.fetchone()
return dict(row) if row else None 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: with self._lock, self._conn:
self._conn.execute( self._conn.execute(
""" """
INSERT INTO channel_scan_state INSERT INTO channel_scan_state
(channel_id, target_id, newest_id_seen, backlog_complete, last_scanned_at) (channel_id, target_id, newest_id_seen, oldest_covered_at, backlog_complete, last_scanned_at)
VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
ON CONFLICT(channel_id) DO UPDATE SET ON CONFLICT(channel_id) DO UPDATE SET
newest_id_seen = excluded.newest_id_seen, newest_id_seen = excluded.newest_id_seen,
oldest_covered_at = excluded.oldest_covered_at,
backlog_complete = excluded.backlog_complete, backlog_complete = excluded.backlog_complete,
last_scanned_at = excluded.last_scanned_at 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: def get_all_reactions(self) -> list:

View File

@@ -25,6 +25,7 @@ async def undo_all_reactions(client: discord.Client, storage: Storage, display:
should_clear_log = True should_clear_log = True
if display is not None: if display is not None:
display.set_active_target(row["target_id"])
display.set_status(f"Removing reaction from message {message_id} ({i}/{total})...") display.set_status(f"Removing reaction from message {message_id} ({i}/{total})...")
try: try:
@@ -46,10 +47,13 @@ async def undo_all_reactions(client: discord.Client, storage: Storage, display:
delay = human_jitter() delay = human_jitter()
if display is not None: 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) await asyncio.sleep(delay)
if display is not None:
display.clear_countdown()
if display is not None: if display is not None:
display.set_active_target(None)
display.set_status("Clearing channel resume points and found-message log...") display.set_status("Clearing channel resume points and found-message log...")
cleared = await asyncio.to_thread(storage.clear_scan_state) cleared = await asyncio.to_thread(storage.clear_scan_state)
await asyncio.to_thread(storage.reset_message_counts) await asyncio.to_thread(storage.reset_message_counts)

View File

@@ -80,6 +80,7 @@ class ReactorClient(discord.Client):
) )
for target in targets: for target in targets:
self.display.set_active_target(target.target_id)
if target.target_type in ("group_dm", "dm"): if target.target_type in ("group_dm", "dm"):
channel = self.get_channel(target.target_id) channel = self.get_channel(target.target_id)
if channel is None: if channel is None:
@@ -98,6 +99,7 @@ class ReactorClient(discord.Client):
cutoff, react_fn, display=self.display, cutoff, react_fn, display=self.display,
) )
self.display.set_active_target(None)
log.info("Backlog scan complete. Now watching live.") log.info("Backlog scan complete. Now watching live.")
self.display.set_status("Backlog scan complete — watching live for new messages...") 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: if message.author.id != self.cfg.friend_id:
return return
chan_label = getattr(message.channel, "name", None) or target.name 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}...") self.display.set_status(f"New message from friend in {chan_label}...")
await react_to_message( await react_to_message(
self.storage, message, target, self.cfg.friend_id, self.cfg.emoji, self.storage, message, target, self.cfg.friend_id, self.cfg.emoji,
source="live", display=self.display, source="live", display=self.display,
) )
self.display.set_active_target(None)
def parse_args(): def parse_args():