From 02ea81d0594c5719568a8cdbd2c02f0d8c06b806 Mon Sep 17 00:00:00 2001 From: user Date: Sun, 15 Feb 2026 13:03:35 +0100 Subject: [PATCH] fix: debounce WHO on JOIN to prevent flood on netsplit recovery WHO doesn't support multiple targets (absent from TARGMAX on all major IRCds). Replace per-nick WHO with a debounced per-channel WHO: on JOIN, schedule WHO #channel after 2s delay. Subsequent JOINs within the window reset the timer, so a netsplit producing dozens of JOINs results in a single WHO. Co-Authored-By: Claude Opus 4.6 --- src/derp/bot.py | 20 ++++++++++++++++++-- tests/test_integration.py | 25 ++++++++++++++++++++----- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/derp/bot.py b/src/derp/bot.py index 6483819..056d6b9 100644 --- a/src/derp/bot.py +++ b/src/derp/bot.py @@ -87,6 +87,7 @@ class Bot: self._admins: list[str] = config.get("bot", {}).get("admins", []) self._opers: set[str] = set() # hostmasks of known IRC operators self._caps: set[str] = set() # negotiated IRCv3 caps + self._who_pending: dict[str, asyncio.Task] = {} # debounced WHO per channel self.state = StateStore() # Rate limiter: default 2 msg/sec, burst of 5 rate_cfg = config.get("bot", {}) @@ -265,11 +266,16 @@ class Bot: if "*" in flags: self._opers.add(f"{nick}!{user}@{host}") - # JOIN — WHO the joining user to detect oper status + # JOIN — debounced WHO to detect oper status without flooding if msg.command == "JOIN" and msg.nick and msg.nick != self.nick: channel = msg.params[0] if msg.params else "" if channel: - await self.conn.send(format_msg("WHO", msg.nick)) + existing = self._who_pending.get(channel) + if existing and not existing.done(): + existing.cancel() + self._who_pending[channel] = self._spawn( + self._delayed_who(channel), name=f"who:{channel}", + ) # QUIT — remove departed nicks from oper set if msg.command == "QUIT" and msg.prefix: @@ -298,6 +304,16 @@ class Bot: if msg.command == "PRIVMSG" and msg.text: self._dispatch_command(msg) + async def _delayed_who(self, channel: str, delay: float = 2.0) -> None: + """Send WHO after a delay, debouncing rapid JOINs (e.g. netsplit).""" + try: + await asyncio.sleep(delay) + await self.conn.send(format_msg("WHO", channel)) + except asyncio.CancelledError: + pass + finally: + self._who_pending.pop(channel, None) + async def _handle_ctcp(self, msg: Message) -> None: """Respond to CTCP VERSION, TIME, and PING queries.""" text = msg.text.strip("\x01") diff --git a/tests/test_integration.py b/tests/test_integration.py index 940e464..417a769 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -293,12 +293,12 @@ class TestAdmin: assert any("Opers:" in r for r in replies) def test_oper_detection_on_join(self): - """User joining a channel triggers WHO; oper detected mid-session.""" + """User joining a channel triggers debounced WHO for oper detection.""" h = _Harness(channels=["#test"]) h.inject_registration() - # User joins the channel after the bot is already connected + # User joins after the bot is connected h.conn.inject(":oper!oper@operhost JOIN #test") - # Server responds to the WHO triggered by the JOIN + # Server responds to the debounced WHO #channel h.conn.inject( ":server 352 test #test oper operhost irc.server oper H* " ":0 Oper Name" @@ -306,13 +306,28 @@ class TestAdmin: h.privmsg("oper", "#test", "!admins", user="oper", host="operhost") asyncio.run(h.run()) - # Verify WHO was sent for the joining nick - who_sent = [s for s in h.conn.sent if "WHO" in s and "oper" in s] + # Verify debounced WHO was sent for the channel (not the nick) + who_sent = [s for s in h.conn.sent if "WHO" in s and "#test" in s] assert len(who_sent) >= 1 + # Oper detected from the 352 reply replies = h.sent_privmsgs("#test") assert not any("Permission denied" in r for r in replies) assert any("oper!oper@operhost" in r for r in replies) + def test_join_who_debounce(self): + """Multiple rapid JOINs produce only one WHO per channel.""" + h = _Harness(channels=["#test"]) + h.inject_registration() + # Simulate netsplit recovery: many users rejoin at once + for nick in ("alice", "bob", "carol", "dave", "eve"): + h.conn.inject(f":{nick}!{nick}@host JOIN #test") + asyncio.run(h.run()) + + # Only the initial connect WHO + one debounced WHO, not 5 + who_lines = [s for s in h.conn.sent + if "WHO" in s and "#test" in s] + assert len(who_lines) == 2 # 001 connect + 1 debounced + # -- Channel filter ---------------------------------------------------------