diff --git a/config.ini.example b/config.ini.example index 391c682..833c9bd 100644 --- a/config.ini.example +++ b/config.ini.example @@ -70,7 +70,7 @@ dm_flood_after = 2 # Leave empty to use system timezone timezone = -# Joke command settings +[Jokes] # Enable or disable the joke command # true: Joke command is available # false: Joke command is disabled @@ -87,6 +87,11 @@ seasonal_jokes = true # false: Dad joke command is disabled dadjoke_enabled = true +# Handle long jokes (over 130 characters) +# false: Fetch new jokes until we get a short one +# true: Split long jokes into multiple messages +long_jokes = false + # Send startup advert when bot finishes initializing # false: No startup advert (default) # zero-hop: Send local broadcast advert diff --git a/modules/command_manager.py b/modules/command_manager.py index 317ace1..ab5f475 100644 --- a/modules/command_manager.py +++ b/modules/command_manager.py @@ -105,7 +105,7 @@ class CommandManager: # Check for help requests first (special handling) if content_lower.startswith('help '): command_name = content_lower[5:].strip() # Remove "help " prefix - help_text = self.get_help_for_command(command_name) + help_text = self.get_help_for_command(command_name, message) matches.append(('help', help_text)) return matches elif content_lower == 'help': @@ -302,18 +302,30 @@ class CommandManager: self.logger.error(f"Failed to send channel message: {e}") return False - def get_help_for_command(self, command_name: str) -> str: + def get_help_for_command(self, command_name: str, message: MeshMessage = None) -> str: """Get help text for a specific command (LoRa-friendly compact format)""" # First, try to find a command by exact name command = self.commands.get(command_name) if command: - return f"Help {command_name}: {command.get_help_text()}" + # Try to pass message context to get_help_text if supported + 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() + return f"Help {command_name}: {help_text}" # If not found, search through all commands and their keywords for cmd_name, cmd_instance in self.commands.items(): # Check if the requested command name matches any of this command's keywords if hasattr(cmd_instance, 'keywords') and command_name in cmd_instance.keywords: - return f"Help {command_name}: {cmd_instance.get_help_text()}" + # Try to pass message context to get_help_text if supported + try: + help_text = cmd_instance.get_help_text(message) + except TypeError: + # Fallback for commands that don't accept message parameter + help_text = cmd_instance.get_help_text() + return f"Help {command_name}: {help_text}" # If still not found, return unknown command message available_commands = [] diff --git a/modules/commands/dadjoke_command.py b/modules/commands/dadjoke_command.py index 1a2e366..f17f730 100644 --- a/modules/commands/dadjoke_command.py +++ b/modules/commands/dadjoke_command.py @@ -34,7 +34,8 @@ class DadJokeCommand(BaseCommand): self.user_cooldowns = {} # user_id -> last_execution_time # Load configuration - self.dadjoke_enabled = bot.config.getboolean('Bot', 'dadjoke_enabled', fallback=True) + self.dadjoke_enabled = bot.config.getboolean('Jokes', 'dadjoke_enabled', fallback=True) + self.long_jokes = bot.config.getboolean('Jokes', 'long_jokes', fallback=False) def get_help_text(self) -> str: return "Usage: dadjoke - Get a random dad joke" @@ -101,16 +102,15 @@ class DadJokeCommand(BaseCommand): # Record execution for this user self._record_execution(message.sender_id) - # Get dad joke from API - joke_data = await self.get_dad_joke_from_api() + # Get dad joke from API with length handling + joke_data = await self.get_dad_joke_with_length_handling() if joke_data is None: await self.send_response(message, "Sorry, couldn't fetch a dad joke right now. Try again later!") return True - # Format and send the joke - joke_text = self.format_dad_joke(joke_data) - await self.send_response(message, joke_text) + # Format and send the joke(s) + await self.send_dad_joke_with_length_handling(message, joke_data) return True @@ -161,6 +161,88 @@ class DadJokeCommand(BaseCommand): self.logger.error(f"Error fetching dad joke from API: {e}") return None + async def get_dad_joke_with_length_handling(self) -> Optional[Dict[str, Any]]: + """Get a dad joke from API with length handling based on configuration""" + max_attempts = 5 # Prevent infinite loops + + for attempt in range(max_attempts): + joke_data = await self.get_dad_joke_from_api() + + if joke_data is None: + return None + + # Check joke length + joke_text = self.format_dad_joke(joke_data) + + if len(joke_text) <= 130: + # Joke is short enough, return it + return joke_data + elif self.long_jokes: + # Long jokes are enabled, return it for splitting + return joke_data + else: + # Long jokes are disabled, try again + self.logger.debug(f"Dad joke too long ({len(joke_text)} chars), fetching another...") + continue + + # If we've tried max_attempts times and still getting long jokes, return the last one + self.logger.warning(f"Could not get short dad joke after {max_attempts} attempts") + return joke_data + + async def send_dad_joke_with_length_handling(self, message: MeshMessage, joke_data: Dict[str, Any]): + """Send dad joke with length handling - split if necessary""" + joke_text = self.format_dad_joke(joke_data) + + if len(joke_text) <= 130: + # Joke is short enough, send as single message + await self.send_response(message, joke_text) + else: + # Joke is too long, split it + parts = self.split_dad_joke(joke_text) + + if len(parts) == 2 and len(parts[0]) <= 130 and len(parts[1]) <= 130: + # Can be split into two messages + await self.send_response(message, parts[0]) + # Use conservative delay to avoid rate limiting (same as weather command) + await asyncio.sleep(2.0) + await self.send_response(message, parts[1]) + else: + # Cannot be split properly, send as single message (user will see truncation) + await self.send_response(message, joke_text) + + def split_dad_joke(self, joke_text: str) -> list: + """Split a long dad joke at a logical point""" + # Remove emoji for splitting + clean_joke = joke_text[2:] if joke_text.startswith('🥸 ') else joke_text + + # Try to split at common logical points + split_points = [ + '. ', # Period followed by space + '? ', # Question mark followed by space + '! ', # Exclamation mark followed by space + ', ', # Comma followed by space + ] + + for split_point in split_points: + if split_point in clean_joke: + parts = clean_joke.split(split_point, 1) + if len(parts) == 2: + # Add emoji back to both parts + return [f"🥸 {parts[0]}{split_point}", f"🥸 {parts[1]}"] + + # If no good split point found, split at middle + mid_point = len(clean_joke) // 2 + # Find nearest space to avoid splitting words + for i in range(mid_point, len(clean_joke)): + if clean_joke[i] == ' ': + mid_point = i + break + + part1 = clean_joke[:mid_point] + part2 = clean_joke[mid_point + 1:] + + return [f"🥸 {part1}", f"🥸 {part2}"] + def format_dad_joke(self, joke_data: Dict[str, Any]) -> str: """Format the dad joke data into a readable string""" try: diff --git a/modules/commands/help_command.py b/modules/commands/help_command.py index 26f26f5..45737a5 100644 --- a/modules/commands/help_command.py +++ b/modules/commands/help_command.py @@ -27,7 +27,7 @@ class HelpCommand(BaseCommand): self.logger.debug("Help command executed (handled by keyword matching)") return True - def get_specific_help(self, command_name: str) -> str: + def get_specific_help(self, command_name: str, message: MeshMessage = None) -> str: """Get help text for a specific command""" # Map command aliases to their actual command names command_aliases = { @@ -46,7 +46,16 @@ class HelpCommand(BaseCommand): command = self.bot.command_manager.commands.get(normalized_name) if command: - return f"**Help for '{command_name}':**\n{command.get_help_text()}" + # 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 = "No help available" + return f"**Help for '{command_name}':**\n{help_text}" else: return f"**Unknown command: '{command_name}'**\n\nAvailable commands:\n" + self.get_available_commands_list() diff --git a/modules/commands/joke_command.py b/modules/commands/joke_command.py index dbb9e97..a57f74c 100644 --- a/modules/commands/joke_command.py +++ b/modules/commands/joke_command.py @@ -6,6 +6,8 @@ Provides clean, family-friendly jokes from the JokeAPI import aiohttp import asyncio +import logging +from typing import Optional, Dict, Any from .base_command import BaseCommand from ..models import MeshMessage @@ -44,12 +46,21 @@ class JokeCommand(BaseCommand): self.user_cooldowns = {} # user_id -> last_execution_time # Load configuration - self.joke_enabled = bot.config.getboolean('Bot', 'joke_enabled', fallback=True) - self.seasonal_jokes = bot.config.getboolean('Bot', 'seasonal_jokes', fallback=True) + self.joke_enabled = bot.config.getboolean('Jokes', 'joke_enabled', fallback=True) + self.seasonal_jokes = bot.config.getboolean('Jokes', 'seasonal_jokes', fallback=True) + self.long_jokes = bot.config.getboolean('Jokes', 'long_jokes', fallback=False) - def get_help_text(self) -> str: - categories = ", ".join(self.SUPPORTED_CATEGORIES.keys()) - return f"Usage: joke [category] - Get a random joke or from categories: {categories}" + def get_help_text(self, message: MeshMessage = None) -> str: + """Get help text, excluding dark category if not in DM""" + if message and not message.is_dm: + # In public channel, exclude dark category + categories = [cat for cat in self.SUPPORTED_CATEGORIES.keys() if cat != 'dark'] + categories_str = ", ".join(categories) + return f"Usage: joke [category] - Get a random joke or from categories: {categories_str}" + else: + # In DM or no message context, show all categories + categories = ", ".join(self.SUPPORTED_CATEGORIES.keys()) + return f"Usage: joke [category] - Get a random joke or from categories: {categories}" def matches_keyword(self, message: MeshMessage) -> bool: """Check if message starts with a joke keyword""" @@ -69,6 +80,10 @@ class JokeCommand(BaseCommand): if not self.joke_enabled: return False + # Check if this is a dark joke request - require DM + if self.is_dark_joke_request(message) and not message.is_dm: + return False + # Check if command requires DM and message is not DM if self.requires_dm and not message.is_dm: return False @@ -101,6 +116,20 @@ class JokeCommand(BaseCommand): return 0 + def is_dark_joke_request(self, message: MeshMessage) -> bool: + """Check if the message is requesting a dark joke""" + content = message.content.strip() + if content.startswith('!'): + content = content[1:].strip() + + # Parse the command to extract category + parts = content.split() + if len(parts) >= 2: + category_input = parts[1].lower() + return category_input == 'dark' + + return False + def _record_execution(self, user_id: str): """Record the execution time for a specific user""" import time @@ -150,8 +179,8 @@ class JokeCommand(BaseCommand): # Record execution for this user self._record_execution(message.sender_id) - # Get joke from API - joke_data = await self.get_joke_from_api(category) + # Get joke from API with length handling + joke_data = await self.get_joke_with_length_handling(category) if joke_data is None: if category and category.lower() in ['dark']: @@ -160,9 +189,8 @@ class JokeCommand(BaseCommand): await self.send_response(message, "Sorry, couldn't fetch a joke right now. Try again later!") return True - # Format and send the joke - joke_text = self.format_joke(joke_data) - await self.send_response(message, joke_text) + # Format and send the joke(s) + await self.send_joke_with_length_handling(message, joke_data) return True @@ -228,6 +256,90 @@ class JokeCommand(BaseCommand): self.logger.error(f"Error fetching joke from JokeAPI: {e}") return None + async def get_joke_with_length_handling(self, category: str = None) -> Optional[Dict[str, Any]]: + """Get a joke from API with length handling based on configuration""" + max_attempts = 5 # Prevent infinite loops + + for attempt in range(max_attempts): + joke_data = await self.get_joke_from_api(category) + + if joke_data is None: + return None + + # Check joke length + joke_text = self.format_joke(joke_data) + + if len(joke_text) <= 130: + # Joke is short enough, return it + return joke_data + elif self.long_jokes: + # Long jokes are enabled, return it for splitting + return joke_data + else: + # Long jokes are disabled, try again + self.logger.debug(f"Joke too long ({len(joke_text)} chars), fetching another...") + continue + + # If we've tried max_attempts times and still getting long jokes, return the last one + self.logger.warning(f"Could not get short joke after {max_attempts} attempts") + return joke_data + + async def send_joke_with_length_handling(self, message: MeshMessage, joke_data: Dict[str, Any]): + """Send joke with length handling - split if necessary""" + joke_text = self.format_joke(joke_data) + + if len(joke_text) <= 130: + # Joke is short enough, send as single message + await self.send_response(message, joke_text) + else: + # Joke is too long, split it + parts = self.split_joke(joke_text) + + if len(parts) == 2 and len(parts[0]) <= 130 and len(parts[1]) <= 130: + # Can be split into two messages + await self.send_response(message, parts[0]) + # Use conservative delay to avoid rate limiting (same as weather command) + await asyncio.sleep(2.0) + await self.send_response(message, parts[1]) + else: + # Cannot be split properly, send as single message (user will see truncation) + await self.send_response(message, joke_text) + + def split_joke(self, joke_text: str) -> list: + """Split a long joke at a logical point""" + # Remove emoji for splitting + clean_joke = joke_text[2:] if joke_text.startswith('🎭 ') else joke_text + + # Try to split at common logical points + split_points = [ + '.\n\n', # Two-part jokes with double newline + '.\n', # Single newline + '. ', # Period followed by space + '? ', # Question mark followed by space + '! ', # Exclamation mark followed by space + ', ', # Comma followed by space + ] + + for split_point in split_points: + if split_point in clean_joke: + parts = clean_joke.split(split_point, 1) + if len(parts) == 2: + # Add emoji back to both parts + return [f"🎭 {parts[0]}{split_point}", f"🎭 {parts[1]}"] + + # If no good split point found, split at middle + mid_point = len(clean_joke) // 2 + # Find nearest space to avoid splitting words + for i in range(mid_point, len(clean_joke)): + if clean_joke[i] == ' ': + mid_point = i + break + + part1 = clean_joke[:mid_point] + part2 = clean_joke[mid_point + 1:] + + return [f"🎭 {part1}", f"🎭 {part2}"] + def format_joke(self, joke_data: dict) -> str: """Format the joke data into a readable string""" try: diff --git a/modules/core.py b/modules/core.py index 03fb55b..ad950dd 100644 --- a/modules/core.py +++ b/modules/core.py @@ -169,7 +169,7 @@ dm_flood_after = 2 # Leave empty to use system timezone timezone = -# Joke command settings +[Jokes] # Enable or disable the joke command # true: Joke command is available # false: Joke command is disabled @@ -186,6 +186,11 @@ seasonal_jokes = true # false: Dad joke command is disabled dadjoke_enabled = true +# Handle long jokes (over 130 characters) +# false: Fetch new jokes until we get a short one +# true: Split long jokes into multiple messages +long_jokes = false + # Send startup advert when bot finishes initializing # false: No startup advert (default) # zero-hop: Send local broadcast advert