Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cdd240d98 |
@@ -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:
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user