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 <noreply@anthropic.com>
This commit is contained in:
@@ -87,6 +87,7 @@ class Bot:
|
|||||||
self._admins: list[str] = config.get("bot", {}).get("admins", [])
|
self._admins: list[str] = config.get("bot", {}).get("admins", [])
|
||||||
self._opers: set[str] = set() # hostmasks of known IRC operators
|
self._opers: set[str] = set() # hostmasks of known IRC operators
|
||||||
self._caps: set[str] = set() # negotiated IRCv3 caps
|
self._caps: set[str] = set() # negotiated IRCv3 caps
|
||||||
|
self._who_pending: dict[str, asyncio.Task] = {} # debounced WHO per channel
|
||||||
self.state = StateStore()
|
self.state = StateStore()
|
||||||
# Rate limiter: default 2 msg/sec, burst of 5
|
# Rate limiter: default 2 msg/sec, burst of 5
|
||||||
rate_cfg = config.get("bot", {})
|
rate_cfg = config.get("bot", {})
|
||||||
@@ -265,11 +266,16 @@ class Bot:
|
|||||||
if "*" in flags:
|
if "*" in flags:
|
||||||
self._opers.add(f"{nick}!{user}@{host}")
|
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:
|
if msg.command == "JOIN" and msg.nick and msg.nick != self.nick:
|
||||||
channel = msg.params[0] if msg.params else ""
|
channel = msg.params[0] if msg.params else ""
|
||||||
if channel:
|
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
|
# QUIT — remove departed nicks from oper set
|
||||||
if msg.command == "QUIT" and msg.prefix:
|
if msg.command == "QUIT" and msg.prefix:
|
||||||
@@ -298,6 +304,16 @@ class Bot:
|
|||||||
if msg.command == "PRIVMSG" and msg.text:
|
if msg.command == "PRIVMSG" and msg.text:
|
||||||
self._dispatch_command(msg)
|
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:
|
async def _handle_ctcp(self, msg: Message) -> None:
|
||||||
"""Respond to CTCP VERSION, TIME, and PING queries."""
|
"""Respond to CTCP VERSION, TIME, and PING queries."""
|
||||||
text = msg.text.strip("\x01")
|
text = msg.text.strip("\x01")
|
||||||
|
|||||||
@@ -293,12 +293,12 @@ class TestAdmin:
|
|||||||
assert any("Opers:" in r for r in replies)
|
assert any("Opers:" in r for r in replies)
|
||||||
|
|
||||||
def test_oper_detection_on_join(self):
|
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 = _Harness(channels=["#test"])
|
||||||
h.inject_registration()
|
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")
|
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(
|
h.conn.inject(
|
||||||
":server 352 test #test oper operhost irc.server oper H* "
|
":server 352 test #test oper operhost irc.server oper H* "
|
||||||
":0 Oper Name"
|
":0 Oper Name"
|
||||||
@@ -306,13 +306,28 @@ class TestAdmin:
|
|||||||
h.privmsg("oper", "#test", "!admins", user="oper", host="operhost")
|
h.privmsg("oper", "#test", "!admins", user="oper", host="operhost")
|
||||||
asyncio.run(h.run())
|
asyncio.run(h.run())
|
||||||
|
|
||||||
# Verify WHO was sent for the joining nick
|
# 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 "oper" in s]
|
who_sent = [s for s in h.conn.sent if "WHO" in s and "#test" in s]
|
||||||
assert len(who_sent) >= 1
|
assert len(who_sent) >= 1
|
||||||
|
# Oper detected from the 352 reply
|
||||||
replies = h.sent_privmsgs("#test")
|
replies = h.sent_privmsgs("#test")
|
||||||
assert not any("Permission denied" in r for r in replies)
|
assert not any("Permission denied" in r for r in replies)
|
||||||
assert any("oper!oper@operhost" 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 ---------------------------------------------------------
|
# -- Channel filter ---------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user