mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-28 13:24:09 +00:00
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:
+11
-1
@@ -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
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user