Files
dc-nogi/bot/backlog.py
Minz ef90ee6a0b 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.
2026-08-01 19:24:20 +02:00

120 lines
5.1 KiB
Python

import asyncio
import datetime
import logging
from typing import Awaitable, Callable, Optional
import discord
from bot.discovery import Target
from bot.display import StatsDisplay
from bot.storage import Storage
log = logging.getLogger(__name__)
ReactFn = Callable[[discord.Message, Target], Awaitable[bool]]
async def scan_channel_backlog(
storage: Storage,
channel: discord.abc.Messageable,
target: Target,
friend_id: int,
emoji: str,
cutoff: datetime.datetime,
react_fn: ReactFn,
display: Optional[StatsDisplay] = None,
) -> None:
"""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
in lockstep with "reacted"), permanently logging each one (deduped by
message ID) so re-scanning it later never inflates the count. Phase 2
then reacts to each one in order.
Before Phase 1 even starts, any message previously logged as found for
this channel but not yet reacted to (e.g. the script was stopped midway
through Phase 2 last time) is recovered from that same log and queued
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:
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)
for message_id in pending_ids:
try:
found_messages.append(await channel.fetch_message(int(message_id)))
except discord.NotFound:
pass # message (or its channel access) is gone since it was found
except discord.HTTPException:
log.exception("Failed to refetch pending message %s in channel %s", message_id, 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
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:
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
if message.author.id == friend_id:
found_messages.append(message)
await asyncio.to_thread(
storage.record_message_found, message.id, channel_id, target.target_id, friend_id
)
if display is not None:
display.set_status(f"{status_verb} {chan_label}{len(found_messages)} found so far...")
await display.refresh()
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
except discord.HTTPException:
log.exception("HTTP error scanning channel %s", channel_id)
return
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)