From b50d02aee984d8062422abe8685956707bdde7bb Mon Sep 17 00:00:00 2001 From: agessaman Date: Mon, 26 Jan 2026 21:02:30 -0800 Subject: [PATCH] feat: Add command prefix support and enhance message handling - Introduced a new configuration option for command prefix in config.ini.example, allowing users to customize command invocation. - Updated CommandManager to load and utilize the command prefix, ensuring commands are processed correctly based on the configured prefix. - Enhanced message handling logic to check for the command prefix, maintaining backward compatibility with the legacy "!" prefix. - Improved keyword matching to ensure bot responses are only triggered when the bot is mentioned, refining interaction accuracy. --- config.ini.example | 6 ++ modules/command_manager.py | 42 ++++++-- modules/commands/base_command.py | 170 ++++++++++++++++++++++++++++-- modules/commands/hello_command.py | 20 +++- 4 files changed, 222 insertions(+), 16 deletions(-) diff --git a/config.ini.example b/config.ini.example index 1f07d41..ba6d2a2 100644 --- a/config.ini.example +++ b/config.ini.example @@ -38,6 +38,12 @@ enable_enhanced_correlation = true # Bot node ID (leave empty for auto-assignment) node_id = +# Command prefix (optional) +# 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 = + # Enable/disable bot responses # true: Bot will respond to keywords and commands # false: Bot will only listen and log messages diff --git a/modules/command_manager.py b/modules/command_manager.py index 0e4e17f..ac1765a 100644 --- a/modules/command_manager.py +++ b/modules/command_manager.py @@ -83,6 +83,7 @@ class CommandManager: self.custom_syntax = self.load_custom_syntax() self.banned_users = self.load_banned_users() self.monitor_channels = self.load_monitor_channels() + self.command_prefix = self.load_command_prefix() # Initialize plugin loader and load all plugins self.plugin_loader = PluginLoader(bot) @@ -416,6 +417,15 @@ class CommandManager: channels = self.bot.config.get('Channels', 'monitor_channels', fallback='') return [channel.strip() for channel in channels.split(',') if channel.strip()] + def load_command_prefix(self) -> str: + """Load command prefix from config. + + Returns: + str: The command prefix, or empty string if not configured. + """ + prefix = self.bot.config.get('Bot', 'command_prefix', fallback='') + return prefix.strip() if prefix else '' + def format_keyword_response(self, response_format: str, message: MeshMessage) -> str: """Format a keyword response string with message data. @@ -447,10 +457,20 @@ class CommandManager: List[tuple]: List of (trigger, response) tuples for matched keywords. """ matches = [] - # Strip exclamation mark if present (for command-style messages) content = message.content.strip() - if content.startswith('!'): - content = content[1:].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 matches # No prefix, no match + # 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() + content_lower = content.lower() # Check for help requests first (special handling) @@ -919,10 +939,20 @@ class CommandManager: Args: message: The message triggering the command execution. """ - # Strip exclamation mark if present (for command-style messages) content = message.content.strip() - if content.startswith('!'): - content = content[1:].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 # No prefix, no match + # 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() + content_lower = content.lower() # Check each command to see if it should execute diff --git a/modules/commands/base_command.py b/modules/commands/base_command.py index 660fdb3..2a7913b 100644 --- a/modules/commands/base_command.py +++ b/modules/commands/base_command.py @@ -50,6 +50,9 @@ class BaseCommand(ABC): # Load translated keywords after initialization self._load_translated_keywords() + + # Cache command prefix from config + self._command_prefix = self._load_command_prefix() def translate(self, key: str, **kwargs) -> str: """Translate a key using the bot's translator. @@ -545,15 +548,153 @@ class BaseCommand(ABC): # Log the error for debugging self.logger.debug(f"Could not load translated keywords for {self.name}: {e}") + def _load_command_prefix(self) -> str: + """Load command prefix from config. + + Returns: + str: The command prefix, or empty string if not configured. + """ + prefix = self.bot.config.get('Bot', 'command_prefix', fallback='') + return prefix.strip() if prefix else '' + + def _get_bot_name(self) -> str: + """Get bot name from device or config. + + Returns: + str: The name of the bot/device. + """ + # Try to get name from device first (actual radio username) + if hasattr(self.bot, 'meshcore') and self.bot.meshcore: + try: + if hasattr(self.bot.meshcore, 'self_info') and self.bot.meshcore.self_info: + self_info = self.bot.meshcore.self_info + # Try to get name from self_info (could be dict or object) + if isinstance(self_info, dict): + device_name = self_info.get('name') or self_info.get('adv_name') + if device_name: + return device_name + elif hasattr(self_info, 'name'): + if self_info.name: + return self_info.name + elif hasattr(self_info, 'adv_name'): + if self_info.adv_name: + return self_info.adv_name + except Exception as e: + self.logger.debug(f"Could not get name from device: {e}") + + # Fallback to config + bot_name = self.bot.config.get('Bot', 'bot_name', fallback='Bot') + return bot_name + + def _extract_mentions(self, text: str) -> List[str]: + """Extract all @[username] mentions from message content. + + Args: + text: The message text to process. + + Returns: + List[str]: List of mentioned usernames (without @[] brackets). + """ + # Pattern to match @[username] - username can contain spaces, emojis, special chars + pattern = r'@\[([^\]]+)\]' + mentions = re.findall(pattern, text) + return mentions + + def _is_bot_mentioned(self, text: str) -> bool: + """Check if the bot is mentioned in the message. + + Args: + text: The message text to check. + + Returns: + bool: True if the bot is mentioned, False otherwise. + """ + mentions = self._extract_mentions(text) + if not mentions: + return False + + bot_name = self._get_bot_name() + bot_name_lower = bot_name.lower() + + # Check if any mention matches the bot name (case-insensitive) + for mention in mentions: + if mention.lower() == bot_name_lower: + return True + + return False + + def _check_mentions_ok(self, text: str) -> bool: + """Check if mentions are valid (bot is mentioned if any mentions exist). + + Args: + text: The message text to check. + + Returns: + bool: True if mentions are OK (no mentions, or bot is mentioned), False otherwise. + """ + mentions = self._extract_mentions(text) + if not mentions: + # No mentions - always OK + return True + + # If there are mentions, bot must be mentioned + return self._is_bot_mentioned(text) + + def _strip_mentions(self, text: str) -> str: + """Strip @[username] mentions from message content. + + Args: + text: The message text to process. + + Returns: + str: The text with mentions removed. + """ + # Pattern to match @[username] - username can contain spaces, emojis, special chars + # Match @[ followed by any characters until ] + pattern = r'@\[([^\]]+)\]' + # Remove all mentions and clean up extra whitespace + cleaned = re.sub(pattern, '', text) + # Clean up multiple spaces and strip + cleaned = re.sub(r'\s+', ' ', cleaned).strip() + return cleaned + def matches_keyword(self, message: MeshMessage) -> bool: - """Check if this command matches the message content based on keywords""" + """Check if this command matches the message content based on keywords. + + Handles @[username] mentions: only responds if the bot is mentioned (if any mentions exist). + If other users are mentioned but not the bot, returns False. + + Handles command prefix: if a prefix is configured, the message must start with it. + + Args: + message: The message to check. + + Returns: + bool: True if message matches a keyword and bot is mentioned (if any mentions exist), False otherwise. + """ 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() + + # 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): + return False + + # Strip @[username] mentions before checking keywords + content = self._strip_mentions(content) content_lower = content.lower() for keyword in self.keywords: @@ -573,8 +714,25 @@ class BaseCommand(ABC): return False def matches_custom_syntax(self, message: MeshMessage) -> bool: - """Check if this command matches custom syntax patterns""" - # Override in subclasses for custom syntax matching + """Check if this command matches custom syntax patterns. + + Handles @[username] mentions: only responds if the bot is mentioned (if any mentions exist). + Subclasses should call super().matches_custom_syntax() first if they override this method. + + Args: + message: The message to check. + + Returns: + bool: True if custom syntax matches and bot is mentioned (if any mentions exist), False otherwise. + """ + content = message.content.strip() + + # Check if mentions are valid (bot must be mentioned if any mentions exist) + if not self._check_mentions_ok(content): + return False + + # Subclasses should override this method for custom syntax matching + # This base implementation just checks mentions return False def should_execute(self, message: MeshMessage) -> bool: diff --git a/modules/commands/hello_command.py b/modules/commands/hello_command.py index 02c09dd..6101ebc 100644 --- a/modules/commands/hello_command.py +++ b/modules/commands/hello_command.py @@ -5,6 +5,7 @@ Responds to various greetings with robot-themed responses """ import random +import re from typing import Any, List, Dict from .base_command import BaseCommand from ..models import MeshMessage @@ -268,7 +269,15 @@ class HelloCommand(BaseCommand): Returns: bool: True if it's an emoji-only message, False otherwise. """ - return self.is_emoji_only_message(message.content) + content = message.content.strip() + + # Check if mentions are valid (bot must be mentioned if any mentions exist) + if not self._check_mentions_ok(content): + return False + + # Strip mentions before checking for emoji-only messages + content = self._strip_mentions(content) + return self.is_emoji_only_message(content) async def execute(self, message: MeshMessage) -> bool: """Execute the hello command. @@ -282,9 +291,12 @@ class HelloCommand(BaseCommand): # Get bot name from config bot_name = self.bot.config.get('Bot', 'bot_name', fallback='Bot') - # Check if message is emoji-only - if self.is_emoji_only_message(message.content): - response = self.get_emoji_response(message.content, bot_name) + # Strip mentions from content for processing + content = self._strip_mentions(message.content) + + # Check if message is emoji-only (after stripping mentions) + if self.is_emoji_only_message(content): + response = self.get_emoji_response(content, bot_name) else: # Get random robot greeting random_greeting = self.get_random_greeting()