From 1cdd240d98a8e4ed2dab0a95dece0b68ea309890 Mon Sep 17 00:00:00 2001 From: Oleksandr Kozachuk Date: Thu, 23 Jul 2026 15:26:06 +0200 Subject: [PATCH] =?UTF-8?q?beh-11:=20addressed-only=20channels=20=E2=80=94?= =?UTF-8?q?=20silent=20unless=20mentioned,=20replied=20to,=20or=20named;?= =?UTF-8?q?=20scheduled=20tasks=20skip=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fjerkroa_bot/discord_bot.py | 24 ++++++++++++ specs/SPEC-010-behavior.md | 13 +++++++ tests/test_spec_beh.py | 77 ++++++++++++++++++++++++++++++++++++- 3 files changed, 112 insertions(+), 2 deletions(-) diff --git a/fjerkroa_bot/discord_bot.py b/fjerkroa_bot/discord_bot.py index 0eb7be1..bb38cd2 100644 --- a/fjerkroa_bot/discord_bot.py +++ b/fjerkroa_bot/discord_bot.py @@ -203,6 +203,10 @@ class FjerkroaBot(commands.Bot): async def _execute_task(self, channel_name: str, prompt: str) -> None: """Run a due task through the normal responder path (TSK-02).""" + # Never post unprompted into addressed-only channels (BEH-11) + if self.channel_addressed_only(channel_name): + logging.info(f"task for addressed-only channel {channel_name!r} skipped (BEH-11)") + return channel = self.channel_by_name(channel_name, getattr(self, "chat_channel", None), no_ignore=True) if channel is None: raise RuntimeError(f"task channel {channel_name!r} not resolvable") @@ -501,6 +505,21 @@ class FjerkroaBot(commands.Bot): """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 channel_addressed_only(self, channel_name) -> bool: + """fnmatch patterns like ignore-channels (BEH-11).""" + return any(fnmatch.fnmatchcase(str(channel_name), pattern) for pattern in self.config.get("addressed-only-channels", [])) + + def _addressed(self, message, msg: AIMessage) -> bool: + """Mention/DM, reply to the bot, or the bot's name in the text (BEH-11).""" + if msg.direct: + return True + reference = getattr(message, "reference", None) + resolved = getattr(reference, "resolved", None) if reference else None + if resolved is not None and getattr(resolved, "author", None) == self.user: + return True + name = str(getattr(self.user, "name", "") or "") + return bool(name) and name.lower() in msg.message.lower() + def ignore_message(self, channel_name, message): return self.channel_ignored(channel_name) and not message.direct @@ -554,6 +573,11 @@ class FjerkroaBot(commands.Bot): if attachment_urls: msg.urls = attachment_urls + # Addressed-only channels: silent unless spoken to (BEH-11) + if self.channel_addressed_only(channel_name) and not self._addressed(message, msg): + self.log_message_action("addressed-only-skip", msg, channel_name) + return + # Reply/ignore classifier gate β€” direct messages bypass (BEH-01/02/03/07) handled, factual = await self._classifier_gate(message, msg, airesponder, channel_name) if handled: diff --git a/specs/SPEC-010-behavior.md b/specs/SPEC-010-behavior.md index e3d55fd..c823cf3 100644 --- a/specs/SPEC-010-behavior.md +++ b/specs/SPEC-010-behavior.md @@ -82,3 +82,16 @@ opening hours, release dates, news lookups get the stronger tier while small talk stays on the cheap default. Unset = no change. The `retry-model` override still wins on retry, and vision inputs keep using `model-vision`. + +### BEH-11 β€” Addressed-only channels answer only when spoken to (coverage: test) + +Channels matching `addressed-only-channels` (fnmatch patterns like +BEH-09) never get spontaneous participation: the handler returns +before the classifier gate unless the message addresses the bot β€” an +@mention or DM, a Discord reply to one of the bot's messages, or the +bot's name appearing in the message text (case-insensitive). No +model call, no emoji reaction otherwise. Scheduled tasks +(idle-impulse, follow-up) targeting such a channel are skipped at +execution time β€” the bot never posts there unprompted, whatever a +generator proposes. Unlike BEH-09 the bot still answers when +addressed; DMs are unaffected. diff --git a/tests/test_spec_beh.py b/tests/test_spec_beh.py index b14c853..d0f92d4 100644 --- a/tests/test_spec_beh.py +++ b/tests/test_spec_beh.py @@ -4,12 +4,12 @@ import hashlib import tempfile import unittest from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch from discord import DMChannel, TextChannel from fjerkroa_bot.ai_responder import AIMessage, AIResponse -from fjerkroa_bot.discord_bot import quiet_hours_active, split_answer +from fjerkroa_bot.discord_bot import FjerkroaBot, quiet_hours_active, split_answer from fjerkroa_bot.openai_responder import OpenAIResponder from fjerkroa_bot.persistence import PersistentStore @@ -290,3 +290,76 @@ class TestPinsListing(OpsBase): self.assertIn("Kanalregel", listing) self.assertIn("1", listing) self.assertIn("2", listing) + + +class TestAddressedOnlyChannels(ClassifierGateBase): + FAMILY = "πŸΎπ•±π–†π–’π–Žπ–‘π–Žπ–Š" + + def family_msg(self, content): + message = self.public_msg(content) + message.channel.name = self.FAMILY + message.add_reaction = AsyncMock() + return message + + def family_setup(self): + self.gate_setup({"reply": True, "factual": False, "emoji": None}) + self.bot.config["addressed-only-channels"] = ["*π•±π–†π–’π–Žπ–‘π–Žπ–Š*"] + + def _user(self, name="Luma"): + user = MagicMock() + user.name = name + return user + + async def test_unaddressed_message_stays_silent(self): + """BEH-11: pattern hit + not addressed -> no classifier, no reaction, no reply.""" + self.family_setup() + message = self.family_msg("wie war euer tag so?") + 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_mention_is_answered(self): + """BEH-11: an @mention in an addressed-only channel is answered.""" + self.family_setup() + user = self._user() + message = self.family_msg("was meinst du dazu?") + message.mentions = [user] + with patch.object(FjerkroaBot, "user", new_callable=PropertyMock) as mock_user: + mock_user.return_value = user + await self.bot.on_message(message) + self.bot.respond.assert_awaited_once() + + async def test_name_in_text_is_answered(self): + """BEH-11: the bot's name in the text counts as addressed (case-insensitive).""" + self.family_setup() + message = self.family_msg("luma, was haeltst du davon?") + with patch.object(FjerkroaBot, "user", new_callable=PropertyMock) as mock_user: + mock_user.return_value = self._user("Luma") + await self.bot.on_message(message) + self.bot.respond.assert_awaited_once() + + async def test_reply_to_bot_is_answered(self): + """BEH-11: a Discord reply to one of the bot's messages counts as addressed.""" + self.family_setup() + user = self._user() + message = self.family_msg("ja genau so!") + message.reference.resolved.author = user + message.reference.resolved.content = "earlier bot text" + with patch.object(FjerkroaBot, "user", new_callable=PropertyMock) as mock_user: + mock_user.return_value = user + await self.bot.on_message(message) + self.bot.respond.assert_awaited_once() + + async def test_other_channels_unaffected(self): + """BEH-11: non-matching channels keep the normal classifier path.""" + self.family_setup() + await self.bot.on_message(self.public_msg("hallo zusammen")) + self.bot.respond.assert_awaited_once() + + async def test_tasks_skip_addressed_only_channels(self): + """BEH-11: scheduled tasks never post into addressed-only channels.""" + self.family_setup() + self.bot.channel_by_name = Mock(return_value=MagicMock(spec=TextChannel)) + await self.bot._execute_task(self.FAMILY, "share a thought") + self.bot.respond.assert_not_awaited()