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.
120 lines
5.1 KiB
Python
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)
|