Enhance mention handling in message processing

- Updated `config.ini.example` to introduce the `respond_to_mentions` setting, allowing configuration of how the bot responds to mentions in channel messages.
- Refactored `MessageHandler` to implement logic for handling mentions based on the new configuration, including stripping mentions when appropriate.
- Added `cleanup_message_for_matching` method in `BaseCommand` to streamline message processing and mention validation.
- Enhanced various command classes to utilize the new cleanup method for consistent mention handling.
- Introduced tests to validate the behavior of the new mention handling logic across different configurations.
This commit is contained in:
agessaman
2026-04-11 18:45:34 -07:00
parent 4ee20794df
commit 8bea10cdc3
21 changed files with 415 additions and 113 deletions
+11 -1
View File
@@ -65,7 +65,17 @@ node_id =
# If set, all commands must start with this prefix (e.g., "!", ".", "b", "abc")
# Examples: "!" for !ping, "." for .ping, "b" for bping, "abc" for abcping
# Leave empty or unset to allow commands without prefix (backward compatible)
#command_prefix =
#command_prefix =
# Respond to channel messages when the bot is mentioned via @[bot_name]
# Controls how the bot handles @[bot_name] mentions in channel messages:
# also - Default. Mention is stripped when present, but commands also work without a mention.
# Lets "@[BotName] ping" and "ping" both work.
# only - Bot only responds to channel messages that include @[bot_name].
# Messages without a mention are silently ignored.
# false - Mentions not handled; bot responds to all matching commands as before.
# DMs are always processed normally regardless of this setting.
respond_to_mentions = also
# Enable/disable bot responses
# true: Bot will respond to keywords and commands
+10
View File
@@ -65,6 +65,16 @@ enable_enhanced_correlation = true
# Bot node ID (leave empty for auto-assignment)
node_id =
# Respond to channel messages when the bot is mentioned via @[bot_name]
# Controls how the bot handles @[bot_name] mentions in channel messages:
# also - Default. Mention is stripped when present, but commands also work without a mention.
# Lets "@[BotName] ping" and "ping" both work.
# only - Bot only responds to channel messages that include @[bot_name].
# Messages without a mention are silently ignored.
# false - Mentions not handled; bot responds to all matching commands as before.
# DMs are always processed normally regardless of this setting.
respond_to_mentions = also
# Enable/disable bot responses
# true: Bot will respond to keywords and commands
# false: Bot will only listen and log messages
@@ -148,10 +148,7 @@ class GlobalWxCommand(BaseCommand):
Returns:
bool: True if message matches a keyword, False otherwise.
"""
content = message.content.strip()
if content.startswith('!'):
content = content[1:].strip()
content_lower = content.lower()
content_lower = self.cleanup_message_for_matching(message)
return any(content_lower.startswith(keyword + ' ') or content_lower == keyword for keyword in self.keywords)
def _get_companion_location(self, message: MeshMessage) -> Optional[tuple[float, float]]:
+38 -20
View File
@@ -802,6 +802,42 @@ class BaseCommand(ABC):
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
return cleaned
def cleanup_message_for_matching(self, message: MeshMessage) -> str:
"""Clean up message text before keyword checking.
Strips the command prefix and, when respond_to_mentions is not 'false',
validates mention rules and strips all @[...] mentions. Also updates
message.content and message.content_lower with the cleaned text so that
downstream processing (the execute step) sees the same clean content.
Args:
message: The incoming message.
Returns:
str: Cleaned, lowercased content ready for keyword comparison,
or empty string if the message should be ignored (wrong prefix,
or mentions present but bot not among them).
"""
content = message.content.strip()
if self._command_prefix:
if not content.startswith(self._command_prefix):
return ""
content = content[len(self._command_prefix):].strip()
else:
if content.startswith('!'):
content = content[1:].strip()
mention_mode = self.bot.config.get('Bot', 'respond_to_mentions', fallback='also').strip().lower()
if mention_mode != 'false':
if not self._check_mentions_ok(content):
return ""
content = self._strip_mentions(content)
message.content = content
message.content_lower = content.lower()
return message.content_lower
def matches_keyword(self, message: MeshMessage) -> bool:
"""Check if this command matches the message content based on keywords.
@@ -819,28 +855,10 @@ class BaseCommand(ABC):
if not self.keywords:
return False
content = message.content.strip()
# Check for command prefix if configured
if self._command_prefix:
# If prefix is configured, message must start with it
if not content.startswith(self._command_prefix):
return False
# Strip the prefix
content = content[len(self._command_prefix):].strip()
else:
# If no prefix configured, strip legacy "!" prefix for backward compatibility
if content.startswith('!'):
content = content[1:].strip()
# Check if mentions are valid (bot must be mentioned if any mentions exist)
if not self._check_mentions_ok(content):
content_lower = self.cleanup_message_for_matching(message)
if not content_lower:
return False
# Strip @[username] mentions before checking keywords
content = self._strip_mentions(content)
content_lower = content.lower()
for keyword in self.keywords:
keyword_lower = keyword.lower()
+1 -5
View File
@@ -72,11 +72,7 @@ class ChannelsCommand(BaseCommand):
if not self.keywords:
return False
# Strip exclamation mark if present (for command-style messages)
content = message.content.strip()
if content.startswith('!'):
content = content[1:].strip()
content_lower = content.lower()
content_lower = self.cleanup_message_for_matching(message)
# Don't match if this looks like a subcommand of another command
# (e.g., "stats channels" should not match "channels" command)
+1 -4
View File
@@ -66,10 +66,7 @@ class DadJokeCommand(BaseCommand):
Returns:
bool: True if message matches a keyword, False otherwise.
"""
content = message.content.strip()
if content.startswith('!'):
content = content[1:].strip()
content_lower = content.lower()
content_lower = self.cleanup_message_for_matching(message)
return any(content_lower == keyword or content_lower.startswith(keyword + ' ') for keyword in self.keywords)
def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool:
+4 -8
View File
@@ -83,20 +83,16 @@ class DiceCommand(BaseCommand):
Returns:
bool: True if message is a dice command, False otherwise.
"""
content = message.content.strip().lower()
# Handle command-style messages
if content.startswith('!'):
content = content[1:].strip().lower()
content_lower = self.cleanup_message_for_matching(message)
# Check for exact "dice" match
if content == "dice":
if content_lower == "dice":
return True
# Check for dice with parameters (dice d20, dice 20, dice d6, etc.)
# Match any message starting with "dice " - validation happens in execute()
if content.startswith("dice "):
words = content.split()
if content_lower.startswith("dice "):
words = content_lower.split()
if len(words) >= 2 and words[0] == "dice":
return True # Match any dice command, validation in execute()
+1 -4
View File
@@ -484,10 +484,7 @@ class HackerCommand(BaseCommand):
if not self.enabled:
return False
content = message.content.strip()
if content.startswith('!'):
content = content[1:].strip()
content_lower = content.lower()
content_lower = self.cleanup_message_for_matching(message)
# Commands that should match exactly (no arguments)
exact_match_commands = ['ls -l', 'ls -la', 'echo $PATH', 'df -h', 'whoami', 'history',
+1 -4
View File
@@ -85,10 +85,7 @@ class JokeCommand(BaseCommand):
Returns:
bool: True if a joke keyword matches, False otherwise.
"""
content = message.content.strip()
if content.startswith('!'):
content = content[1:].strip()
content_lower = content.lower()
content_lower = self.cleanup_message_for_matching(message)
return any(content_lower == keyword or content_lower.startswith(keyword + ' ') for keyword in self.keywords)
def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool:
+1 -7
View File
@@ -618,13 +618,7 @@ class MultitestCommand(BaseCommand):
def matches_keyword(self, message: MeshMessage) -> bool:
"""Check if message matches multitest keyword"""
content = message.content.strip()
# Handle exclamation prefix
if content.startswith('!'):
content = content[1:].strip()
content_lower = content.lower()
content_lower = self.cleanup_message_for_matching(message)
# Check for exact match or keyword followed by space
for keyword in self.keywords:
+2 -8
View File
@@ -180,19 +180,13 @@ class PathCommand(BaseCommand):
def matches_keyword(self, message: MeshMessage) -> bool:
"""Check if message starts with 'path' keyword or 'p' shortcut (if enabled)"""
content = message.content.strip()
# Handle exclamation prefix
if content.startswith('!'):
content = content[1:].strip()
content_lower = content.lower()
content_lower = self.cleanup_message_for_matching(message)
# Handle "p" shortcut if enabled
if self.enable_p_shortcut:
if content_lower == "p":
return True # Just "p" by itself
elif (content.startswith('p ') or content.startswith('P ')) and len(content) > 2:
elif content_lower.startswith('p ') and len(content_lower) > 2:
return True # "p " followed by path data
# Check if message starts with any of our keywords
+1 -8
View File
@@ -134,14 +134,7 @@ class PrefixCommand(BaseCommand):
def matches_keyword(self, message: MeshMessage) -> bool:
"""Check if message starts with 'prefix' keyword"""
content = message.content.strip()
# Handle exclamation prefix
if content.startswith('!'):
content = content[1:].strip()
# Check if message starts with 'prefix' (with or without space)
content_lower = content.lower()
content_lower = self.cleanup_message_for_matching(message)
return content_lower == 'prefix' or content_lower.startswith('prefix ')
async def _parse_location_to_lat_lon(self, location: str) -> tuple[Optional[float], Optional[float], Optional[str]]:
+1 -6
View File
@@ -65,14 +65,9 @@ class RepeaterCommand(BaseCommand):
Returns:
bool: True if the message starts with any of the command keywords.
"""
content = message.content.strip()
# Handle exclamation prefix
if content.startswith('!'):
content = content[1:].strip()
content_lower = self.cleanup_message_for_matching(message)
# Check if message starts with any of our keywords
content_lower = content.lower()
return any(content_lower.startswith(keyword + ' ') or content_lower == keyword for keyword in self.keywords)
async def execute(self, message: MeshMessage) -> bool:
+5 -9
View File
@@ -73,22 +73,18 @@ class RollCommand(BaseCommand):
Returns:
bool: True if the message matches the roll command syntax, False otherwise.
"""
content = message.content.strip().lower()
# Handle command-style messages
if content.startswith('!'):
content = content[1:].strip().lower()
content_lower = self.cleanup_message_for_matching(message)
# Check for exact "roll" match
if content == "roll":
if content_lower == "roll":
return True
# Check for roll with parameters (roll 50, roll 1000, etc.)
# Ensure "roll" is the first word and followed by valid number
if content.startswith("roll "):
words = content.split()
if content_lower.startswith("roll "):
words = content_lower.split()
if len(words) >= 2 and words[0] == "roll":
roll_part = content[5:].strip() # Get everything after "roll "
roll_part = content_lower[5:].strip() # Get everything after "roll "
# Check if the roll part is valid number notation (not just any word)
max_num = self.parse_roll_notation(roll_part)
return max_num is not None # Only match if it's valid number notation
+3 -6
View File
@@ -112,17 +112,14 @@ class SportsCommand(BaseCommand):
if not self.keywords:
return False
# Strip exclamation mark if present (for command-style messages)
content = message.content.strip()
if content.startswith('!'):
content = content[1:].strip()
content_lower = self.cleanup_message_for_matching(message)
# Split into words and check if first word matches any keyword
words = content.split()
words = content_lower.split()
if not words:
return False
first_word = words[0].lower()
first_word = words[0]
return any(first_word == keyword.lower() for keyword in self.keywords)
+3 -6
View File
@@ -60,13 +60,10 @@ class TraceCommand(BaseCommand):
)
def matches_keyword(self, message: MeshMessage) -> bool:
content = message.content.strip()
if content.startswith("!"):
content = content[1:].strip()
c = content.lower()
if c == "trace" or c == "tracer":
content_lower = self.cleanup_message_for_matching(message)
if content_lower == "trace" or content_lower == "tracer":
return True
return bool(c.startswith("trace ") or c.startswith("tracer "))
return bool(content_lower.startswith("trace ") or content_lower.startswith("tracer "))
def _extract_path_from_message(self, message: MeshMessage) -> list[str]:
"""Extract path node IDs from message.path (supports 1-hop and multi-hop)."""
+1 -8
View File
@@ -58,14 +58,7 @@ class WebViewerCommand(BaseCommand):
Returns:
bool: True if matches, False otherwise.
"""
content = message.content.strip()
# Handle exclamation prefix
if content.startswith('!'):
content = content[1:].strip()
# Check if message starts with any of our keywords
content_lower = content.lower()
content_lower = self.cleanup_message_for_matching(message)
return any(content_lower.startswith(keyword + ' ') or content_lower == keyword for keyword in self.keywords)
async def execute(self, message: MeshMessage) -> bool:
+1 -4
View File
@@ -168,10 +168,7 @@ class WxCommand(BaseCommand):
if self.delegate_command:
return self.delegate_command.matches_keyword(message)
content = message.content.strip()
if content.startswith('!'):
content = content[1:].strip()
content_lower = content.lower()
content_lower = self.cleanup_message_for_matching(message)
return any(content_lower.startswith(keyword + ' ') or content_lower == keyword for keyword in self.keywords)
def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool:
+15 -1
View File
@@ -2849,7 +2849,21 @@ class MessageHandler:
if not self.should_process_message(message):
return
self.logger.info(f"Processing message: {message.content}")
# Handle respond_to_mentions for channel messages
if not message.is_dm:
_mention_mode = self.bot.config.get('Bot', 'respond_to_mentions', fallback='also').strip().lower()
if _mention_mode in ('also', 'only'):
import re
_bot_name = self.bot.config.get('Bot', 'bot_name', fallback='Bot')
_mention = f'@[{_bot_name}]'
_has_mention = _mention.lower() in message.content.lower()
if _mention_mode == 'only' and not _has_mention:
self.logger.debug(f"Ignoring channel message (respond_to_mentions=only, no mention of {_mention})")
return
if _has_mention:
message.content = re.sub(re.escape(_mention), '', message.content, flags=re.IGNORECASE).strip()
self.logger.info(f"Processing message: '{message.content}' from {message.sender_id} in {'DM' if message.is_dm else message.channel}")
# Check for advert command (DM only)
if message.is_dm and message.content.strip().lower() == "advert":
+191
View File
@@ -247,3 +247,194 @@ class TestCanExecute:
cmd = PingCommand(command_mock_bot)
msg = mock_message(content="ping", is_dm=True)
assert cmd.can_execute(msg) is True
class TestMentionHelpers:
"""Tests for BaseCommand mention-detection helper methods."""
def _cmd(self, bot):
bot.meshcore = None # force bot name from config ("TestBot")
return _TestCommand(bot)
# _extract_mentions
def test_extract_no_mentions(self, command_mock_bot):
assert self._cmd(command_mock_bot)._extract_mentions("hello world") == []
def test_extract_single_mention(self, command_mock_bot):
assert self._cmd(command_mock_bot)._extract_mentions("@[Alice] hi") == ["Alice"]
def test_extract_multiple_mentions(self, command_mock_bot):
assert self._cmd(command_mock_bot)._extract_mentions("@[Alice] and @[Bob]") == ["Alice", "Bob"]
def test_extract_mention_with_spaces_in_name(self, command_mock_bot):
assert self._cmd(command_mock_bot)._extract_mentions("@[First Last] go") == ["First Last"]
# _is_bot_mentioned
def test_bot_mentioned_exact(self, command_mock_bot):
assert self._cmd(command_mock_bot)._is_bot_mentioned("@[TestBot] ping") is True
def test_bot_mentioned_case_insensitive(self, command_mock_bot):
assert self._cmd(command_mock_bot)._is_bot_mentioned("@[testbot] ping") is True
def test_bot_not_mentioned_other_user(self, command_mock_bot):
assert self._cmd(command_mock_bot)._is_bot_mentioned("@[Alice] ping") is False
def test_bot_not_mentioned_no_mention(self, command_mock_bot):
assert self._cmd(command_mock_bot)._is_bot_mentioned("ping") is False
# _check_mentions_ok
def test_ok_no_mentions(self, command_mock_bot):
assert self._cmd(command_mock_bot)._check_mentions_ok("ping") is True
def test_ok_bot_mentioned(self, command_mock_bot):
assert self._cmd(command_mock_bot)._check_mentions_ok("@[TestBot] ping") is True
def test_not_ok_only_other_user(self, command_mock_bot):
assert self._cmd(command_mock_bot)._check_mentions_ok("@[Alice] ping") is False
def test_ok_bot_and_other_user(self, command_mock_bot):
# Bot is among mentions — should be OK
assert self._cmd(command_mock_bot)._check_mentions_ok("@[TestBot] @[Alice] ping") is True
# _strip_mentions
def test_strip_single_mention(self, command_mock_bot):
assert self._cmd(command_mock_bot)._strip_mentions("@[Alice] hello") == "hello"
def test_strip_multiple_mentions(self, command_mock_bot):
assert self._cmd(command_mock_bot)._strip_mentions("@[Alice] hello @[Bob]") == "hello"
def test_strip_normalizes_whitespace(self, command_mock_bot):
assert self._cmd(command_mock_bot)._strip_mentions("@[Alice] ping") == "ping"
def test_strip_no_mention_unchanged(self, command_mock_bot):
assert self._cmd(command_mock_bot)._strip_mentions("ping") == "ping"
class TestCleanupMessageForMatching:
"""Tests for BaseCommand.cleanup_message_for_matching() across all respond_to_mentions modes."""
def _cmd(self, bot):
bot.meshcore = None # force bot name from config ("TestBot")
return _TestCommand(bot)
# ------------------------------------------------------------------ also --
def test_also_no_mention_returns_content(self, command_mock_bot):
"""'also' (default): command responds even without a mention."""
command_mock_bot.config.set("Bot", "respond_to_mentions", "also")
cmd = self._cmd(command_mock_bot)
msg = mock_message(content="testcmd")
assert cmd.cleanup_message_for_matching(msg) == "testcmd"
assert msg.content == "testcmd"
def test_also_strips_bot_mention(self, command_mock_bot):
"""'also': @[bot] prefix is stripped before keyword matching."""
command_mock_bot.config.set("Bot", "respond_to_mentions", "also")
cmd = self._cmd(command_mock_bot)
msg = mock_message(content="@[TestBot] testcmd")
result = cmd.cleanup_message_for_matching(msg)
assert result == "testcmd"
assert msg.content == "testcmd"
def test_also_strips_bot_mention_case_insensitive(self, command_mock_bot):
"""'also': bot mention matching is case-insensitive."""
command_mock_bot.config.set("Bot", "respond_to_mentions", "also")
cmd = self._cmd(command_mock_bot)
msg = mock_message(content="@[testbot] testcmd")
assert cmd.cleanup_message_for_matching(msg) == "testcmd"
def test_also_blocks_other_user_mention(self, command_mock_bot):
"""'also': if only someone else is mentioned (not bot), return empty string."""
command_mock_bot.config.set("Bot", "respond_to_mentions", "also")
cmd = self._cmd(command_mock_bot)
msg = mock_message(content="@[Alice] testcmd")
assert cmd.cleanup_message_for_matching(msg) == ""
def test_also_updates_message_content_and_lower(self, command_mock_bot):
"""'also': message.content and message.content_lower are updated after stripping."""
command_mock_bot.config.set("Bot", "respond_to_mentions", "also")
cmd = self._cmd(command_mock_bot)
msg = mock_message(content="@[TestBot] TESTCMD")
cmd.cleanup_message_for_matching(msg)
assert msg.content == "TESTCMD"
assert msg.content_lower == "testcmd"
# ------------------------------------------------------------------ only --
def test_only_with_mention_strips_and_returns(self, command_mock_bot):
"""'only': responds when bot is mentioned; strips the mention."""
command_mock_bot.config.set("Bot", "respond_to_mentions", "only")
cmd = self._cmd(command_mock_bot)
msg = mock_message(content="@[TestBot] testcmd")
result = cmd.cleanup_message_for_matching(msg)
assert result == "testcmd"
assert msg.content == "testcmd"
def test_only_plain_message_not_gated_here(self, command_mock_bot):
"""'only': cleanup_message_for_matching does NOT gate plain (unmention'd) messages —
the 'only' require-mention gate is enforced upstream in process_message."""
command_mock_bot.config.set("Bot", "respond_to_mentions", "only")
cmd = self._cmd(command_mock_bot)
msg = mock_message(content="testcmd")
# No mentions present → _check_mentions_ok returns True → content passes through
assert cmd.cleanup_message_for_matching(msg) == "testcmd"
def test_only_other_user_mention_returns_empty(self, command_mock_bot):
"""'only': another user mentioned but not bot → blocked."""
command_mock_bot.config.set("Bot", "respond_to_mentions", "only")
cmd = self._cmd(command_mock_bot)
msg = mock_message(content="@[Alice] testcmd")
assert cmd.cleanup_message_for_matching(msg) == ""
# ------------------------------------------------------------------ false --
def test_false_mention_not_stripped(self, command_mock_bot):
"""'false': no mention logic; mention is NOT stripped from content."""
command_mock_bot.config.set("Bot", "respond_to_mentions", "false")
cmd = self._cmd(command_mock_bot)
msg = mock_message(content="@[TestBot] testcmd")
result = cmd.cleanup_message_for_matching(msg)
assert "@[testbot]" in result
def test_false_other_user_mention_not_blocked(self, command_mock_bot):
"""'false': another user mentioned — bot still processes (no filtering)."""
command_mock_bot.config.set("Bot", "respond_to_mentions", "false")
cmd = self._cmd(command_mock_bot)
msg = mock_message(content="@[Alice] testcmd")
result = cmd.cleanup_message_for_matching(msg)
assert "@[alice]" in result
def test_false_plain_message_works(self, command_mock_bot):
"""'false': plain messages work normally."""
command_mock_bot.config.set("Bot", "respond_to_mentions", "false")
cmd = self._cmd(command_mock_bot)
msg = mock_message(content="testcmd")
assert cmd.cleanup_message_for_matching(msg) == "testcmd"
# ---------------------------------------------------------------- prefix -
def test_strips_legacy_bang_prefix(self, command_mock_bot):
"""No command_prefix configured: legacy '!' is stripped."""
command_mock_bot.config.set("Bot", "respond_to_mentions", "false")
cmd = self._cmd(command_mock_bot)
msg = mock_message(content="!testcmd")
assert cmd.cleanup_message_for_matching(msg) == "testcmd"
def test_wrong_command_prefix_returns_empty(self, command_mock_bot):
"""Configured command_prefix mismatch → empty string (no match)."""
command_mock_bot.config.set("Bot", "respond_to_mentions", "false")
command_mock_bot.config.set("Bot", "command_prefix", "!")
cmd = self._cmd(command_mock_bot)
msg = mock_message(content="testcmd") # missing the ! prefix
assert cmd.cleanup_message_for_matching(msg) == ""
# ---------------------------------------------- matches_keyword integration
def test_matches_keyword_with_bot_mention(self, command_mock_bot):
"""matches_keyword uses cleanup_message_for_matching — @[bot] ping matches 'testcmd'."""
command_mock_bot.config.set("Bot", "respond_to_mentions", "also")
cmd = self._cmd(command_mock_bot)
msg = mock_message(content="@[TestBot] testcmd")
assert cmd.matches_keyword(msg) is True
def test_matches_keyword_other_mention_blocked(self, command_mock_bot):
"""matches_keyword returns False when only another user is mentioned."""
command_mock_bot.config.set("Bot", "respond_to_mentions", "also")
cmd = self._cmd(command_mock_bot)
msg = mock_message(content="@[Alice] testcmd")
assert cmd.matches_keyword(msg) is False
+123
View File
@@ -8,6 +8,7 @@ import pytest
from modules.message_handler import MessageHandler
from modules.models import MeshMessage
from tests.conftest import mock_message as make_message
@pytest.fixture
@@ -1694,3 +1695,125 @@ class TestSignalCacheLRUBounds:
assert len(handler.snr_cache) == 2
assert handler.snr_cache["a"] == 5.0
assert handler.snr_cache["b"] == 2.0
# ---------------------------------------------------------------------------
# respond_to_mentions — process_message gate and stripping
# ---------------------------------------------------------------------------
class TestRespondToMentions:
"""Tests for the respond_to_mentions config gate in process_message.
process_message is async; we short-circuit the command execution
side-effects by mocking should_process_message to return True and
stubbing out check_keywords / execute_commands so only the mention
block is exercised.
"""
@pytest.fixture
def mention_bot(self, mock_logger):
"""Bot fixture with respond_to_mentions support."""
bot = Mock()
bot.logger = mock_logger
bot.config = configparser.ConfigParser()
bot.config.add_section("Bot")
bot.config.set("Bot", "enabled", "true")
bot.config.set("Bot", "bot_name", "TestBot")
bot.config.set("Bot", "rf_data_timeout", "15.0")
bot.config.set("Bot", "message_correlation_timeout", "10.0")
bot.config.set("Bot", "enable_enhanced_correlation", "true")
bot.config.add_section("Channels")
bot.config.set("Channels", "respond_to_dms", "true")
bot.config.set("Channels", "max_response_hops", "64")
bot.connection_time = None
bot.prefix_hex_chars = 2
bot.channel_responses_enabled = True
bot.command_manager = Mock()
bot.command_manager.monitor_channels = ["general"]
bot.command_manager.is_user_banned = Mock(return_value=False)
bot.command_manager.commands = {}
bot.command_manager.check_keywords = Mock(return_value=[])
bot.command_manager.match_randomline = Mock(return_value=None)
bot.command_manager.execute_commands = AsyncMock()
return bot
@pytest.fixture
def mention_handler(self, mention_bot):
return MessageHandler(mention_bot)
def _channel_msg(self, content, channel="general"):
return make_message(content=content, channel=channel, is_dm=False, sender_id="User")
def _dm_msg(self, content):
return make_message(content=content, channel=None, is_dm=True, sender_id="User")
# ------------------------------------------------------------------ also --
async def test_also_plain_command_processed(self, mention_handler, mention_bot):
"""'also': plain channel message (no mention) is still processed."""
mention_bot.config.set("Bot", "respond_to_mentions", "also")
msg = self._channel_msg("ping")
await mention_handler.process_message(msg)
# Command execution was reached; content unchanged
assert msg.content == "ping"
async def test_also_strips_bot_mention_from_content(self, mention_handler, mention_bot):
"""'also': @[TestBot] is stripped before command dispatch."""
mention_bot.config.set("Bot", "respond_to_mentions", "also")
msg = self._channel_msg("@[TestBot] ping")
await mention_handler.process_message(msg)
assert msg.content == "ping"
async def test_also_case_insensitive_strip(self, mention_handler, mention_bot):
"""'also': bot name match is case-insensitive."""
mention_bot.config.set("Bot", "respond_to_mentions", "also")
msg = self._channel_msg("@[testbot] ping")
await mention_handler.process_message(msg)
assert msg.content == "ping"
async def test_also_dm_bypasses_mention_logic(self, mention_handler, mention_bot):
"""'also': DMs are never subject to mention stripping or gating."""
mention_bot.config.set("Bot", "respond_to_mentions", "also")
msg = self._dm_msg("@[TestBot] ping")
await mention_handler.process_message(msg)
# Content should remain as-is — mention logic skipped for DMs
assert "@[TestBot]" in msg.content
# ------------------------------------------------------------------ only --
async def test_only_with_mention_processes(self, mention_handler, mention_bot):
"""'only': message with bot mention is processed (mention stripped)."""
mention_bot.config.set("Bot", "respond_to_mentions", "only")
msg = self._channel_msg("@[TestBot] ping")
await mention_handler.process_message(msg)
assert msg.content == "ping"
mention_bot.command_manager.execute_commands.assert_called_once()
async def test_only_without_mention_ignored(self, mention_handler, mention_bot):
"""'only': plain channel message is silently dropped."""
mention_bot.config.set("Bot", "respond_to_mentions", "only")
msg = self._channel_msg("ping")
await mention_handler.process_message(msg)
mention_bot.command_manager.execute_commands.assert_not_called()
async def test_only_dm_always_processed(self, mention_handler, mention_bot):
"""'only': DMs bypass the mention gate and are always processed."""
mention_bot.config.set("Bot", "respond_to_mentions", "only")
msg = self._dm_msg("ping")
await mention_handler.process_message(msg)
mention_bot.command_manager.execute_commands.assert_called_once()
# ------------------------------------------------------------------ false --
async def test_false_no_stripping(self, mention_handler, mention_bot):
"""'false': mention is NOT stripped from message content."""
mention_bot.config.set("Bot", "respond_to_mentions", "false")
msg = self._channel_msg("@[TestBot] ping")
await mention_handler.process_message(msg)
# Content must still contain the mention — no stripping in false mode
assert "@[TestBot]" in msg.content
async def test_false_plain_command_processed(self, mention_handler, mention_bot):
"""'false': plain commands work exactly as before."""
mention_bot.config.set("Bot", "respond_to_mentions", "false")
msg = self._channel_msg("ping")
await mention_handler.process_message(msg)
assert msg.content == "ping"
mention_bot.command_manager.execute_commands.assert_called_once()