Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fa23068a4 | |||
| da50395dc7 |
@@ -1,5 +1,6 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import fnmatch
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
@@ -88,10 +89,22 @@ class ConfigFileHandler(FileSystemEventHandler):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def on_any_event(self, event):
|
def _dispatch(self, event):
|
||||||
if not event.is_directory and self._hits_config(event):
|
if not event.is_directory and self._hits_config(event):
|
||||||
self._on_change()
|
self._on_change()
|
||||||
|
|
||||||
|
# Only write/rename events — NOT on_opened/on_closed, whose read-opens
|
||||||
|
# (our own load_config re-reads the file) would otherwise feed back into
|
||||||
|
# a reload loop (CFG-05).
|
||||||
|
def on_modified(self, event):
|
||||||
|
self._dispatch(event)
|
||||||
|
|
||||||
|
def on_created(self, event):
|
||||||
|
self._dispatch(event)
|
||||||
|
|
||||||
|
def on_moved(self, event):
|
||||||
|
self._dispatch(event)
|
||||||
|
|
||||||
|
|
||||||
class FjerkroaBot(commands.Bot):
|
class FjerkroaBot(commands.Bot):
|
||||||
def __init__(self, config_file: str):
|
def __init__(self, config_file: str):
|
||||||
@@ -469,7 +482,7 @@ class FjerkroaBot(commands.Bot):
|
|||||||
return fallback_channel
|
return fallback_channel
|
||||||
if channel_name.startswith("#"):
|
if channel_name.startswith("#"):
|
||||||
channel_name = channel_name[1:]
|
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
|
return fallback_channel
|
||||||
for guild in self.guilds:
|
for guild in self.guilds:
|
||||||
channel = discord.utils.get(guild.channels, name=channel_name)
|
channel = discord.utils.get(guild.channels, name=channel_name)
|
||||||
@@ -482,8 +495,12 @@ class FjerkroaBot(commands.Bot):
|
|||||||
return str(channel.recipient.name)
|
return str(channel.recipient.name)
|
||||||
return str(channel.id) if isinstance(channel, DMChannel) else str(channel.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):
|
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):
|
def log_message_action(self, action, message, channel_name):
|
||||||
logging.info(f"{action} message {repr(message)} for channel {channel_name}")
|
logging.info(f"{action} message {repr(message)} for channel {channel_name}")
|
||||||
@@ -509,6 +526,11 @@ class FjerkroaBot(commands.Bot):
|
|||||||
|
|
||||||
async def handle_message_through_responder(self, message):
|
async def handle_message_through_responder(self, message):
|
||||||
"""Handle a message through the AI responder"""
|
"""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()
|
message_content = str(message.content).strip()
|
||||||
if message.reference and message.reference.resolved and isinstance(message.reference.resolved.content, str):
|
if message.reference and message.reference.resolved and isinstance(message.reference.resolved.content, str):
|
||||||
reference_content = str(message.reference.resolved.content).replace("\n", "> \n")
|
reference_content = str(message.reference.resolved.content).replace("\n", "> \n")
|
||||||
|
|||||||
@@ -35,9 +35,12 @@ applies directly — there are no concurrent readers yet.
|
|||||||
### CFG-05 — Hot-reload is rename-safe (coverage: test)
|
### CFG-05 — Hot-reload is rename-safe (coverage: test)
|
||||||
|
|
||||||
The watcher observes the config file's **directory**, not the file, and
|
The watcher observes the config file's **directory**, not the file, and
|
||||||
reacts to any event (modified, created, moved) whose source or
|
reacts to a **modified, created, or moved** event whose source or
|
||||||
destination path is the config file. This catches atomic saves — write
|
destination path is the config file. This catches atomic saves — write
|
||||||
a temp file, then rename it over the target — which replace the inode
|
a temp file, then rename it over the target — which replace the inode
|
||||||
and fire a move/create rather than a modify; watching the file directly
|
and fire a move/create rather than a modify; watching the file directly
|
||||||
would go deaf after the first such save. Events for other files in the
|
would go deaf after the first such save. Open/close events are
|
||||||
directory, and directory events themselves, are ignored.
|
deliberately not handled: reloading re-opens the file to read it, so
|
||||||
|
reacting to opens would feed back into an endless reload loop. Events
|
||||||
|
for other files in the directory, and directory events themselves, are
|
||||||
|
ignored.
|
||||||
|
|||||||
@@ -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
|
`bot_initiated_allowed()` is false: no boreness, later no scheduler
|
||||||
posts. Replies to users stay unaffected — a guest asking at 23:30
|
posts. Replies to users stay unaffected — a guest asking at 23:30
|
||||||
still gets an answer.
|
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.)
|
||||||
|
|||||||
@@ -65,6 +65,55 @@ class TestClassifierGate(ClassifierGateBase):
|
|||||||
self.bot.respond.assert_not_awaited()
|
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):
|
class TestTypingPacing(OpsBase):
|
||||||
async def send_with(self, answer, factual, cps=30):
|
async def send_with(self, answer, factual, cps=30):
|
||||||
if cps is not None:
|
if cps is not None:
|
||||||
|
|||||||
+11
-6
@@ -157,10 +157,15 @@ class TestConfigReloadRenameSafe(unittest.TestCase):
|
|||||||
event.dest_path = dest if dest is not None else ""
|
event.dest_path = dest if dest is not None else ""
|
||||||
return event
|
return event
|
||||||
|
|
||||||
handler.on_any_event(evt(src=str(config))) # in-place modify
|
handler.on_modified(evt(src=str(config))) # in-place modify
|
||||||
handler.on_any_event(evt(src=str(Path(tmp) / "kroa.toml.tmp"), dest=str(config))) # atomic rename over
|
handler.on_moved(evt(src=str(Path(tmp) / "kroa.toml.tmp"), dest=str(config))) # atomic rename over
|
||||||
self.assertEqual(len(hits), 2)
|
handler.on_created(evt(src=str(config))) # write-new
|
||||||
|
self.assertEqual(len(hits), 3)
|
||||||
|
|
||||||
handler.on_any_event(evt(src=str(Path(tmp) / "other.txt"))) # unrelated file
|
handler.on_modified(evt(src=str(Path(tmp) / "other.txt"))) # unrelated file
|
||||||
handler.on_any_event(evt(is_dir=True, src=str(config))) # directory event
|
handler.on_modified(evt(is_dir=True, src=str(config))) # directory event
|
||||||
self.assertEqual(len(hits), 2) # neither fired
|
self.assertEqual(len(hits), 3) # neither fired
|
||||||
|
|
||||||
|
# open/close of the config (our own load_config re-reads) must NOT be handled — else a reload loop.
|
||||||
|
self.assertNotIn("on_opened", vars(ConfigFileHandler))
|
||||||
|
self.assertNotIn("on_closed", vars(ConfigFileHandler))
|
||||||
|
|||||||
Reference in New Issue
Block a user