mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-03-30 12:05:38 +00:00
- Implemented a configuration option for enabling or disabling commands across multiple command classes. - Each command now checks its enabled state before execution, improving control over command availability. - Updated the configuration loading mechanism to retrieve the enabled state from the config file for commands like Advert, AQI, Catfact, and others.
478 lines
20 KiB
Python
478 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Channels command for the MeshCore Bot
|
|
Lists common hashtag channels for the region with multi-message support
|
|
"""
|
|
|
|
from .base_command import BaseCommand
|
|
from ..models import MeshMessage
|
|
from typing import Optional
|
|
import asyncio
|
|
import re
|
|
|
|
|
|
class ChannelsCommand(BaseCommand):
|
|
"""Handles the channels command.
|
|
|
|
Lists common hashtag channels for the region with multi-message support
|
|
and sub-category filtering.
|
|
"""
|
|
|
|
# Plugin metadata
|
|
name = "channels"
|
|
keywords = ['channels', 'channel']
|
|
description = "Lists hashtag channels with sub-categories. Use 'channels' for general, 'channels list' for all categories, 'channels <category>' for specific categories, 'channels #channel' for specific channel info."
|
|
category = "basic"
|
|
|
|
def __init__(self, bot):
|
|
"""Initialize the channels command.
|
|
|
|
Args:
|
|
bot: The bot instance.
|
|
"""
|
|
super().__init__(bot)
|
|
self.channels_enabled = self.get_config_value('Channels_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.channels_enabled:
|
|
return False
|
|
return super().can_execute(message)
|
|
|
|
def get_help_text(self) -> str:
|
|
return self.translate('commands.channels.help')
|
|
|
|
def matches_keyword(self, message: MeshMessage) -> bool:
|
|
"""Check if this command matches the message content based on keywords.
|
|
|
|
Args:
|
|
message: The message to check.
|
|
|
|
Returns:
|
|
bool: True if the message matches a command keyword.
|
|
"""
|
|
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()
|
|
|
|
# Don't match if this looks like a subcommand of another command
|
|
# (e.g., "stats channels" should not match "channels" command)
|
|
if ' ' in content_lower:
|
|
parts = content_lower.split()
|
|
if len(parts) > 1 and parts[0] not in ['channels', 'channel']:
|
|
return False
|
|
|
|
for keyword in self.keywords:
|
|
keyword_lower = keyword.lower()
|
|
|
|
# Check for exact match first
|
|
if keyword_lower == content_lower:
|
|
return True
|
|
|
|
# Check for word boundary matches using regex
|
|
# Create a regex pattern that matches the keyword at word boundaries
|
|
# Use custom word boundary that treats underscores as separators
|
|
# (?<![a-zA-Z0-9]) = negative lookbehind for alphanumeric characters (not underscore)
|
|
# (?![a-zA-Z0-9]) = negative lookahead for alphanumeric characters (not underscore)
|
|
# This allows underscores to act as word boundaries
|
|
pattern = r'(?<![a-zA-Z0-9])' + re.escape(keyword_lower) + r'(?![a-zA-Z0-9])'
|
|
if re.search(pattern, content_lower):
|
|
return True
|
|
|
|
return False
|
|
|
|
async def execute(self, message: MeshMessage) -> bool:
|
|
"""Execute the channels command.
|
|
|
|
Args:
|
|
message: The input message trigger.
|
|
|
|
Returns:
|
|
bool: True if execution was successful.
|
|
"""
|
|
try:
|
|
# Parse the command to check for sub-commands
|
|
content = message.content.strip()
|
|
if content.startswith('!'):
|
|
content = content[1:].strip()
|
|
|
|
# Check for sub-command (e.g., "channels seattle", "channel seahawks", "channels list", "channels #bot")
|
|
sub_command = None
|
|
specific_channel = None
|
|
if content.lower().startswith('channels ') or content.lower().startswith('channel '):
|
|
parts = content.split(' ', 1)
|
|
if len(parts) > 1:
|
|
sub_command = parts[1].strip().lower()
|
|
|
|
# Handle special "list" command to show all categories
|
|
if sub_command == 'list':
|
|
await self._show_all_categories(message)
|
|
return True
|
|
|
|
# Check if user is asking for a specific channel (starts with #)
|
|
if sub_command.startswith('#'):
|
|
specific_channel = sub_command
|
|
sub_command = None
|
|
else:
|
|
# First check if this is a valid category
|
|
if self._is_valid_category(sub_command):
|
|
# It's a category, keep it as sub_command
|
|
pass
|
|
else:
|
|
# Check if this might be a channel search (not a category)
|
|
# Try to find a channel that matches this name across all categories
|
|
found_channel = self._find_channel_by_name(sub_command)
|
|
if found_channel:
|
|
specific_channel = '#' + found_channel
|
|
sub_command = None
|
|
|
|
# Handle specific channel request
|
|
if specific_channel:
|
|
await self._show_specific_channel(message, specific_channel)
|
|
return True
|
|
|
|
# Load channels from config (with sub-command support)
|
|
channels = self._load_channels_from_config(sub_command)
|
|
|
|
if not channels:
|
|
if sub_command:
|
|
await self.send_response(message, self.translate('commands.channels.no_channels_for_category', category=sub_command))
|
|
else:
|
|
await self.send_response(message, self.translate('commands.channels.no_channels_configured'))
|
|
return True
|
|
|
|
# Build channel list (names only, no descriptions)
|
|
channel_list = []
|
|
for channel_name, description in channels.items():
|
|
channel_list.append(channel_name) # Just the channel name
|
|
|
|
# Split into multiple messages if needed (130 character limit)
|
|
messages = self._split_into_messages(channel_list, sub_command)
|
|
|
|
# Send each message with a small delay between them
|
|
await self._send_multiple_messages(message, messages)
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error in channels command: {e}")
|
|
await self.send_response(message, self.translate('commands.channels.error_retrieving_channels', error=str(e)))
|
|
return False
|
|
|
|
def _load_channels_from_config(self, sub_command: str = None) -> dict:
|
|
"""Load channels from the Channels_List config section with optional sub-command filtering.
|
|
|
|
Args:
|
|
sub_command: Optional category filter.
|
|
|
|
Returns:
|
|
dict: Dictionary of channel names to descriptions.
|
|
"""
|
|
channels = {}
|
|
|
|
for channel_name, description in self._parse_config_channels():
|
|
# Handle sub-command filtering
|
|
if sub_command:
|
|
# Special case: "general" should show general channels (no category prefix)
|
|
if sub_command == 'general':
|
|
# For general channels, only show channels that don't have sub-command prefixes
|
|
if '.' in channel_name:
|
|
continue
|
|
display_name = channel_name
|
|
else:
|
|
# Check if this channel belongs to the sub-command
|
|
if not channel_name.startswith(f'{sub_command}.'):
|
|
continue
|
|
# Remove the sub-command prefix for display
|
|
display_name = channel_name[len(sub_command) + 1:] # Remove 'subcommand.'
|
|
else:
|
|
# For general channels, only show channels that don't have sub-command prefixes
|
|
if '.' in channel_name:
|
|
continue
|
|
display_name = channel_name
|
|
|
|
# Add # prefix if not already present
|
|
if not display_name.startswith('#'):
|
|
display_name = '#' + display_name
|
|
|
|
channels[display_name] = description
|
|
|
|
return channels
|
|
|
|
async def _show_all_categories(self, message: MeshMessage) -> None:
|
|
"""Show all available channel categories.
|
|
|
|
Args:
|
|
message: The message to reply to.
|
|
"""
|
|
try:
|
|
categories = self._get_all_categories()
|
|
|
|
if not categories:
|
|
await self.send_response(message, self.translate('commands.channels.no_categories_configured'))
|
|
return
|
|
|
|
# Build category list
|
|
category_list = []
|
|
for category, count in categories.items():
|
|
category_list.append(self.translate('commands.channels.category_count', category=category, count=count))
|
|
|
|
# Split into multiple messages if needed
|
|
messages = self._split_into_messages(category_list, "Available categories")
|
|
|
|
# Send each message with a small delay between them
|
|
await self._send_multiple_messages(message, messages)
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error showing categories: {e}")
|
|
await self.send_response(message, self.translate('commands.channels.error_retrieving_categories', error=str(e)))
|
|
|
|
def _get_all_categories(self) -> dict:
|
|
"""Get all available channel categories and their channel counts.
|
|
|
|
Returns:
|
|
dict: Dictionary mapping category names to channel counts.
|
|
"""
|
|
categories = {}
|
|
|
|
for channel_name, description in self._parse_config_channels():
|
|
# Check if this is a sub-command channel (has a dot)
|
|
if '.' in channel_name:
|
|
category = channel_name.split('.')[0]
|
|
if category not in categories:
|
|
categories[category] = 0
|
|
categories[category] += 1
|
|
else:
|
|
# General channels (no category)
|
|
if 'general' not in categories:
|
|
categories['general'] = 0
|
|
categories['general'] += 1
|
|
|
|
return categories
|
|
|
|
def _find_channel_by_name(self, search_name: str) -> Optional[str]:
|
|
"""Find a channel by partial name match across all categories.
|
|
|
|
Args:
|
|
search_name: The channel name to search for.
|
|
|
|
Returns:
|
|
Optional[str]: The full channel name if found, None otherwise.
|
|
"""
|
|
search_name_lower = search_name.lower()
|
|
|
|
for config_name, description in self._parse_config_channels():
|
|
# Handle sub-command channels
|
|
if '.' in config_name:
|
|
category, name = config_name.split('.', 1)
|
|
# Check if the channel name matches (case insensitive)
|
|
if name.lower() == search_name_lower:
|
|
return name
|
|
else:
|
|
# Check general channels
|
|
if config_name.lower() == search_name_lower:
|
|
return config_name
|
|
|
|
return None
|
|
|
|
async def _show_specific_channel(self, message: MeshMessage, channel_name: str) -> None:
|
|
"""Show description for a specific channel.
|
|
|
|
Args:
|
|
message: The message to reply to.
|
|
channel_name: The channel name to show info for.
|
|
"""
|
|
try:
|
|
# Search for the channel in all categories
|
|
found_channel = None
|
|
found_category = None
|
|
|
|
for config_name, description in self._parse_config_channels():
|
|
# Handle sub-command channels
|
|
if '.' in config_name:
|
|
category, name = config_name.split('.', 1)
|
|
display_name = '#' + name
|
|
else:
|
|
display_name = '#' + config_name
|
|
|
|
# Check if this matches the requested channel
|
|
if display_name.lower() == channel_name.lower():
|
|
found_channel = display_name
|
|
found_category = category if '.' in config_name else 'general'
|
|
break
|
|
|
|
if found_channel:
|
|
# Get the description
|
|
if found_category == 'general':
|
|
config_key = found_channel[1:] # Remove #
|
|
else:
|
|
config_key = f"{found_category}.{found_channel[1:]}" # Remove #
|
|
|
|
description = self.bot.config.get('Channels_List', config_key, fallback=self.translate('commands.channels.no_description_available'))
|
|
|
|
# Strip quotes if present
|
|
if description.startswith('"') and description.endswith('"'):
|
|
description = description[1:-1]
|
|
|
|
response = f"{found_channel}: {description}"
|
|
await self.send_response(message, response)
|
|
else:
|
|
await self.send_response(message, self.translate('commands.channels.channel_not_found', channel=channel_name))
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error showing specific channel: {e}")
|
|
await self.send_response(message, self.translate('commands.channels.error_retrieving_channel_info', error=str(e)))
|
|
|
|
def _split_into_messages(self, channel_list: list, sub_command: str = None) -> list:
|
|
"""Split channel list into multiple messages if they exceed 130 characters.
|
|
|
|
Args:
|
|
channel_list: List of channel string items.
|
|
sub_command: The current sub-command/category context.
|
|
|
|
Returns:
|
|
list: List of message strings ready for sending.
|
|
"""
|
|
messages = []
|
|
|
|
# Set appropriate header based on sub-command
|
|
current_message = self._get_header_for_subcommand(sub_command)
|
|
current_length = len(current_message)
|
|
|
|
for channel in channel_list:
|
|
# Check if adding this channel would exceed the limit
|
|
if current_length + len(channel) + 2 > 130: # +2 for ", " separator
|
|
# Start a new message
|
|
expected_header = self._get_continuation_header_for_subcommand(sub_command)
|
|
|
|
if current_message != expected_header:
|
|
messages.append(current_message.rstrip(", "))
|
|
current_message = self.translate('commands.channels.headers.channels_cont')
|
|
current_length = len(current_message)
|
|
else:
|
|
# If even the first channel is too long, just send it alone
|
|
messages.append(f"{expected_header}{channel}")
|
|
current_message = self.translate('commands.channels.headers.channels_cont')
|
|
current_length = len(current_message)
|
|
continue
|
|
|
|
# Add channel to current message
|
|
initial_header = self._get_header_for_subcommand(sub_command)
|
|
continuation_header = self._get_continuation_header_for_subcommand(sub_command)
|
|
channels_cont = self.translate('commands.channels.headers.channels_cont')
|
|
if current_message == initial_header or current_message == continuation_header or current_message == channels_cont:
|
|
current_message += channel
|
|
else:
|
|
current_message += f", {channel}"
|
|
current_length = len(current_message)
|
|
|
|
# Add the last message if it has content
|
|
header = self._get_continuation_header_for_subcommand(sub_command)
|
|
channels_cont = self.translate('commands.channels.headers.channels_cont')
|
|
if current_message != header and current_message != channels_cont:
|
|
messages.append(current_message)
|
|
|
|
# If no messages were created, send a default message
|
|
if not messages:
|
|
if sub_command:
|
|
messages.append(self.translate('commands.channels.no_category_channels', category=sub_command))
|
|
else:
|
|
messages.append(self.translate('commands.channels.no_channels_configured'))
|
|
|
|
return messages
|
|
|
|
def _get_header_for_subcommand(self, sub_command: str = None) -> str:
|
|
"""Get the appropriate header for a sub-command.
|
|
|
|
Args:
|
|
sub_command: The sub-command/category name.
|
|
|
|
Returns:
|
|
str: Header string.
|
|
"""
|
|
if sub_command == "Available categories":
|
|
return self.translate('commands.channels.headers.available_categories')
|
|
elif sub_command and sub_command != "general":
|
|
return self.translate('commands.channels.headers.category', category=sub_command.title())
|
|
else:
|
|
return self.translate('commands.channels.headers.common_channels')
|
|
|
|
def _get_continuation_header_for_subcommand(self, sub_command: str = None) -> str:
|
|
"""Get the appropriate header for continuation messages.
|
|
|
|
Args:
|
|
sub_command: The sub-command/category name.
|
|
|
|
Returns:
|
|
str: Continuation header string.
|
|
"""
|
|
if sub_command == "Available categories":
|
|
return self.translate('commands.channels.headers.available_categories')
|
|
elif sub_command and sub_command != "general":
|
|
return self.translate('commands.channels.headers.category_channels', category=sub_command.title())
|
|
else:
|
|
return self.translate('commands.channels.headers.common_channels_cont')
|
|
|
|
async def _send_multiple_messages(self, message: MeshMessage, messages: list) -> None:
|
|
"""Send multiple messages with delays between them.
|
|
|
|
Args:
|
|
message: The original command message.
|
|
messages: List of message strings to send.
|
|
"""
|
|
for i, msg_content in enumerate(messages):
|
|
if i > 0:
|
|
# Small delay between messages to prevent overwhelming the network
|
|
await asyncio.sleep(0.5)
|
|
await self.send_response(message, msg_content)
|
|
|
|
def _parse_config_channels(self):
|
|
"""Parse all channels from config, returning a generator of (name, description) tuples.
|
|
|
|
Yields:
|
|
tuple: (channel_name, description) pairs.
|
|
"""
|
|
if not self.bot.config.has_section('Channels_List'):
|
|
return
|
|
|
|
for channel_name, description in self.bot.config.items('Channels_List'):
|
|
# Skip empty or commented lines
|
|
if channel_name.strip() and not channel_name.startswith('#'):
|
|
# Strip quotes if present
|
|
if description.startswith('"') and description.endswith('"'):
|
|
description = description[1:-1]
|
|
yield channel_name, description
|
|
|
|
def _is_valid_category(self, category_name: str) -> bool:
|
|
"""Check if a category name is valid (has channels with that prefix).
|
|
|
|
Args:
|
|
category_name: The category to check.
|
|
|
|
Returns:
|
|
bool: True if the category exists.
|
|
"""
|
|
if not category_name:
|
|
return False
|
|
|
|
# Check if there are any channels with this category prefix
|
|
for channel_name, description in self._parse_config_channels():
|
|
if '.' in channel_name:
|
|
category = channel_name.split('.')[0]
|
|
if category.lower() == category_name.lower():
|
|
return True
|
|
|
|
return False
|