mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-11-04 03:52:34 +00:00
S4. BaseCommand and the 40 bundled overrides that took no argument now take `message: MeshMessage | None = None`, matching joke and rain, which already use the message. None of the 40 bodies read it, so their help text is unchanged. help and command_manager keep their TypeError retry, since local plugins may still implement get_help_text(self). For bundled commands the retry no longer runs: `help <command>` calls get_help_text(message) once instead of failing and calling it again. A new test pins the signature for every bundled command class. The offline smoke run against the real config produces the same help text for all 46 commands.
302 lines
14 KiB
Python
302 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Help command for the MeshCore Bot
|
|
Provides help information for commands and general usage
|
|
"""
|
|
|
|
from collections import defaultdict
|
|
from typing import (
|
|
Any, # noqa: F401 importable from this module on dev
|
|
Optional,
|
|
)
|
|
|
|
from ..models import MeshMessage
|
|
from .base_command import BaseCommand
|
|
|
|
|
|
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"
|
|
honors_skip_channel_check = False
|
|
enabled_attr = "help_enabled"
|
|
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 get_help_text(self, message: MeshMessage | None = None) -> 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.
|
|
"""
|
|
requested_name = command_name.strip()
|
|
command = None
|
|
candidates = [requested_name]
|
|
if requested_name:
|
|
base_name = requested_name.split(maxsplit=1)[0]
|
|
if base_name != requested_name:
|
|
candidates.append(base_name)
|
|
|
|
mappings = getattr(self.bot.command_manager.plugin_loader, 'keyword_mappings', {})
|
|
for lookup_name in candidates:
|
|
normalized_name = lookup_name.lower()
|
|
command = (
|
|
self.bot.command_manager.commands.get(normalized_name)
|
|
or self.bot.command_manager.commands.get(lookup_name)
|
|
)
|
|
if not command:
|
|
mapped_name = mappings.get(normalized_name)
|
|
if mapped_name:
|
|
command = self.bot.command_manager.commands.get(mapped_name)
|
|
if not command:
|
|
command = next(
|
|
(
|
|
cmd_instance
|
|
for cmd_instance in self.bot.command_manager.commands.values()
|
|
if hasattr(cmd_instance, 'keywords')
|
|
and normalized_name in [keyword.lower() for keyword in cmd_instance.keywords]
|
|
),
|
|
None,
|
|
)
|
|
if command:
|
|
break
|
|
|
|
if command:
|
|
# Pass message context to get_help_text if the method supports it
|
|
if hasattr(command, 'get_help_text') and callable(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
|
|
|
|
# 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:
|
|
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
|
|
|
|
# Ensure every channel-valid command appears even if it has no usage stats
|
|
# yet. Without this, a populated command_stats table would make the list show
|
|
# only previously-used commands (the fallback below only fires when the map
|
|
# is empty). Unused commands get count 0 and sort after used ones.
|
|
for pname in primary_names:
|
|
if pname not in command_counts:
|
|
command_counts[pname] = 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)
|