diff --git a/bot/config.py b/bot/config.py index b705c35..efa4b5b 100644 --- a/bot/config.py +++ b/bot/config.py @@ -13,6 +13,7 @@ class Config: db_path: str log_level: str manual_guild_ids: List[int] = field(default_factory=list) + verify_reactions: bool = True def load_config(path: str = "config.json") -> Config: @@ -37,4 +38,5 @@ def load_config(path: str = "config.json") -> Config: db_path=data.get("db_path", "reactions.sqlite3"), log_level=data.get("log_level", "INFO"), manual_guild_ids=[int(g) for g in data.get("manual_guild_ids", [])], + verify_reactions=bool(data.get("verify_reactions", True)), ) diff --git a/bot/discovery.py b/bot/discovery.py index a14979a..fd820ae 100644 --- a/bot/discovery.py +++ b/bot/discovery.py @@ -10,16 +10,27 @@ log = logging.getLogger(__name__) @dataclass class Target: target_id: int - target_type: str # "guild" | "group_dm" + target_type: str # "guild" | "group_dm" | "dm" name: str -async def discover_targets(client: discord.Client, friend_id: int, manual_guild_ids: Optional[List[int]] = None) -> List[Target]: +async def resolve_friend(client: discord.Client, friend_id: int) -> discord.User: + """Fetch the target user object so we can display who the script is + actually reacting to (helps catch a wrong/typo'd friend_id early).""" + user = client.get_user(friend_id) + if user is None: + user = await client.fetch_user(friend_id) + return user + + +async def discover_targets( + client: discord.Client, friend: discord.User, manual_guild_ids: Optional[List[int]] = None +) -> List[Target]: + friend_id = friend.id targets: List[Target] = [] guild_ids_seen = set() try: - friend = client.get_user(friend_id) or await client.fetch_user(friend_id) profile = await friend.profile(with_mutual_guilds=True, with_mutual_friends=False, with_mutual_friends_count=False) for mutual in (profile.mutual_guilds or []): guild = client.get_guild(mutual.id) @@ -44,6 +55,12 @@ async def discover_targets(client: discord.Client, friend_id: int, manual_guild_ targets.append(Target(guild_id, "guild", name)) guild_ids_seen.add(guild_id) + try: + dm_channel = friend.dm_channel or await friend.create_dm() + targets.append(Target(dm_channel.id, "dm", f"DM with {friend}")) + except discord.HTTPException: + log.warning("Failed to open the 1:1 DM channel with %s", friend, exc_info=True) + for channel in client.private_channels: if isinstance(channel, discord.GroupChannel) and any(u.id == friend_id for u in channel.recipients): name = channel.name or ", ".join(u.name for u in channel.recipients) diff --git a/bot/display.py b/bot/display.py index d49ffbe..c921dc2 100644 --- a/bot/display.py +++ b/bot/display.py @@ -1,5 +1,5 @@ import asyncio -from typing import List +from typing import List, Optional from rich.console import Console from rich.live import Live @@ -8,8 +8,10 @@ from rich.table import Table from bot.storage import Storage +_TYPE_LABELS = {"guild": "server", "group_dm": "group", "dm": "DM"} -def _render(stats: List[dict]) -> Panel: + +def _render(stats: List[dict], friend_label: Optional[str] = None) -> Panel: table = Table(expand=True) table.add_column("Target") table.add_column("Type") @@ -21,7 +23,7 @@ def _render(stats: List[dict]) -> Panel: total += row["reaction_count"] table.add_row( row["name"] or row["target_id"], - "server" if row["target_type"] == "guild" else "group", + _TYPE_LABELS.get(row["target_type"], row["target_type"]), str(row["reaction_count"]), row["last_reaction_at"] or "-", ) @@ -29,7 +31,8 @@ def _render(stats: List[dict]) -> Panel: if not stats: table.add_row("(no targets discovered yet)", "-", "-", "-") - return Panel(table, title=f"Reaction stats — total: {total}", border_style="cyan") + who = f" for {friend_label}" if friend_label else "" + return Panel(table, title=f"Reaction stats{who} — total: {total}", border_style="cyan") class StatsDisplay: @@ -39,6 +42,7 @@ class StatsDisplay: def __init__(self, console: Console, storage: Storage): self.console = console self.storage = storage + self.friend_label: Optional[str] = None self._live = Live( _render([]), console=console, @@ -53,10 +57,13 @@ class StatsDisplay: def __exit__(self, *exc_info): return self._live.__exit__(*exc_info) + def set_friend(self, label: str) -> None: + self.friend_label = label + def refresh_sync(self) -> None: stats = self.storage.get_stats() - self._live.update(_render(stats)) + self._live.update(_render(stats, self.friend_label)) async def refresh(self) -> None: stats = await asyncio.to_thread(self.storage.get_stats) - self._live.update(_render(stats)) + self._live.update(_render(stats, self.friend_label)) diff --git a/bot/reactor.py b/bot/reactor.py index bb89e33..0cd3f46 100644 --- a/bot/reactor.py +++ b/bot/reactor.py @@ -26,6 +26,7 @@ async def react_to_message( emoji: str, source: str, display: Optional[StatsDisplay] = None, + verify: bool = True, ) -> bool: if message.author.id != friend_id: return False @@ -46,6 +47,20 @@ async def react_to_message( log.exception("Failed to react to message %s in channel %s", message.id, message.channel.id) return False + if verify: + try: + refetched = await message.channel.fetch_message(message.id) + confirmed = any(r.me and str(r.emoji) == emoji for r in refetched.reactions) + except discord.HTTPException: + confirmed = None # couldn't verify either way; don't block on it + + if confirmed is False: + log.warning( + "Reaction API call succeeded but is NOT visible on refetch! " + "message=%s channel=%s target=%s (%s) url=%s", + message.id, message.channel.id, target.name, target.target_type, message.jump_url, + ) + inserted = await asyncio.to_thread( storage.record_reaction, message.id, @@ -57,7 +72,10 @@ async def react_to_message( message.created_at, ) if inserted: - log.info("Reacted to message %s in %s [%s] (%s)", message.id, target.name, target.target_type, source) + log.info( + "Reacted to message in %s [%s] (%s): %s", + target.name, target.target_type, source, message.jump_url, + ) if display is not None: await display.refresh() return inserted diff --git a/bot/storage.py b/bot/storage.py index f8025b1..6112f6f 100644 --- a/bot/storage.py +++ b/bot/storage.py @@ -6,7 +6,7 @@ from typing import Optional SCHEMA = """ CREATE TABLE IF NOT EXISTS targets ( target_id TEXT PRIMARY KEY, - target_type TEXT NOT NULL CHECK(target_type IN ('guild','group_dm')), + target_type TEXT NOT NULL CHECK(target_type IN ('guild','group_dm','dm')), name TEXT, first_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), reaction_count INTEGER NOT NULL DEFAULT 0 @@ -55,6 +55,28 @@ 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 '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. + targets_sql = self._conn.execute( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='targets'" + ).fetchone() + if targets_sql and "'dm'" not in targets_sql["sql"]: + self._conn.executescript( + """ + ALTER TABLE targets RENAME TO targets_old; + CREATE TABLE targets ( + target_id TEXT PRIMARY KEY, + target_type TEXT NOT NULL CHECK(target_type IN ('guild','group_dm','dm')), + name TEXT, + first_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + reaction_count INTEGER NOT NULL DEFAULT 0 + ); + INSERT INTO targets SELECT * FROM targets_old; + DROP TABLE targets_old; + """ + ) + def upsert_target(self, target_id, target_type: str, name: str) -> None: with self._lock, self._conn: self._conn.execute( @@ -66,6 +88,21 @@ class Storage: (str(target_id), target_type, name), ) + def prune_targets(self, keep_target_ids: list) -> int: + """Remove targets not present in the latest discovery run (e.g. a server + that's no longer mutual, or a stale entry from an earlier bug/test run). + reactions_log/channel_scan_state rows are left alone for history/undo.""" + keep = [str(t) for t in keep_target_ids] + with self._lock, self._conn: + placeholders = ",".join("?" * len(keep)) if keep else None + if placeholders: + cur = self._conn.execute( + f"DELETE FROM targets WHERE target_id NOT IN ({placeholders})", keep + ) + else: + cur = self._conn.execute("DELETE FROM targets") + return cur.rowcount + def has_reacted(self, message_id, emoji: str) -> bool: with self._lock: cur = self._conn.execute( diff --git a/config.example.json b/config.example.json index bfc13bd..be1c122 100644 --- a/config.example.json +++ b/config.example.json @@ -5,5 +5,6 @@ "days_back": 10, "db_path": "reactions.sqlite3", "log_level": "INFO", - "manual_guild_ids": [] + "manual_guild_ids": [], + "verify_reactions": true } diff --git a/main.py b/main.py index 8b5d436..8b215d4 100644 --- a/main.py +++ b/main.py @@ -10,7 +10,7 @@ from rich.logging import RichHandler from bot.backlog import scan_channel_backlog from bot.channels import get_scannable_channels from bot.config import load_config -from bot.discovery import Target, discover_targets +from bot.discovery import Target, discover_targets, resolve_friend from bot.display import StatsDisplay from bot.reactor import react_to_message from bot.storage import Storage @@ -44,11 +44,27 @@ class ReactorClient(discord.Client): await self.close() async def _run_backlog_then_watch(self): - targets = await discover_targets(self, self.cfg.friend_id, self.cfg.manual_guild_ids) + try: + friend = await resolve_friend(self, self.cfg.friend_id) + except discord.NotFound: + log.error( + "friend_id %s does not resolve to any Discord user. Check config.json and fix it.", + self.cfg.friend_id, + ) + return + + log.info("Targeting friend: %s (%s)", friend, friend.id) + self.display.set_friend(str(friend)) + await self.display.refresh() + + targets = await discover_targets(self, friend, self.cfg.manual_guild_ids) log.info("Discovered %d mutual target(s): %s", len(targets), [t.name for t in targets]) for t in targets: await asyncio.to_thread(self.storage.upsert_target, t.target_id, t.target_type, t.name) + pruned = await asyncio.to_thread(self.storage.prune_targets, [t.target_id for t in targets]) + if pruned: + log.info("Pruned %d stale target(s) no longer mutual/discovered", pruned) await self.display.refresh() cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=self.cfg.days_back) @@ -56,14 +72,14 @@ class ReactorClient(discord.Client): async def react_fn(message: discord.Message, target: Target) -> bool: return await react_to_message( self.storage, message, target, self.cfg.friend_id, self.cfg.emoji, - source="backlog", display=self.display, + source="backlog", display=self.display, verify=self.cfg.verify_reactions, ) for target in targets: - if target.target_type == "group_dm": + if target.target_type in ("group_dm", "dm"): channel = self.get_channel(target.target_id) if channel is None: - log.warning("Group DM %s not found in cache, skipping", target.target_id) + log.warning("Channel %s (%s) not found in cache, skipping", target.target_id, target.name) continue self.known_channel_targets[channel.id] = target await scan_channel_backlog(self.storage, channel, target, self.cfg.friend_id, cutoff, react_fn) @@ -84,7 +100,7 @@ class ReactorClient(discord.Client): return await react_to_message( self.storage, message, target, self.cfg.friend_id, self.cfg.emoji, - source="live", display=self.display, + source="live", display=self.display, verify=self.cfg.verify_reactions, )