diff --git a/fjerkroa_bot/discord_bot.py b/fjerkroa_bot/discord_bot.py index 1c92b84..0faf843 100644 --- a/fjerkroa_bot/discord_bot.py +++ b/fjerkroa_bot/discord_bot.py @@ -1,5 +1,6 @@ import argparse import asyncio +import fnmatch import logging import random import re @@ -481,7 +482,7 @@ class FjerkroaBot(commands.Bot): return fallback_channel if channel_name.startswith("#"): channel_name = channel_name[1:] - if not no_ignore and channel_name in self.config.get("ignore-channels", []): + if not no_ignore and self.channel_ignored(channel_name): return fallback_channel for guild in self.guilds: channel = discord.utils.get(guild.channels, name=channel_name) @@ -494,8 +495,12 @@ class FjerkroaBot(commands.Bot): return str(channel.recipient.name) return str(channel.id) if isinstance(channel, DMChannel) else str(channel.name) + def channel_ignored(self, channel_name) -> bool: + """fnmatch patterns; plain names match exactly as before (BEH-09).""" + return any(fnmatch.fnmatchcase(str(channel_name), pattern) for pattern in self.config.get("ignore-channels", [])) + def ignore_message(self, channel_name, message): - return channel_name in self.config.get("ignore-channels", []) and not message.direct + return self.channel_ignored(channel_name) and not message.direct def log_message_action(self, action, message, channel_name): logging.info(f"{action} message {repr(message)} for channel {channel_name}") @@ -521,6 +526,11 @@ class FjerkroaBot(commands.Bot): async def handle_message_through_responder(self, message): """Handle a message through the AI responder""" + # Ignored channels are fully silent — before the classifier gate, + # so no emoji reaction leaks either (BEH-09). DMs are never ignored. + if not isinstance(message.channel, DMChannel) and self.channel_ignored(self.get_channel_name(message.channel)): + self.log_message_action("ignore", message, self.get_channel_name(message.channel)) + return message_content = str(message.content).strip() if message.reference and message.reference.resolved and isinstance(message.reference.resolved.content, str): reference_content = str(message.reference.resolved.content).replace("\n", "> \n") diff --git a/specs/SPEC-010-behavior.md b/specs/SPEC-010-behavior.md index 824d6d4..93b1c6a 100644 --- a/specs/SPEC-010-behavior.md +++ b/specs/SPEC-010-behavior.md @@ -61,3 +61,15 @@ Within `quiet-hours = "HH:MM-HH:MM"` (host-local, may wrap midnight) `bot_initiated_allowed()` is false: no boreness, later no scheduler posts. Replies to users stay unaffected — a guest asking at 23:30 still gets an answer. + +### BEH-09 — Ignored channels are fully silent (coverage: test) + +Channels matching `ignore-channels` get neither replies nor +classifier emoji reactions: the message handler returns before the +classifier gate, so no model call, no reaction, no history entry. +Entries are fnmatch patterns (`todo*` matches `todo`, `todo-lists`); +plain names keep matching exactly as before. DMs are never ignored. +`channel_by_name` resolution honors the same patterns. (Previously +the ignore check sat only in `respond()`, after the classifier — +emoji reactions leaked into ignored channels, and matching was +exact-name only.) diff --git a/tests/test_spec_beh.py b/tests/test_spec_beh.py index 70ba886..060de6c 100644 --- a/tests/test_spec_beh.py +++ b/tests/test_spec_beh.py @@ -65,6 +65,55 @@ class TestClassifierGate(ClassifierGateBase): self.bot.respond.assert_not_awaited() +class TestIgnoredChannels(ClassifierGateBase): + def ignored_msg(self, channel_name): + message = self.public_msg("hello there") + message.channel.name = channel_name + message.add_reaction = AsyncMock() + return message + + async def test_pattern_match_suppresses_reaction_and_reply(self): + """BEH-09: fnmatch pattern hit -> no classifier call, no emoji, no reply.""" + self.gate_setup({"reply": False, "factual": False, "emoji": "👍"}) + self.bot.config["ignore-channels"] = ["todo*"] + message = self.ignored_msg("todo-lists") + await self.bot.on_message(message) + self.bot.airesponder.classify.assert_not_awaited() + message.add_reaction.assert_not_awaited() + self.bot.respond.assert_not_awaited() + + async def test_exact_name_still_matches(self): + """BEH-09: plain names keep working as exact matches.""" + self.gate_setup({"reply": True, "factual": False, "emoji": None}) + self.bot.config["ignore-channels"] = ["blengon"] + await self.bot.on_message(self.ignored_msg("blengon")) + self.bot.respond.assert_not_awaited() + + async def test_non_matching_channel_passes(self): + """BEH-09: unmatched channels reach the responder as before.""" + self.gate_setup({"reply": True, "factual": False, "emoji": None}) + self.bot.config["ignore-channels"] = ["todo*"] + await self.bot.on_message(self.ignored_msg("chat")) + self.bot.respond.assert_awaited_once() + + async def test_dm_never_ignored(self): + """BEH-09: a DM whose recipient name matches a pattern is still answered.""" + self.gate_setup({"reply": True, "factual": False, "emoji": None}) + self.bot.config["ignore-channels"] = ["todo*"] + message = self.public_msg("hei bot") + message.channel = MagicMock(spec=DMChannel) + message.channel.recipient = MagicMock() + message.channel.recipient.name = "todo-fan" + await self.bot.on_message(message) + self.bot.respond.assert_awaited_once() + + def test_channel_by_name_honors_patterns(self): + """BEH-09: channel_by_name resolution skips pattern-ignored channels.""" + self.bot.config["ignore-channels"] = ["todo*"] + fallback = MagicMock(spec=TextChannel) + self.assertIs(self.bot.channel_by_name("todo-lists", fallback), fallback) + + class TestTypingPacing(OpsBase): async def send_with(self, answer, factual, cps=30): if cps is not None: