Files
meshcore-bot/modules/commands/help_command.py
agessaman 217d2a4089 Refactor database connection handling across multiple modules
- Replaced direct SQLite connection calls with a context manager in various modules to ensure proper resource management and prevent file descriptor leaks.
- Introduced a new `connection` method in `DBManager` to standardize connection handling.
- Updated all relevant database interactions in modules such as `feed_manager`, `scheduler`, `commands`, and others to utilize the new connection method.
- Improved code readability and maintainability by consolidating connection logic.
2026-03-01 14:12:22 -08:00

299 lines
14 KiB
Python

#!/usr/bin/env python3
"""
Help command for the MeshCore Bot
Provides help information for commands and general usage
"""
import sqlite3
from collections import defaultdict
from typing import Any, Optional
from .base_command import BaseCommand
from ..models import MeshMessage
class HelpCommand(BaseCommand):
"""Handles the help command.
Provides assistance to users by listing available commands or displaying
detailed help for specific commands. It dynamically aggregates command
information from all loaded plugins.
"""
# Plugin metadata
name = "help"
keywords = ['help']
description = "Shows commands. Use 'help <command>' for details."
category = "basic"
# Documentation
short_description = "Get help on available commands"
usage = "help [command]"
examples = ["help", "help wx"]
parameters = [
{"name": "command", "description": "Command name for detailed help (optional)"}
]
def __init__(self, bot):
"""Initialize the help command.
Args:
bot: The bot instance.
"""
super().__init__(bot)
self.help_enabled = self.get_config_value('Help_Command', 'enabled', fallback=True, value_type='bool')
def can_execute(self, message: MeshMessage) -> bool:
"""Check if this command can be executed with the given message.
Args:
message: The message triggering the command.
Returns:
bool: True if command is enabled and checks pass, False otherwise.
"""
if not self.help_enabled:
return False
return super().can_execute(message)
def get_help_text(self) -> str:
"""Get help text for the help command.
Returns:
str: The help text for this command.
"""
return self.translate('commands.help.description')
async def execute(self, message: MeshMessage) -> bool:
"""Execute the help command.
Note: The help command logic is primarily handled by the CommandManager's
keyword matching system. This method serves as a placeholder or fallback.
Args:
message: The message that triggered the command.
Returns:
bool: True (always, as actual processing happens elsewhere).
"""
# The help command is now handled by keyword matching in the command manager
# This is just a placeholder for future functionality
self.logger.debug("Help command executed (handled by keyword matching)")
return True
def get_specific_help(self, command_name: str, message: MeshMessage = None) -> str:
"""Get help text for a specific command.
Resolves aliases, finds the corresponding command plugin, and retrieves
its help text.
Args:
command_name: The name or alias of the command.
message: Optional message object for context-aware help.
Returns:
str: The formatted help text for the specific command.
"""
# Map command aliases to their actual command names
command_aliases = {
't': 't_phrase',
'advert': 'advert',
'test': 'test',
'ping': 'ping',
'help': 'help'
}
# Normalize the command name
normalized_name = command_aliases.get(command_name, command_name)
# Get the command instance
command = self.bot.command_manager.commands.get(normalized_name)
if command:
# Pass message context to get_help_text if the method supports it
if hasattr(command, 'get_help_text') and callable(getattr(command, 'get_help_text')):
try:
help_text = command.get_help_text(message)
except TypeError:
# Fallback for commands that don't accept message parameter
help_text = command.get_help_text()
else:
help_text = self.translate('commands.help.no_help')
return self.translate('commands.help.specific', command=command_name, help_text=help_text)
else:
available = self.get_available_commands_list(message)
return self.translate('commands.help.unknown', command=command_name, available=available)
def get_general_help(self) -> str:
"""Get general help text.
Compiles a list of available commands and usage examples.
Returns:
str: The general help message to display to users.
"""
commands_list = self.get_available_commands_list()
help_text = self.translate('commands.help.general', commands_list=commands_list)
help_text += self.translate('commands.help.usage_examples')
help_text += self.translate('commands.help.custom_syntax')
return help_text
def _is_command_valid_for_channel(self, cmd_name: str, cmd_instance: Any, message: Optional[MeshMessage]) -> bool:
"""Return True if this command is valid in the message's channel context."""
if message is None:
return True
if hasattr(cmd_instance, 'is_channel_allowed') and callable(cmd_instance.is_channel_allowed):
if not cmd_instance.is_channel_allowed(message):
return False
if hasattr(self.bot.command_manager, '_is_channel_trigger_allowed'):
if not self.bot.command_manager._is_channel_trigger_allowed(cmd_name, message):
return False
return True
# Reserved suffix appended by command_manager.get_general_help (must match there)
HELP_LIST_SUFFIX = " | More: 'help <command>'"
def get_available_commands_list(
self, message: Optional[MeshMessage] = None, max_length: Optional[int] = None
) -> str:
"""Get a list of most popular commands in descending order.
Queries usage statistics to order commands by popularity. Ensures each
command is listed only once using its primary name. When message is
provided, only returns commands valid for the message's channel (respects
per-command channel overrides and channel_keywords). When max_length is
set, truncates the list so the result fits (appends " (N more)" when needed).
Args:
message: Optional message for context filtering. When provided, only
commands that can execute in this channel are included.
max_length: Optional max length for the returned list (for LoRa).
When set, list is truncated and may end with " (N more)".
Returns:
str: Comma-separated list of command names.
"""
try:
# Use the plugin loader's keyword mappings to map keywords/aliases to primary command names
plugin_loader = self.bot.command_manager.plugin_loader
keyword_mappings = plugin_loader.keyword_mappings.copy() if hasattr(plugin_loader, 'keyword_mappings') else {}
# Build a set of all primary command names and ensure they map to themselves
# Filter by channel when message is provided
primary_names = set()
for cmd_name, cmd_instance in self.bot.command_manager.commands.items():
if not self._is_command_valid_for_channel(cmd_name, cmd_instance, message):
continue
primary_name = cmd_instance.name if hasattr(cmd_instance, 'name') else cmd_name
primary_names.add(primary_name)
# Ensure primary name maps to itself in keyword_mappings
keyword_mappings[primary_name.lower()] = primary_name
# Query the database for command usage statistics
command_counts = defaultdict(int)
try:
with self.bot.db_manager.connection() as conn:
cursor = conn.cursor()
# Check if command_stats table exists
cursor.execute("""
SELECT name FROM sqlite_master
WHERE type='table' AND name='command_stats'
""")
if cursor.fetchone():
# Query command usage
cursor.execute("""
SELECT command_name, COUNT(*) as count
FROM command_stats
GROUP BY command_name
""")
for row in cursor.fetchall():
command_name = row[0]
count = row[1]
# Map keyword/alias to primary command name
# First try the plugin_loader's keyword_mappings
primary_name = keyword_mappings.get(command_name.lower())
# If not found in mappings, check if it's already a primary name
if primary_name is None:
if command_name in primary_names:
primary_name = command_name
else:
# Try to find which command this belongs to by checking all commands
for cmd_name, cmd_instance in self.bot.command_manager.commands.items():
# Check if command_name matches the command's name
cmd_primary = cmd_instance.name if hasattr(cmd_instance, 'name') else cmd_name
if cmd_primary == command_name:
primary_name = cmd_primary
break
# Check if it's a keyword of this command
if hasattr(cmd_instance, 'keywords'):
if command_name.lower() in [k.lower() for k in cmd_instance.keywords]:
primary_name = cmd_primary
break
# If still not found, use the command_name as-is
if primary_name is None:
primary_name = command_name
if primary_name in primary_names:
command_counts[primary_name] += count
except Exception as e:
self.logger.debug(f"Error querying command stats: {e}")
# If stats table doesn't exist or query fails, fall back to all commands
for cmd_name in self.bot.command_manager.commands.keys():
primary_name = self.bot.command_manager.commands[cmd_name].name if hasattr(self.bot.command_manager.commands[cmd_name], 'name') else cmd_name
command_counts[primary_name] = 0
# If we have stats, sort by count descending, otherwise use all commands
if command_counts:
# Sort by count descending, then by name for consistency
sorted_commands = sorted(
command_counts.items(),
key=lambda x: (-x[1], x[0])
)
# Extract just the command names (only primary names, no aliases)
command_names = [name for name, _ in sorted_commands]
else:
# Fallback: use all primary command names (filtered by channel)
command_names = sorted([
cmd.name if hasattr(cmd, 'name') else name
for name, cmd in self.bot.command_manager.commands.items()
if self._is_command_valid_for_channel(name, cmd, message)
])
# Apply max_length truncation when reserved for suffix (e.g. " | More: 'help <command>'")
return self._format_commands_list_to_length(command_names, max_length)
except Exception as e:
self.logger.error(f"Error getting available commands list: {e}")
# Fallback to simple list of all command names (filtered by channel)
command_names = sorted([
cmd.name if hasattr(cmd, 'name') else name
for name, cmd in self.bot.command_manager.commands.items()
if self._is_command_valid_for_channel(name, cmd, message)
])
return self._format_commands_list_to_length(command_names, max_length)
def _format_commands_list_to_length(
self, command_names: list, max_length: Optional[int] = None
) -> str:
"""Format command names as comma-separated list, optionally truncated to max_length."""
if not max_length or max_length <= 0:
return ', '.join(command_names)
result = []
current_length = 0
for name in command_names:
add_len = len(name) + (2 if result else 0) # ", " before each after first
if current_length + add_len <= max_length:
result.append(name)
current_length += add_len
else:
remaining = len(command_names) - len(result)
if remaining > 0:
suffix = f" ({remaining} more)"
if current_length + len(suffix) <= max_length:
return ', '.join(result) + suffix
break
return ', '.join(result)