diff --git a/config.ini.example b/config.ini.example index 83d6a09..83fa31d 100644 --- a/config.ini.example +++ b/config.ini.example @@ -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 diff --git a/config.ini.minimal-example b/config.ini.minimal-example index 6bedf56..1fb2f28 100644 --- a/config.ini.minimal-example +++ b/config.ini.minimal-example @@ -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 diff --git a/modules/commands/alternatives/wx_international.py b/modules/commands/alternatives/wx_international.py index 5695cb4..ba2cfe6 100644 --- a/modules/commands/alternatives/wx_international.py +++ b/modules/commands/alternatives/wx_international.py @@ -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]]: diff --git a/modules/commands/base_command.py b/modules/commands/base_command.py index d6782a7..a66aca7 100644 --- a/modules/commands/base_command.py +++ b/modules/commands/base_command.py @@ -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() diff --git a/modules/commands/channels_command.py b/modules/commands/channels_command.py index 67ebde3..56474ae 100644 --- a/modules/commands/channels_command.py +++ b/modules/commands/channels_command.py @@ -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) diff --git a/modules/commands/dadjoke_command.py b/modules/commands/dadjoke_command.py index 6d13045..0876db3 100644 --- a/modules/commands/dadjoke_command.py +++ b/modules/commands/dadjoke_command.py @@ -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: diff --git a/modules/commands/dice_command.py b/modules/commands/dice_command.py index 04953a4..37e6fa9 100644 --- a/modules/commands/dice_command.py +++ b/modules/commands/dice_command.py @@ -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() diff --git a/modules/commands/hacker_command.py b/modules/commands/hacker_command.py index d257b22..416c6b4 100644 --- a/modules/commands/hacker_command.py +++ b/modules/commands/hacker_command.py @@ -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', diff --git a/modules/commands/joke_command.py b/modules/commands/joke_command.py index 2619b38..2e0117c 100644 --- a/modules/commands/joke_command.py +++ b/modules/commands/joke_command.py @@ -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: diff --git a/modules/commands/multitest_command.py b/modules/commands/multitest_command.py index 7772d2c..17dcb5c 100644 --- a/modules/commands/multitest_command.py +++ b/modules/commands/multitest_command.py @@ -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: diff --git a/modules/commands/path_command.py b/modules/commands/path_command.py index 6d6b5e4..aad2aec 100644 --- a/modules/commands/path_command.py +++ b/modules/commands/path_command.py @@ -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 diff --git a/modules/commands/prefix_command.py b/modules/commands/prefix_command.py index 706aefb..ee52bd5 100644 --- a/modules/commands/prefix_command.py +++ b/modules/commands/prefix_command.py @@ -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]]: diff --git a/modules/commands/repeater_command.py b/modules/commands/repeater_command.py index 64a316e..f245107 100644 --- a/modules/commands/repeater_command.py +++ b/modules/commands/repeater_command.py @@ -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: diff --git a/modules/commands/roll_command.py b/modules/commands/roll_command.py index 1aaaca9..2032094 100644 --- a/modules/commands/roll_command.py +++ b/modules/commands/roll_command.py @@ -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 diff --git a/modules/commands/sports_command.py b/modules/commands/sports_command.py index f2eaf1b..037c478 100644 --- a/modules/commands/sports_command.py +++ b/modules/commands/sports_command.py @@ -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) diff --git a/modules/commands/trace_command.py b/modules/commands/trace_command.py index e9dd0d1..bc803cf 100644 --- a/modules/commands/trace_command.py +++ b/modules/commands/trace_command.py @@ -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).""" diff --git a/modules/commands/webviewer_command.py b/modules/commands/webviewer_command.py index cb20492..0a39a5a 100644 --- a/modules/commands/webviewer_command.py +++ b/modules/commands/webviewer_command.py @@ -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: diff --git a/modules/commands/wx_command.py b/modules/commands/wx_command.py index 5213aa3..f234bbf 100644 --- a/modules/commands/wx_command.py +++ b/modules/commands/wx_command.py @@ -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: diff --git a/modules/message_handler.py b/modules/message_handler.py index 1a7975f..65f5a62 100644 --- a/modules/message_handler.py +++ b/modules/message_handler.py @@ -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": diff --git a/tests/commands/test_base_command.py b/tests/commands/test_base_command.py index 1b81f1b..4cebf41 100644 --- a/tests/commands/test_base_command.py +++ b/tests/commands/test_base_command.py @@ -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 diff --git a/tests/test_message_handler.py b/tests/test_message_handler.py index 09ba217..7cb4652 100644 --- a/tests/test_message_handler.py +++ b/tests/test_message_handler.py @@ -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()