#!/usr/bin/env python3 """ Repeater Management Command Provides commands to manage repeater contacts and purging operations """ from .base_command import BaseCommand from ..models import MeshMessage from typing import List, Optional class RepeaterCommand(BaseCommand): """Command for managing repeater contacts""" # Plugin metadata name = "repeater" keywords = ["repeater", "repeaters", "rp"] description = "Manage repeater contacts and purging operations (DM only)" requires_dm = True cooldown_seconds = 0 category = "management" def __init__(self, bot): super().__init__(bot) def matches_keyword(self, message: MeshMessage) -> bool: """Check if message starts with 'repeater' keyword""" content = message.content.strip() # Handle exclamation prefix if content.startswith('!'): content = content[1:].strip() # Check if message starts with any of our keywords content_lower = content.lower() for keyword in self.keywords: if content_lower.startswith(keyword + ' '): return True return False async def execute(self, message: MeshMessage) -> bool: """Execute repeater management command""" self.logger.info(f"Repeater command executed with content: {message.content}") # Parse the message content to extract subcommand and args content = message.content.strip() parts = content.split() if len(parts) < 2: response = self.get_help() else: subcommand = parts[1].lower() args = parts[2:] if len(parts) > 2 else [] try: if subcommand == "scan": response = await self._handle_scan() elif subcommand == "list": response = await self._handle_list(args) elif subcommand == "purge": response = await self._handle_purge(args) elif subcommand == "restore": response = await self._handle_restore(args) elif subcommand == "stats": response = await self._handle_stats() elif subcommand == "status": response = await self._handle_status() elif subcommand == "manage": response = await self._handle_manage(args) elif subcommand == "add": response = await self._handle_add(args) elif subcommand == "discover": response = await self._handle_discover() elif subcommand == "auto": response = await self._handle_auto(args) elif subcommand == "tst": response = await self._handle_test(args) elif subcommand == "help": response = self.get_help() else: response = f"Unknown subcommand: {subcommand}\n{self.get_help()}" except Exception as e: self.logger.error(f"Error in repeater command: {e}") import traceback self.logger.error(traceback.format_exc()) response = f"Error executing repeater command: {e}" # Send the response await self.bot.command_manager.send_response(message, response) return True async def _handle_scan(self) -> str: """Scan contacts for repeaters""" self.logger.info("Repeater scan command received") if not hasattr(self.bot, 'repeater_manager'): self.logger.error("Repeater manager not found on bot object") return "Repeater manager not initialized. Please check bot configuration." self.logger.info("Repeater manager found, starting scan...") try: cataloged_count = await self.bot.repeater_manager.scan_and_catalog_repeaters() self.logger.info(f"Scan completed, cataloged {cataloged_count} repeaters") return f"โœ… Scanned contacts and cataloged {cataloged_count} new repeaters" except Exception as e: self.logger.error(f"Error in repeater scan: {e}") return f"โŒ Error scanning for repeaters: {e}" async def _handle_list(self, args: List[str]) -> str: """List repeater contacts""" if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." try: # Check for --all flag to show purged repeaters too show_all = "--all" in args or "-a" in args active_only = not show_all repeaters = await self.bot.repeater_manager.get_repeater_contacts(active_only=active_only) if not repeaters: status = "all" if show_all else "active" return f"No {status} repeaters found in database" # Format the output lines = [] lines.append(f"๐Ÿ“ก **Repeater Contacts** ({'All' if show_all else 'Active'}):") lines.append("") for repeater in repeaters: status_icon = "๐ŸŸข" if repeater['is_active'] else "๐Ÿ”ด" device_icon = "๐Ÿ“ก" if repeater['device_type'] == 'Repeater' else "๐Ÿ " last_seen = repeater['last_seen'] if last_seen: # Parse and format the timestamp try: from datetime import datetime dt = datetime.fromisoformat(last_seen.replace('Z', '+00:00')) last_seen_str = dt.strftime("%Y-%m-%d %H:%M") except: last_seen_str = last_seen else: last_seen_str = "Unknown" lines.append(f"{status_icon} {device_icon} **{repeater['name']}**") lines.append(f" Type: {repeater['device_type']}") lines.append(f" Last seen: {last_seen_str}") lines.append(f" Purge count: {repeater['purge_count']}") lines.append("") return "\n".join(lines) except Exception as e: return f"โŒ Error listing repeaters: {e}" async def _handle_purge(self, args: List[str]) -> str: """Purge repeater contacts""" if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." if not args: return "Usage: !repeater purge [all|days|name] [reason]\nExamples:\n !repeater purge all 'Clear all repeaters'\n !repeater purge 30 'Auto-cleanup old repeaters'\n !repeater purge 'Hillcrest' 'Remove specific repeater'" try: if args[0].lower() == 'all': # Purge all repeaters reason = " ".join(args[1:]) if len(args) > 1 else "Manual purge - all repeaters" # Get all active repeaters repeaters = await self.bot.repeater_manager.get_repeater_contacts(active_only=True) if not repeaters: return "โŒ No active repeaters found to purge" purged_count = 0 for repeater in repeaters: success = await self.bot.repeater_manager.purge_repeater_from_contacts( repeater['public_key'], reason ) if success: purged_count += 1 return f"โœ… Purged {purged_count}/{len(repeaters)} repeaters" elif args[0].isdigit(): # Purge old repeaters days = int(args[0]) reason = " ".join(args[1:]) if len(args) > 1 else f"Auto-purge older than {days} days" purged_count = await self.bot.repeater_manager.purge_old_repeaters(days, reason) return f"โœ… Purged {purged_count} repeaters older than {days} days" else: # Purge specific repeater by name (partial match) name_pattern = args[0] reason = " ".join(args[1:]) if len(args) > 1 else "Manual purge" # Find repeaters matching the name pattern repeaters = await self.bot.repeater_manager.get_repeater_contacts(active_only=True) matching_repeaters = [r for r in repeaters if name_pattern.lower() in r['name'].lower()] if not matching_repeaters: return f"โŒ No active repeaters found matching '{name_pattern}'" if len(matching_repeaters) == 1: # Purge the single match repeater = matching_repeaters[0] success = await self.bot.repeater_manager.purge_repeater_from_contacts( repeater['public_key'], reason ) if success: return f"โœ… Purged repeater: {repeater['name']}" else: return f"โŒ Failed to purge repeater: {repeater['name']}" else: # Multiple matches - show options lines = [f"Multiple repeaters found matching '{name_pattern}':"] for i, repeater in enumerate(matching_repeaters, 1): lines.append(f"{i}. {repeater['name']} ({repeater['device_type']})") lines.append("") lines.append("Please be more specific with the name.") return "\n".join(lines) except ValueError: return "โŒ Invalid number of days. Please provide a valid integer." except Exception as e: return f"โŒ Error purging repeaters: {e}" async def _handle_restore(self, args: List[str]) -> str: """Restore purged repeater contacts""" if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." if not args: return "Usage: !repeater restore [reason]\nExample: !repeater restore 'Hillcrest' 'Manual restore'" try: name_pattern = args[0] reason = " ".join(args[1:]) if len(args) > 1 else "Manual restore" # Find purged repeaters matching the name pattern repeaters = await self.bot.repeater_manager.get_repeater_contacts(active_only=False) matching_repeaters = [r for r in repeaters if not r['is_active'] and name_pattern.lower() in r['name'].lower()] if not matching_repeaters: return f"โŒ No purged repeaters found matching '{name_pattern}'" if len(matching_repeaters) == 1: # Restore the single match repeater = matching_repeaters[0] success = await self.bot.repeater_manager.restore_repeater( repeater['public_key'], reason ) if success: return f"โœ… Restored repeater: {repeater['name']}" else: return f"โŒ Failed to restore repeater: {repeater['name']}" else: # Multiple matches - show options lines = [f"Multiple purged repeaters found matching '{name_pattern}':"] for i, repeater in enumerate(matching_repeaters, 1): lines.append(f"{i}. {repeater['name']} ({repeater['device_type']})") lines.append("") lines.append("Please be more specific with the name.") return "\n".join(lines) except Exception as e: return f"โŒ Error restoring repeaters: {e}" async def _handle_stats(self) -> str: """Show repeater management statistics""" if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." try: stats = await self.bot.repeater_manager.get_purging_stats() lines = [] lines.append("๐Ÿ“Š **Repeater Management Statistics**") lines.append("") lines.append(f"๐Ÿ“ก Total repeaters cataloged: {stats.get('total_repeaters', 0)}") lines.append(f"๐ŸŸข Active repeaters: {stats.get('active_repeaters', 0)}") lines.append(f"๐Ÿ”ด Purged repeaters: {stats.get('purged_repeaters', 0)}") lines.append("") recent_activity = stats.get('recent_activity_7_days', {}) if recent_activity: lines.append("๐Ÿ“ˆ **Recent Activity (7 days):**") for action, count in recent_activity.items(): action_icon = {"added": "โž•", "purged": "โž–", "restored": "๐Ÿ”„"}.get(action, "๐Ÿ“") lines.append(f" {action_icon} {action.title()}: {count}") else: lines.append("๐Ÿ“ˆ **Recent Activity (7 days):** None") return "\n".join(lines) except Exception as e: return f"โŒ Error getting statistics: {e}" async def _handle_status(self) -> str: """Show contact list status and limits""" if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." try: status = await self.bot.repeater_manager.get_contact_list_status() if not status: return "โŒ Failed to get contact list status" lines = [] lines.append("๐Ÿ“Š **Contact Status**") lines.append(f"๐Ÿ“ฑ {status['current_contacts']}/{status['estimated_limit']} ({status['usage_percentage']:.0f}%)") lines.append(f"๐Ÿ‘ฅ {status['companion_count']} companions, ๐Ÿ“ก {status['repeater_count']} repeaters") lines.append(f"โฐ {status['stale_contacts_count']} stale contacts") # Status indicators if status['is_at_limit']: lines.append("๐Ÿšจ **CRITICAL**: 95%+ full!") elif status['is_near_limit']: lines.append("โš ๏ธ **WARNING**: 80%+ full") else: lines.append("โœ… Adequate space") return "\n".join(lines) except Exception as e: return f"โŒ Error getting contact status: {e}" async def _handle_manage(self, args: List[str]) -> str: """Manage contact list to prevent hitting limits""" if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." try: # Check for --dry-run flag dry_run = "--dry-run" in args or "-d" in args auto_cleanup = not dry_run if dry_run: # Just show what would be done status = await self.bot.repeater_manager.get_contact_list_status() if not status: return "โŒ Failed to get contact list status" lines = [] lines.append("๐Ÿ” **Contact List Management (Dry Run)**") lines.append("") lines.append(f"๐Ÿ“Š Current status: {status['current_contacts']}/{status['estimated_limit']} ({status['usage_percentage']:.1f}%)") if status['is_near_limit']: lines.append("") lines.append("โš ๏ธ **Actions that would be taken:**") if status['stale_contacts']: lines.append(f" โ€ข Remove {min(10, len(status['stale_contacts']))} stale contacts") if status['repeater_count'] > 0: lines.append(" โ€ข Remove old repeaters (14+ days)") if status['is_at_limit']: lines.append(" โ€ข Aggressive cleanup (7+ day repeaters, 14+ day stale contacts)") else: lines.append("โœ… No management actions needed") return "\n".join(lines) else: # Actually perform management result = await self.bot.repeater_manager.manage_contact_list(auto_cleanup=True) if not result.get('success', False): return f"โŒ Contact list management failed: {result.get('error', 'Unknown error')}" lines = [] lines.append("๐Ÿ”ง **Contact List Management Results**") lines.append("") status = result['status'] lines.append(f"๐Ÿ“Š Final status: {status['current_contacts']}/{status['estimated_limit']} ({status['usage_percentage']:.1f}%)") actions = result.get('actions_taken', []) if actions: lines.append("") lines.append("โœ… **Actions taken:**") for action in actions: lines.append(f" โ€ข {action}") else: lines.append("") lines.append("โ„น๏ธ No actions were needed") return "\n".join(lines) except Exception as e: return f"โŒ Error managing contact list: {e}" async def _handle_add(self, args: List[str]) -> str: """Add a discovered contact to the contact list""" if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." if not args: return "โŒ Please specify a contact name to add" try: contact_name = args[0] public_key = args[1] if len(args) > 1 else None reason = " ".join(args[2:]) if len(args) > 2 else "Manual addition" success = await self.bot.repeater_manager.add_discovered_contact( contact_name, public_key, reason ) if success: return f"โœ… Successfully added contact: {contact_name}" else: return f"โŒ Failed to add contact: {contact_name}" except Exception as e: return f"โŒ Error adding contact: {e}" async def _handle_discover(self) -> str: """Discover companion contacts""" if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." try: success = await self.bot.repeater_manager.discover_companion_contacts("Manual discovery command") if success: return "โœ… Companion contact discovery initiated" else: return "โŒ Failed to initiate companion contact discovery" except Exception as e: return f"โŒ Error discovering contacts: {e}" async def _handle_auto(self, args: List[str]) -> str: """Toggle manual contact addition setting""" if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." if not args: return "โŒ Please specify 'on' or 'off' for manual contact addition setting" try: setting = args[0].lower() reason = " ".join(args[1:]) if len(args) > 1 else "Manual toggle" if setting in ['on', 'enable', 'true', '1']: enabled = True setting_text = "enabled" elif setting in ['off', 'disable', 'false', '0']: enabled = False setting_text = "disabled" else: return "โŒ Invalid setting. Use 'on' or 'off'" success = await self.bot.repeater_manager.toggle_auto_add(enabled, reason) if success: return f"โœ… Manual contact addition {setting_text}" else: return f"โŒ Failed to {setting_text} manual contact addition" except Exception as e: return f"โŒ Error toggling manual contact addition: {e}" async def _handle_test(self, args: List[str]) -> str: """Test meshcore-cli command functionality""" if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." try: results = await self.bot.repeater_manager.test_meshcore_cli_commands() lines = [] lines.append("๐Ÿงช **MeshCore-CLI Command Test Results**") lines.append("") if 'error' in results: lines.append(f"โŒ **ERROR**: {results['error']}") return "\n".join(lines) # Test results help_status = "โœ… PASS" if results.get('help', False) else "โŒ FAIL" remove_status = "โœ… PASS" if results.get('remove_contact', False) else "โŒ FAIL" lines.append(f"๐Ÿ“‹ Help command: {help_status}") lines.append(f"๐Ÿ—‘๏ธ Remove contact command: {remove_status}") lines.append("") if not results.get('remove_contact', False): lines.append("โš ๏ธ **WARNING**: remove_contact command not available!") lines.append("This means repeater purging will not work properly.") lines.append("Check your meshcore-cli installation and device connection.") else: lines.append("โœ… All required commands are available.") return "\n".join(lines) except Exception as e: return f"โŒ Error testing meshcore-cli commands: {e}" def get_help(self) -> str: """Get help text for the repeater command""" return """๐Ÿ“ก **Repeater & Contact Management Commands** **Usage:** `!repeater [options]` **Repeater Management:** โ€ข `scan` - Scan current contacts and catalog new repeaters โ€ข `list` - List repeater contacts (use `--all` to show purged ones) โ€ข `purge all` - Purge all repeaters โ€ข `purge ` - Purge repeaters older than specified days โ€ข `purge ` - Purge specific repeater by name โ€ข `restore ` - Restore a previously purged repeater โ€ข `stats` - Show repeater management statistics **Contact List Management:** โ€ข `status` - Show contact list status and limits โ€ข `manage` - Manage contact list to prevent hitting limits โ€ข `manage --dry-run` - Show what management actions would be taken โ€ข `add [key]` - Add a discovered contact to contact list โ€ข `discover` - Discover companion contacts โ€ข `auto ` - Toggle manual contact addition setting โ€ข `test` - Test meshcore-cli command functionality **Examples:** โ€ข `!repeater scan` - Find and catalog new repeaters โ€ข `!repeater status` - Check contact list capacity โ€ข `!repeater manage` - Auto-manage contact list โ€ข `!repeater manage --dry-run` - Preview management actions โ€ข `!repeater add "John"` - Add contact named John โ€ข `!repeater discover` - Discover new companion contacts โ€ข `!repeater auto off` - Disable manual contact addition โ€ข `!repeater test` - Test meshcore-cli commands โ€ข `!repeater purge all` - Purge all repeaters โ€ข `!repeater purge 30` - Purge repeaters older than 30 days โ€ข `!repeater stats` - Show management statistics **Note:** This system helps manage both repeater contacts and overall contact list capacity. It automatically removes stale contacts and old repeaters when approaching device limits. **Automatic Features:** โ€ข NEW_CONTACT events are automatically monitored โ€ข Repeaters are automatically cataloged when discovered โ€ข Contact list capacity is monitored in real-time โ€ข `auto_manage_contacts = device`: Device handles auto-addition, bot manages capacity โ€ข `auto_manage_contacts = bot`: Bot automatically adds companion contacts and manages capacity โ€ข `auto_manage_contacts = false`: Manual mode - use !repeater commands to manage contacts"""