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.
This commit is contained in:
agessaman committed 2026-01-26 21:02:30 -08:00
1 parent ec4b4dbc66
commit b50d02aee9
4 files changed
+222 -16

No files matched your search

+6
View File
@@ -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
+36 -6
View File
@@ -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
+164 -6
View File
@@ -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:
+16 -4
View File
@@ -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()