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.
This commit is contained in:
2026-08-01 18:53:48 +02:00
parent ab0afa149a
commit 7b3c358b05
5 changed files with 57 additions and 11 deletions

View File

@@ -42,6 +42,10 @@ async def scan_channel_backlog(
channel_id = getattr(channel, "id", None) channel_id = getattr(channel, "id", None)
if channel_id is None: if channel_id is None:
return 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 = [] found_messages = []
pending_ids = await asyncio.to_thread(storage.get_pending_found_message_ids, channel_id, emoji) 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 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 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: try:
async for message in channel.history(after=after, oldest_first=True, limit=None): 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:
@@ -68,6 +75,7 @@ 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...")
await display.refresh() await display.refresh()
await asyncio.to_thread(storage.set_scan_state, channel_id, target.target_id, newest_id_seen, True) 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) log.exception("HTTP error scanning channel %s", channel_id)
return 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) await react_fn(message, target)

View File

@@ -1,18 +1,19 @@
import asyncio import asyncio
from typing import List, Optional from typing import List, Optional
from rich.console import Console 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.table import Table from rich.table import Table
from rich.text import Text
from bot.storage import Storage 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) -> Panel: def _render(stats: List[dict], friend_label: Optional[str] = None, status: 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")
@@ -40,8 +41,12 @@ def _render(stats: List[dict], friend_label: Optional[str] = None) -> Panel:
if not stats: if not stats:
table.add_row("(no targets discovered yet)", "-", "-", "-", "-") 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 "" 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: class StatsDisplay:
@@ -52,8 +57,10 @@ class StatsDisplay:
self.console = console self.console = console
self.storage = storage self.storage = storage
self.friend_label: Optional[str] = None self.friend_label: Optional[str] = None
self.status: str = "Starting..."
self._last_stats: List[dict] = []
self._live = Live( self._live = Live(
_render([]), _render([], None, self.status),
console=console, console=console,
refresh_per_second=4, refresh_per_second=4,
transient=False, transient=False,
@@ -69,10 +76,16 @@ class StatsDisplay:
def set_friend(self, label: str) -> None: def set_friend(self, label: str) -> None:
self.friend_label = label 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: def refresh_sync(self) -> None:
stats = self.storage.get_stats() self._last_stats = self.storage.get_stats()
self._live.update(_render(stats, self.friend_label)) self._live.update(_render(self._last_stats, self.friend_label, self.status))
async def refresh(self) -> None: async def refresh(self) -> None:
stats = await asyncio.to_thread(self.storage.get_stats) self._last_stats = await asyncio.to_thread(self.storage.get_stats)
self._live.update(_render(stats, self.friend_label)) self._live.update(_render(self._last_stats, self.friend_label, self.status))

View File

@@ -46,8 +46,14 @@ async def react_to_message(
if already_reacted: if already_reacted:
return False 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: try:
await message.add_reaction(emoji) await message.add_reaction(emoji)
except discord.HTTPException: except discord.HTTPException:

View File

@@ -24,6 +24,9 @@ async def undo_all_reactions(client: discord.Client, storage: Storage, display:
emoji = row["emoji"] emoji = row["emoji"]
should_clear_log = True should_clear_log = True
if display is not None:
display.set_status(f"Removing reaction from message {message_id} ({i}/{total})...")
try: try:
channel = client.get_channel(channel_id) or await client.fetch_channel(channel_id) channel = client.get_channel(channel_id) or await client.fetch_channel(channel_id)
message = await channel.fetch_message(message_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: if display is not None:
await display.refresh() 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) 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)
if display is not None: if display is not None:
display.set_status("Undo complete.")
await display.refresh() await display.refresh()
log.info( log.info(
"Undo complete: removed %d/%d reaction(s), cleared %d channel resume point(s) — " "Undo complete: removed %d/%d reaction(s), cleared %d channel resume point(s) — "

View File

@@ -44,6 +44,7 @@ class ReactorClient(discord.Client):
await self.close() await self.close()
async def _run_backlog_then_watch(self): async def _run_backlog_then_watch(self):
self.display.set_status(f"Resolving friend id {self.cfg.friend_id}...")
try: try:
friend = await resolve_friend(self, self.cfg.friend_id) friend = await resolve_friend(self, self.cfg.friend_id)
except discord.NotFound: 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.", "friend_id %s does not resolve to any Discord user. Check config.json and fix it.",
self.cfg.friend_id, self.cfg.friend_id,
) )
self.display.set_status("Failed: friend_id does not resolve to a Discord user.")
return return
log.info("Targeting friend: %s (%s)", friend, friend.id) log.info("Targeting friend: %s (%s)", friend, friend.id)
self.display.set_friend(str(friend)) self.display.set_friend(str(friend))
self.display.set_status(f"Discovering mutual servers/groups with {friend}...")
await self.display.refresh() await self.display.refresh()
targets = await discover_targets(self, friend, self.cfg.manual_guild_ids) 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]) pruned = await asyncio.to_thread(self.storage.prune_targets, [t.target_id for t in targets])
if pruned: if pruned:
log.info("Pruned %d stale target(s) no longer mutual/discovered", 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() await self.display.refresh()
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=self.cfg.days_back) 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.") 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): async def on_message(self, message: discord.Message):
if self.undo: if self.undo:
@@ -104,6 +109,8 @@ class ReactorClient(discord.Client):
return return
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
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,
@@ -133,6 +140,7 @@ def main():
storage = Storage(cfg.db_path) storage = Storage(cfg.db_path)
display = StatsDisplay(console, storage) display = StatsDisplay(console, storage)
display.set_status("Connecting to Discord...")
with display: with display:
client = ReactorClient(cfg, storage, display, undo=args.undo) client = ReactorClient(cfg, storage, display, undo=args.undo)