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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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:

View File

@@ -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)

View File

@@ -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():