From 3118f7b9d5b0d6bfa3d119a1fd9d3675b61bbb0f Mon Sep 17 00:00:00 2001 From: Minz Date: Sat, 1 Aug 2026 15:09:47 +0200 Subject: [PATCH] Initial commit --- .gitignore | 8 +++ README.md | 31 +++++++++ bot/__init__.py | 0 bot/backlog.py | 62 +++++++++++++++++ bot/channels.py | 14 ++++ bot/config.py | 40 +++++++++++ bot/discovery.py | 52 +++++++++++++++ bot/display.py | 62 +++++++++++++++++ bot/reactor.py | 63 ++++++++++++++++++ bot/storage.py | 157 ++++++++++++++++++++++++++++++++++++++++++++ bot/undo.py | 46 +++++++++++++ config.example.json | 9 +++ main.py | 124 ++++++++++++++++++++++++++++++++++ requirements.txt | 2 + 14 files changed, 670 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bot/__init__.py create mode 100644 bot/backlog.py create mode 100644 bot/channels.py create mode 100644 bot/config.py create mode 100644 bot/discovery.py create mode 100644 bot/display.py create mode 100644 bot/reactor.py create mode 100644 bot/storage.py create mode 100644 bot/undo.py create mode 100644 config.example.json create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..314a80d --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +venv/ +__pycache__/ +*.pyc + +config.json +*.sqlite3 +*.sqlite3-wal +*.sqlite3-shm diff --git a/README.md b/README.md new file mode 100644 index 0000000..109050e --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# dc-nogi + +Does the thing + +## Setup + +1. Install Python 3.10 or newer. +2. Install dependencies: + ``` + pip install -r requirements.txt + ``` +3. Copy `config.example.json` to `config.json` and fill in: + - `token`: your account token (Discord dev tools -> Network tab -> any + request -> `authorization` header, while logged into the Discord web app). + - `friend_id`: your friend's numeric Discord user ID (enable Developer Mode + in Discord settings, right-click their name -> Copy User ID). + - `emoji`: the unicode emoji to react with, e.g. `"🫄"`. + - `days_back`: how many days of history to scan on first run (default 10). + - `manual_guild_ids` (optional): guild IDs to always include, used as a + fallback if the mutual-guilds lookup ever fails or misses a server. +4. Run it: + ``` + python main.py + ``` + +## Usage + +``` +python main.py # run normally: backlog scan, then live watching +python main.py --undo # remove every reaction this script has ever added, then exit +``` diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/backlog.py b/bot/backlog.py new file mode 100644 index 0000000..db28346 --- /dev/null +++ b/bot/backlog.py @@ -0,0 +1,62 @@ +import asyncio +import datetime +import logging +from typing import Awaitable, Callable + +import discord + +from bot.discovery import Target +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, + cutoff: datetime.datetime, + react_fn: ReactFn, +) -> None: + """Scan a channel's history once, resumably. + + First run: walk forward from `cutoff` (now - days_back) to the present. + Subsequent runs: only diff forward from the newest message already seen, + since the days_back window only ever moves forward in time. + """ + channel_id = getattr(channel, "id", None) + if channel_id is None: + return + + 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 + + after = discord.Object(id=newest_id_seen) if newest_id_seen else cutoff + + checkpoint_every = 25 + seen_since_checkpoint = 0 + + try: + 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: + newest_id_seen = message.id + + await react_fn(message, target) + + seen_since_checkpoint += 1 + if seen_since_checkpoint >= checkpoint_every: + seen_since_checkpoint = 0 + await asyncio.to_thread( + storage.set_scan_state, channel_id, target.target_id, newest_id_seen, False + ) + + await asyncio.to_thread( + storage.set_scan_state, channel_id, target.target_id, newest_id_seen, True + ) + except discord.Forbidden: + log.warning("No access to channel %s (%s), skipping", channel_id, getattr(channel, "name", "")) + except discord.HTTPException: + log.exception("HTTP error scanning channel %s", channel_id) diff --git a/bot/channels.py b/bot/channels.py new file mode 100644 index 0000000..692bb62 --- /dev/null +++ b/bot/channels.py @@ -0,0 +1,14 @@ +import logging +from typing import List + +import discord + +log = logging.getLogger(__name__) + + +def get_scannable_channels(client: discord.Client, guild_id: int) -> List[discord.TextChannel]: + guild = client.get_guild(guild_id) + if guild is None: + log.warning("Guild %s not found in local cache, skipping", guild_id) + return [] + return list(guild.text_channels) diff --git a/bot/config.py b/bot/config.py new file mode 100644 index 0000000..b705c35 --- /dev/null +++ b/bot/config.py @@ -0,0 +1,40 @@ +import json +import os +from dataclasses import dataclass, field +from typing import List + + +@dataclass +class Config: + token: str + friend_id: int + emoji: str + days_back: int + db_path: str + log_level: str + manual_guild_ids: List[int] = field(default_factory=list) + + +def load_config(path: str = "config.json") -> Config: + if not os.path.exists(path): + raise FileNotFoundError( + f"Config file not found: {path}. Copy config.example.json to config.json and fill it in." + ) + + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + + required = ["token", "friend_id", "emoji"] + missing = [key for key in required if not data.get(key)] + if missing: + raise ValueError(f"Missing required config fields: {missing}") + + return Config( + token=data["token"], + friend_id=int(data["friend_id"]), + emoji=data["emoji"], + days_back=int(data.get("days_back", 10)), + 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", [])], + ) diff --git a/bot/discovery.py b/bot/discovery.py new file mode 100644 index 0000000..a14979a --- /dev/null +++ b/bot/discovery.py @@ -0,0 +1,52 @@ +import logging +from dataclasses import dataclass +from typing import List, Optional + +import discord + +log = logging.getLogger(__name__) + + +@dataclass +class Target: + target_id: int + target_type: str # "guild" | "group_dm" + name: str + + +async def discover_targets(client: discord.Client, friend_id: int, manual_guild_ids: Optional[List[int]] = None) -> List[Target]: + 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) + name = guild.name if guild else f"Unknown Guild {mutual.id}" + targets.append(Target(mutual.id, "guild", name)) + guild_ids_seen.add(mutual.id) + except discord.NotFound: + log.warning( + "No mutual guild/friend relationship visible for user %s " + "(profile lookup requires already being friends or sharing a server). " + "Relying on manual_guild_ids and group DMs only.", + friend_id, + ) + except discord.HTTPException: + log.warning("Mutual-guilds profile lookup failed; falling back to manual_guild_ids only", exc_info=True) + + for guild_id in (manual_guild_ids or []): + if guild_id in guild_ids_seen: + continue + guild = client.get_guild(guild_id) + name = guild.name if guild else f"Manual Guild {guild_id}" + targets.append(Target(guild_id, "guild", name)) + guild_ids_seen.add(guild_id) + + 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) + targets.append(Target(channel.id, "group_dm", name)) + + return targets diff --git a/bot/display.py b/bot/display.py new file mode 100644 index 0000000..d49ffbe --- /dev/null +++ b/bot/display.py @@ -0,0 +1,62 @@ +import asyncio +from typing import List + +from rich.console import Console +from rich.live import Live +from rich.panel import Panel +from rich.table import Table + +from bot.storage import Storage + + +def _render(stats: List[dict]) -> Panel: + table = Table(expand=True) + table.add_column("Target") + table.add_column("Type") + table.add_column("Reactions", justify="right") + table.add_column("Last reaction (UTC)") + + total = 0 + for row in stats: + total += row["reaction_count"] + table.add_row( + row["name"] or row["target_id"], + "server" if row["target_type"] == "guild" else "group", + str(row["reaction_count"]), + row["last_reaction_at"] or "-", + ) + + if not stats: + table.add_row("(no targets discovered yet)", "-", "-", "-") + + return Panel(table, title=f"Reaction stats — total: {total}", border_style="cyan") + + +class StatsDisplay: + """A stats panel pinned to the bottom of the terminal, with normal + log/print output scrolling above it (via rich.live.Live).""" + + def __init__(self, console: Console, storage: Storage): + self.console = console + self.storage = storage + self._live = Live( + _render([]), + console=console, + refresh_per_second=4, + transient=False, + ) + + def __enter__(self): + self._live.__enter__() + return self + + def __exit__(self, *exc_info): + return self._live.__exit__(*exc_info) + + def refresh_sync(self) -> None: + stats = self.storage.get_stats() + self._live.update(_render(stats)) + + async def refresh(self) -> None: + stats = await asyncio.to_thread(self.storage.get_stats) + self._live.update(_render(stats)) diff --git a/bot/reactor.py b/bot/reactor.py new file mode 100644 index 0000000..bb89e33 --- /dev/null +++ b/bot/reactor.py @@ -0,0 +1,63 @@ +import asyncio +import logging +import random +from typing import Optional + +import discord + +from bot.discovery import Target +from bot.display import StatsDisplay +from bot.storage import Storage + +log = logging.getLogger(__name__) + + +def human_jitter() -> float: + """Gaussian delay centered a few seconds in, clamped to a natural-looking range.""" + delay = random.gauss(4.0, 1.5) + return max(1.5, min(delay, 8.0)) + + +async def react_to_message( + storage: Storage, + message: discord.Message, + target: Target, + friend_id: int, + emoji: str, + source: str, + display: Optional[StatsDisplay] = None, +) -> bool: + if message.author.id != friend_id: + return False + + already_reacted = any( + reaction.me and str(reaction.emoji) == emoji for reaction in message.reactions + ) + if not already_reacted: + already_reacted = await asyncio.to_thread(storage.has_reacted, message.id, emoji) + if already_reacted: + return False + + await asyncio.sleep(human_jitter()) + + try: + await message.add_reaction(emoji) + except discord.HTTPException: + log.exception("Failed to react to message %s in channel %s", message.id, message.channel.id) + return False + + inserted = await asyncio.to_thread( + storage.record_reaction, + message.id, + message.channel.id, + target.target_id, + friend_id, + emoji, + source, + message.created_at, + ) + if inserted: + log.info("Reacted to message %s in %s [%s] (%s)", message.id, target.name, target.target_type, source) + if display is not None: + await display.refresh() + return inserted diff --git a/bot/storage.py b/bot/storage.py new file mode 100644 index 0000000..f8025b1 --- /dev/null +++ b/bot/storage.py @@ -0,0 +1,157 @@ +import sqlite3 +import threading +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')), + name TEXT, + first_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + reaction_count INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS reactions_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + target_id TEXT NOT NULL, + author_id TEXT NOT NULL, + emoji TEXT NOT NULL, + reacted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + message_created_at TEXT, + source TEXT NOT NULL CHECK(source IN ('backlog','live')), + UNIQUE(message_id, emoji) +); +CREATE INDEX IF NOT EXISTS idx_reactions_log_target ON reactions_log(target_id); + +CREATE TABLE IF NOT EXISTS channel_scan_state ( + channel_id TEXT PRIMARY KEY, + target_id TEXT NOT NULL, + newest_id_seen TEXT, + backlog_complete INTEGER NOT NULL DEFAULT 0, + last_scanned_at TEXT +); +""" + + +class Storage: + """Thin synchronous sqlite3 wrapper. Callers from async code should + dispatch through asyncio.to_thread() to avoid blocking the event loop.""" + + def __init__(self, db_path: str): + self._conn = sqlite3.connect(db_path, check_same_thread=False) + self._conn.row_factory = sqlite3.Row + self._lock = threading.Lock() + with self._lock, self._conn: + self._conn.execute("PRAGMA journal_mode=WAL") + self._conn.executescript(SCHEMA) + self._migrate() + + def _migrate(self) -> None: + # Older DBs created before message_created_at existed. + columns = {row["name"] for row in self._conn.execute("PRAGMA table_info(reactions_log)")} + if "message_created_at" not in columns: + self._conn.execute("ALTER TABLE reactions_log ADD COLUMN message_created_at TEXT") + + def upsert_target(self, target_id, target_type: str, name: str) -> None: + with self._lock, self._conn: + self._conn.execute( + """ + INSERT INTO targets (target_id, target_type, name) + VALUES (?, ?, ?) + ON CONFLICT(target_id) DO UPDATE SET name = excluded.name + """, + (str(target_id), target_type, name), + ) + + def has_reacted(self, message_id, emoji: str) -> bool: + with self._lock: + cur = self._conn.execute( + "SELECT 1 FROM reactions_log WHERE message_id = ? AND emoji = ? LIMIT 1", + (str(message_id), emoji), + ) + return cur.fetchone() is not None + + def record_reaction( + self, message_id, channel_id, target_id, author_id, emoji: str, source: str, message_created_at=None + ) -> bool: + created_at = message_created_at.strftime("%Y-%m-%dT%H:%M:%fZ") if message_created_at else None + with self._lock, self._conn: + cur = self._conn.execute( + """ + INSERT OR IGNORE INTO reactions_log + (message_id, channel_id, target_id, author_id, emoji, source, message_created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (str(message_id), str(channel_id), str(target_id), str(author_id), emoji, source, created_at), + ) + inserted = cur.rowcount > 0 + if inserted: + self._conn.execute( + "UPDATE targets SET reaction_count = reaction_count + 1 WHERE target_id = ?", + (str(target_id),), + ) + return inserted + + def get_scan_state(self, channel_id) -> Optional[dict]: + with self._lock: + cur = self._conn.execute( + "SELECT * FROM channel_scan_state WHERE channel_id = ?", + (str(channel_id),), + ) + row = cur.fetchone() + return dict(row) if row else None + + def set_scan_state(self, channel_id, target_id, newest_id_seen, backlog_complete: bool) -> None: + with self._lock, self._conn: + self._conn.execute( + """ + INSERT INTO channel_scan_state + (channel_id, target_id, newest_id_seen, backlog_complete, last_scanned_at) + VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ON CONFLICT(channel_id) DO UPDATE SET + newest_id_seen = excluded.newest_id_seen, + backlog_complete = excluded.backlog_complete, + last_scanned_at = excluded.last_scanned_at + """, + (str(channel_id), str(target_id), str(newest_id_seen) if newest_id_seen else None, int(backlog_complete)), + ) + + def get_all_reactions(self) -> list: + with self._lock: + cur = self._conn.execute( + "SELECT id, message_id, channel_id, target_id, emoji FROM reactions_log ORDER BY id" + ) + return [dict(row) for row in cur.fetchall()] + + def delete_reaction(self, row_id: int) -> None: + with self._lock, self._conn: + row = self._conn.execute( + "SELECT target_id FROM reactions_log WHERE id = ?", (row_id,) + ).fetchone() + self._conn.execute("DELETE FROM reactions_log WHERE id = ?", (row_id,)) + if row: + self._conn.execute( + "UPDATE targets SET reaction_count = MAX(reaction_count - 1, 0) WHERE target_id = ?", + (row["target_id"],), + ) + + def get_stats(self) -> list: + with self._lock: + cur = self._conn.execute( + """ + SELECT t.target_id, t.target_type, t.name, t.reaction_count, + COUNT(r.id) AS logged_reactions, MAX(r.message_created_at) AS last_reaction_at + FROM targets t + LEFT JOIN reactions_log r ON r.target_id = t.target_id + GROUP BY t.target_id + ORDER BY t.reaction_count DESC + """ + ) + return [dict(row) for row in cur.fetchall()] + + def close(self) -> None: + with self._lock: + self._conn.close() diff --git a/bot/undo.py b/bot/undo.py new file mode 100644 index 0000000..722389a --- /dev/null +++ b/bot/undo.py @@ -0,0 +1,46 @@ +import asyncio +import logging +from typing import Optional + +import discord + +from bot.display import StatsDisplay +from bot.reactor import human_jitter +from bot.storage import Storage + +log = logging.getLogger(__name__) + + +async def undo_all_reactions(client: discord.Client, storage: Storage, display: Optional[StatsDisplay] = None) -> None: + """Remove every reaction this script has previously logged, then clear those log entries.""" + rows = await asyncio.to_thread(storage.get_all_reactions) + total = len(rows) + log.info("Undo: removing %d previously-added reaction(s)...", total) + + removed = 0 + for i, row in enumerate(rows, start=1): + channel_id = int(row["channel_id"]) + message_id = int(row["message_id"]) + emoji = row["emoji"] + should_clear_log = True + + try: + channel = client.get_channel(channel_id) or await client.fetch_channel(channel_id) + message = await channel.fetch_message(message_id) + await message.remove_reaction(emoji, client.user) + removed += 1 + log.info("[%d/%d] Removed reaction from message %s", i, total, message_id) + except discord.NotFound: + log.info("[%d/%d] Message/reaction %s already gone, clearing log entry", i, total, message_id) + except discord.HTTPException: + log.exception("[%d/%d] Failed to remove reaction from message %s, will retry next run", i, total, message_id) + should_clear_log = False + + if should_clear_log: + await asyncio.to_thread(storage.delete_reaction, row["id"]) + if display is not None: + await display.refresh() + + await asyncio.sleep(human_jitter()) + + log.info("Undo complete: removed %d/%d reaction(s)", removed, total) diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..bfc13bd --- /dev/null +++ b/config.example.json @@ -0,0 +1,9 @@ +{ + "token": "your-account-token-here", + "friend_id": "123456789012345678", + "emoji": "👍", + "days_back": 10, + "db_path": "reactions.sqlite3", + "log_level": "INFO", + "manual_guild_ids": [] +} diff --git a/main.py b/main.py new file mode 100644 index 0000000..8b5d436 --- /dev/null +++ b/main.py @@ -0,0 +1,124 @@ +import argparse +import asyncio +import datetime +import logging + +import discord +from rich.console import Console +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.display import StatsDisplay +from bot.reactor import react_to_message +from bot.storage import Storage +from bot.undo import undo_all_reactions + +log = logging.getLogger("dc_reactor") + + +class ReactorClient(discord.Client): + def __init__(self, cfg, storage: Storage, display: StatsDisplay, undo: bool = False, **kwargs): + super().__init__(**kwargs) + self.cfg = cfg + self.storage = storage + self.display = display + self.undo = undo + self.known_channel_targets: dict[int, Target] = {} + self._started = False + + async def on_ready(self): + log.info("Logged in as %s (%s)", self.user, self.user.id) + if self._started: + return + self._started = True + if self.undo: + self.loop.create_task(self._run_undo()) + else: + self.loop.create_task(self._run_backlog_then_watch()) + + async def _run_undo(self): + await undo_all_reactions(self, self.storage, display=self.display) + await self.close() + + async def _run_backlog_then_watch(self): + targets = await discover_targets(self, self.cfg.friend_id, 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) + await self.display.refresh() + + cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=self.cfg.days_back) + + 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, + ) + + for target in targets: + if target.target_type == "group_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) + continue + self.known_channel_targets[channel.id] = target + await scan_channel_backlog(self.storage, channel, target, self.cfg.friend_id, cutoff, react_fn) + else: + for channel in get_scannable_channels(self, target.target_id): + self.known_channel_targets[channel.id] = target + await scan_channel_backlog(self.storage, channel, target, self.cfg.friend_id, cutoff, react_fn) + + log.info("Backlog scan complete. Now watching live.") + + async def on_message(self, message: discord.Message): + if self.undo: + return + target = self.known_channel_targets.get(message.channel.id) + if target is None: + return + if message.author.id != self.cfg.friend_id: + return + await react_to_message( + self.storage, message, target, self.cfg.friend_id, self.cfg.emoji, + source="live", display=self.display, + ) + + +def parse_args(): + parser = argparse.ArgumentParser(description="React to a friend's messages across mutual servers/groups.") + parser.add_argument( + "--undo", + action="store_true", + help="Remove every reaction previously added by this script (per the local database log) and exit.", + ) + return parser.parse_args() + + +def main(): + args = parse_args() + cfg = load_config() + + console = Console() + logging.basicConfig( + level=cfg.log_level, + format="%(message)s", + handlers=[RichHandler(console=console, show_path=False, rich_tracebacks=True)], + ) + + storage = Storage(cfg.db_path) + display = StatsDisplay(console, storage) + + with display: + client = ReactorClient(cfg, storage, display, undo=args.undo) + try: + client.run(cfg.token, log_handler=None) + finally: + storage.close() + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8b6446f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +discord.py-self>=2.0.0 +rich>=13.0.0