Compare commits

..

1 Commits

3 changed files with 112 additions and 2 deletions
+24
View File
@@ -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:
+13
View File
@@ -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.
+75 -2
View File
@@ -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()