diff --git a/README.md b/README.md index 5722b5d..d4d1202 100644 --- a/README.md +++ b/README.md @@ -284,7 +284,7 @@ class MyCommand(BaseCommand): 1. Fork the repository 2. Create a feature branch 3. Make your changes -4. Submit a pull request +4. Submit a pull request against the dev branch ## License diff --git a/modules/commands/announcements_command.py b/modules/commands/announcements_command.py index 30948c7..fbc7d96 100644 --- a/modules/commands/announcements_command.py +++ b/modules/commands/announcements_command.py @@ -27,6 +27,11 @@ class AnnouncementsCommand(BaseCommand): # Per-trigger cooldown tracking: trigger_name -> last_execution_time self.trigger_cooldowns: Dict[str, float] = {} + # Per-trigger lockout tracking: trigger_name -> last_send_time + # Prevents duplicate sends from retried DMs (60 second lockout) + self.trigger_lockouts: Dict[str, float] = {} + self.lockout_seconds = 60 # 60 second lockout to prevent duplicate sends + # Load configuration self.enabled = self.get_config_value('Announcements_Command', 'enabled', fallback=False, value_type='bool') self.default_channel = self.get_config_value('Announcements_Command', 'default_announcement_channel', fallback='Public', value_type='str') @@ -172,7 +177,20 @@ class AnnouncementsCommand(BaseCommand): def _record_trigger_execution(self, trigger_name: str): """Record the execution time for a trigger""" - self.trigger_cooldowns[trigger_name] = time.time() + current_time = time.time() + self.trigger_cooldowns[trigger_name] = current_time + self.trigger_lockouts[trigger_name] = current_time + + def _is_trigger_locked(self, trigger_name: str) -> bool: + """Check if a trigger is currently locked (within 60 seconds of last send)""" + if trigger_name not in self.trigger_lockouts: + return False + + current_time = time.time() + last_send = self.trigger_lockouts[trigger_name] + elapsed = current_time - last_send + + return elapsed < self.lockout_seconds def _parse_command(self, content: str) -> tuple: """ @@ -252,6 +270,15 @@ class AnnouncementsCommand(BaseCommand): ) return True + # Check lockout (applies even with override - prevents duplicate sends from retries) + if self._is_trigger_locked(trigger_name): + remaining_seconds = int(self.lockout_seconds - (time.time() - self.trigger_lockouts[trigger_name])) + await self.send_response( + message, + f"That announcement was just sent. Please wait {remaining_seconds} seconds to prevent duplicate sends." + ) + return True + # Check cooldown (unless override) if not is_override: remaining_minutes = self._get_trigger_cooldown_remaining(trigger_name) diff --git a/modules/commands/base_command.py b/modules/commands/base_command.py index 35a993a..cf2474c 100644 --- a/modules/commands/base_command.py +++ b/modules/commands/base_command.py @@ -257,6 +257,54 @@ class BaseCommand(ABC): self.logger.error(f"Failed to send response: {e}") return False + def get_max_message_length(self, message: MeshMessage) -> int: + """ + Calculate the maximum message length dynamically based on message type and bot username. + + Channel messages are formatted as ": ", so: + max_length = 150 - username_length - 2 (for ": ") + + DM messages don't have a username prefix, so: + max_length = 150 + + Args: + message: The MeshMessage to calculate max length for + + Returns: + Maximum message length in characters + """ + # For DMs, no username prefix - full 150 characters available + if message.is_dm: + return 150 + + # For channel messages, calculate based on bot username length + # Try to get device username from meshcore first (actual radio username) + username = None + 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): + username = self_info.get('name') or self_info.get('user_name') + elif hasattr(self_info, 'name'): + username = self_info.name + elif hasattr(self_info, 'user_name'): + username = self_info.user_name + except Exception as e: + self.logger.debug(f"Could not get username from meshcore.self_info: {e}") + + # Fall back to bot_name from config if device username not available + if not username: + username = self.bot.config.get('Bot', 'bot_name', fallback='Bot') + + # Calculate max length: 150 - username_length - 2 (for ": ") + max_length = 150 - len(str(username)) - 2 + + # Ensure we don't return a negative or unreasonably small value + # Minimum of 130 characters to ensure some functionality + return max(130, max_length) + def _record_execution(self): """Record the execution time for cooldown tracking""" import time diff --git a/modules/commands/path_command.py b/modules/commands/path_command.py index 7c262c4..5410688 100644 --- a/modules/commands/path_command.py +++ b/modules/commands/path_command.py @@ -1037,7 +1037,7 @@ class PathCommand(BaseCommand): return None, 0.0 def _format_path_response(self, node_ids: List[str], repeater_info: Dict[str, Dict[str, Any]]) -> str: - """Format the path decode response (max 130 chars per line) + """Format the path decode response Maintains the order of repeaters as they appear in the path (first to last) """ @@ -1103,7 +1103,10 @@ class PathCommand(BaseCommand): # This ensures capture_command gets the full response, not just the last split message self.last_response = response - if len(response) <= 130: + # Get dynamic max message length based on message type and bot username + max_length = self.get_max_message_length(message) + + if len(response) <= max_length: # Single message is fine await self.send_response(message, response) else: @@ -1114,8 +1117,8 @@ class PathCommand(BaseCommand): message_count = 0 for i, line in enumerate(lines): - # Check if adding this line would exceed 130 characters - if len(current_message) + len(line) + 1 > 130: # +1 for newline + # Check if adding this line would exceed max_length characters + if len(current_message) + len(line) + 1 > max_length: # +1 for newline # Send current message and start new one if current_message: # Add ellipsis on new line to end of continued message (if not the last message) diff --git a/modules/commands/prefix_command.py b/modules/commands/prefix_command.py index 5870c76..55114bd 100644 --- a/modules/commands/prefix_command.py +++ b/modules/commands/prefix_command.py @@ -110,9 +110,11 @@ class PrefixCommand(BaseCommand): free_prefixes, total_free, has_data = await self.get_free_prefixes() if not has_data: response = self.translate('commands.prefix.unable_determine_free') + return await self.send_response(message, response) else: response = self.format_free_prefixes_response(free_prefixes, total_free) - return await self.send_response(message, response) + await self._send_prefix_response(message, response) + return True # Check for "all" modifier include_all = False @@ -136,7 +138,8 @@ class PrefixCommand(BaseCommand): # Format response response = self.format_prefix_response(command, prefix_data) - return await self.send_response(message, response) + await self._send_prefix_response(message, response) + return True async def get_prefix_data(self, prefix: str, include_all: bool = False) -> Optional[Dict[str, Any]]: """Get prefix data from API first, enhanced with local database location data @@ -614,6 +617,103 @@ class PrefixCommand(BaseCommand): return response + async def _send_prefix_response(self, message: MeshMessage, response: str): + """Send prefix response, splitting into multiple messages if necessary""" + # Store the complete response for web viewer integration BEFORE splitting + # command_manager will prioritize command.last_response over _last_response + # This ensures capture_command gets the full response, not just the last split message + self.last_response = response + + # Get dynamic max message length based on message type and bot username + max_length = self.get_max_message_length(message) + + if len(response) <= max_length: + # Single message is fine + await self.send_response(message, response) + else: + # Split into multiple messages for over-the-air transmission + # But keep the full response in last_response for web viewer + lines = response.split('\n') + + # Calculate continuation markers length for planning + continuation_end = self.translate('commands.path.continuation_end') + continuation_start_template = self.translate('commands.path.continuation_start', line='PLACEHOLDER') + # Estimate continuation overhead (account for variable line length in template) + continuation_overhead = len(continuation_end) + len(continuation_start_template) - len('PLACEHOLDER') + + # Estimate how many messages we'll need based on total content + total_content_length = sum(len(line) for line in lines) + (len(lines) - 1) # +1 for each newline between lines + # Account for continuation markers in multi-message scenarios + estimated_messages = max(2, (total_content_length + continuation_overhead * 2) // max(max_length - continuation_overhead, 1) + 1) + target_lines_per_message = max(1, (len(lines) + estimated_messages - 1) // estimated_messages) # Ceiling division + + current_message = "" + message_count = 0 + lines_in_current = 0 + + for i, line in enumerate(lines): + # Calculate if adding this line would exceed max_length + test_message = current_message + if test_message: + test_message += f"\n{line}" + else: + test_message = line + + # Determine if we should split + must_split = len(test_message) > max_length + + # Smart splitting: try to balance lines across messages + # Calculate remaining lines and messages for balancing + remaining_lines = len(lines) - i - 1 # Lines after current one + remaining_messages = estimated_messages - message_count - 1 # Messages after current one + + # For first message, be more aggressive about fitting lines (prioritize earlier messages) + # For subsequent messages, balance more evenly + if message_count == 0: + # First message: try to fit more lines, only split if we must + # Be very conservative about splitting the first message - only if we absolutely must + # or if we're way over target and have plenty of room left + first_message_target = max(target_lines_per_message, 3) # At least 3 lines in first message if possible + should_balance_split = ( + lines_in_current >= first_message_target and # We've hit minimum target for first message + remaining_lines > 0 and # There are more lines + remaining_messages > 0 and # There are more messages + len(test_message) > max_length * 0.95 # Very close to limit (95% threshold - be aggressive) + ) + else: + # Subsequent messages: balance more evenly, but still try to fit reasonable amounts + should_balance_split = ( + lines_in_current >= max(target_lines_per_message, 2) and # At least 2 lines per message + remaining_lines > 0 and # There are more lines + remaining_messages > 0 and # There are more messages + (remaining_lines >= remaining_messages or lines_in_current >= target_lines_per_message + 1) and # Can distribute or we've exceeded target + len(test_message) > max_length * 0.88 # Getting close to limit (88% threshold) + ) + + if (must_split or should_balance_split) and current_message: + # Send current message and start new one + # Add ellipsis on new line to end of continued message + current_message += continuation_end + await self.send_response(message, current_message.rstrip()) + await asyncio.sleep(3.0) # Delay between messages (same as other commands) + message_count += 1 + lines_in_current = 0 + + # Start new message with ellipsis on new line at beginning + current_message = self.translate('commands.path.continuation_start', line=line) + lines_in_current = 1 + else: + # Add line to current message + if current_message: + current_message += f"\n{line}" + else: + current_message = line + lines_in_current += 1 + + # Send the last message if there's content + if current_message: + await self.send_response(message, current_message) + async def __aenter__(self): """Async context manager entry""" return self