Add DM support, friend identification, and target pruning
React in 1:1 DMs alongside servers/groups, surface the resolved friend username at startup and in the stats panel, verify reactions actually stick after adding them (with jump links for manual spot-checking), and prune stale targets from stats on each run so only the currently mutual set is shown.
This commit is contained in:
@@ -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)),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"days_back": 10,
|
||||
"db_path": "reactions.sqlite3",
|
||||
"log_level": "INFO",
|
||||
"manual_guild_ids": []
|
||||
"manual_guild_ids": [],
|
||||
"verify_reactions": true
|
||||
}
|
||||
|
||||
28
main.py
28
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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user