#!/usr/bin/env python3 """ Repeater Management Command Provides commands to manage repeater contacts and purging operations """ import asyncio 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 == "locations": response = await self._handle_locations() elif subcommand == "update-geo": dry_run = "dry-run" in args batch_size = 10 # Default batch size # Look for batch size argument for i, arg in enumerate(args): if arg.isdigit(): batch_size = int(arg) break response = await self._handle_update_geolocation(dry_run, batch_size) elif subcommand == "auto-purge": response = await self._handle_auto_purge(args) elif subcommand == "purge-status": response = await self._handle_purge_status() elif subcommand == "test-purge": response = await self._handle_test_purge() elif subcommand == "debug-purge": response = await self._handle_debug_purge() elif subcommand == "geocode": response = await self._handle_geocode(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}" # Handle multi-message responses (like locations command) if isinstance(response, tuple) and response[0] == "multi_message": # Send first message await self.send_response(message, response[1]) # Wait for bot TX rate limiter to allow next message import asyncio rate_limit = self.bot.config.getfloat('Bot', 'bot_tx_rate_limit_seconds', fallback=1.0) # Use a conservative sleep time to avoid rate limiting sleep_time = max(rate_limit + 1.0, 2.0) # At least 2 seconds, or rate_limit + 1 second await asyncio.sleep(sleep_time) # Send second message await self.send_response(message, response[2]) else: # Send single message as usual await self.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") # Get more detailed information about what happened if cataloged_count > 0: return f"โœ… Scanned contacts and cataloged {cataloged_count} new repeaters" else: # Check if there are any repeaters in the database repeaters = await self.bot.repeater_manager.get_repeater_contacts(active_only=True) if repeaters: return f"โœ… Scanned contacts - no new repeaters found, but updated location data for existing {len(repeaters)} repeaters" else: return "โœ… Scanned contacts - no repeaters found in contact list" 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 or companion 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|companions] [reason]\nExamples:\n !repeater purge all 'Clear all repeaters'\n !repeater purge companions 'Clear inactive companions'\n !repeater purge companions 30 'Purge companions inactive 30+ days'\n !repeater purge 30 'Auto-cleanup old repeaters'\n !repeater purge 'Hillcrest' 'Remove specific repeater'" try: # Check if purging companions if args[0].lower() == 'companions': return await self._handle_purge_companions(args[1:]) if args[0].lower() == 'all': # Check for force flag force_purge = len(args) > 1 and args[1].lower() == 'force' if force_purge: reason = " ".join(args[2:]) if len(args) > 2 else "Force purge - all repeaters" else: reason = " ".join(args[1:]) if len(args) > 1 else "Manual purge - all repeaters" # Always get repeaters directly from device contacts for purging # This ensures we have the correct contact_key for removal self.logger.info("Scanning device contacts for repeaters to purge...") device_repeaters = [] if hasattr(self.bot.meshcore, 'contacts'): for contact_key, contact_data in self.bot.meshcore.contacts.items(): if self.bot.repeater_manager._is_repeater_device(contact_data): public_key = contact_data.get('public_key', contact_key) name = contact_data.get('adv_name', contact_data.get('name', 'Unknown')) device_repeaters.append({ 'public_key': public_key, 'contact_key': contact_key, # Include the contact key for removal 'name': name, 'contact_data': contact_data }) if not device_repeaters: return "โŒ No repeaters found on device to purge" repeaters = device_repeaters self.logger.info(f"Found {len(repeaters)} repeaters directly from device contacts") # Also catalog them in the database for future reference cataloged = await self.bot.repeater_manager.scan_and_catalog_repeaters() if cataloged > 0: self.logger.info(f"Cataloged {cataloged} new repeaters in database") # Force a complete refresh of contacts from device after purging self.logger.info("Forcing contact list refresh from device to ensure persistence...") try: from meshcore_cli.meshcore_cli import next_cmd await asyncio.wait_for( next_cmd(self.bot.meshcore, ["contacts"]), timeout=30.0 ) self.logger.info("Contact list refreshed from device") except Exception as e: self.logger.warning(f"Failed to refresh contact list: {e}") purged_count = 0 failed_count = 0 failed_repeaters = [] for i, repeater in enumerate(repeaters): self.logger.info(f"Purging repeater {i+1}/{len(repeaters)}: {repeater['name']} (force={force_purge})") # Always use the new method that works with contact keys success = await self.bot.repeater_manager.purge_repeater_by_contact_key( repeater['contact_key'], reason ) if success: purged_count += 1 else: failed_count += 1 failed_repeaters.append(repeater['name']) # Add a small delay between purges to avoid overwhelming the device if i < len(repeaters) - 1: await asyncio.sleep(1) # Final verification: Check if contacts were actually removed from device self.logger.info("Performing final verification of contact removal...") try: from meshcore_cli.meshcore_cli import next_cmd await asyncio.wait_for( next_cmd(self.bot.meshcore, ["contacts"]), timeout=30.0 ) # Count remaining repeaters on device remaining_repeaters = 0 if hasattr(self.bot.meshcore, 'contacts'): for contact_key, contact_data in self.bot.meshcore.contacts.items(): if self.bot.repeater_manager._is_repeater_device(contact_data): remaining_repeaters += 1 self.logger.info(f"Final verification: {remaining_repeaters} repeaters still on device") except Exception as e: self.logger.warning(f"Final verification failed: {e}") # Build response message purge_type = "Force purged" if force_purge else "Purged" response = f"โœ… {purge_type} {purged_count}/{len(repeaters)} repeaters" if failed_count > 0: response += f"\nโŒ Failed to purge {failed_count} repeaters: {', '.join(failed_repeaters[:5])}" if len(failed_repeaters) > 5: response += f" (and {len(failed_repeaters) - 5} more)" if not force_purge: response += f"\n๐Ÿ’ก Try '!repeater purge all force' to force remove stubborn repeaters" return response 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_purge_companions(self, args: List[str]) -> str: """Purge companion contacts based on inactivity""" if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." if not self.bot.repeater_manager.companion_purge_enabled: return "โŒ Companion purge disabled. Enable: [Companion_Purge] companion_purge_enabled = true" try: # Check for days argument days_old = None reason = "Manual purge - inactive companions" if args: try: # Try to parse first arg as number of days days_old = int(args[0]) reason = " ".join(args[1:]) if len(args) > 1 else f"Manual purge - companions inactive {days_old}+ days" except ValueError: # Not a number, treat as reason reason = " ".join(args) if args else "Manual purge - inactive companions" # Get companions for purging if days_old: # Purge companions inactive for specified days companions_to_purge = await self.bot.repeater_manager._get_companions_for_purging(999) # Get all eligible # Filter by days from datetime import datetime, timedelta cutoff_date = datetime.now() - timedelta(days=days_old) filtered_companions = [] for companion in companions_to_purge: if companion.get('last_activity'): try: last_activity = datetime.fromisoformat(companion['last_activity']) if last_activity < cutoff_date: filtered_companions.append(companion) except: pass elif companion.get('days_inactive', 0) >= days_old: filtered_companions.append(companion) companions_to_purge = filtered_companions else: # Get companions based on configured thresholds companions_to_purge = await self.bot.repeater_manager._get_companions_for_purging(999) # Get all eligible if not companions_to_purge: return "โŒ No companions match criteria (inactive for DM+advert thresholds, not in ACL)" # Purge companions (compact format for 130 char limit) total_to_purge = len(companions_to_purge) purged_count = 0 failed_count = 0 for i, companion in enumerate(companions_to_purge): self.logger.info(f"Purging companion {i+1}/{total_to_purge}: {companion['name']}") success = await self.bot.repeater_manager.purge_companion_from_contacts( companion['public_key'], reason ) if success: purged_count += 1 else: failed_count += 1 # Add delay between purges to avoid overwhelming the radio # Use 2 seconds to give radio time to process each removal if i < total_to_purge - 1: await asyncio.sleep(2) # Build compact response (must fit in 130 chars) if failed_count > 0: response = f"โœ… {purged_count}/{total_to_purge} companions purged, {failed_count} failed" else: response = f"โœ… {purged_count}/{total_to_purge} companions purged" # Truncate if still too long if len(response) > 130: response = f"โœ… {purged_count}/{total_to_purge} purged" return response except Exception as e: return f"โŒ Error purging companions: {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() # Shortened for LoRa transmission total = stats.get('total_repeaters', 0) active = stats.get('active_repeaters', 0) purged = stats.get('purged_repeaters', 0) response = f"๐Ÿ“Š Stats: {total} total, {active} active, {purged} purged" recent_activity = stats.get('recent_activity_7_days', {}) if recent_activity: activity_summary = [] for action, count in recent_activity.items(): activity_summary.append(f"{action}:{count}") if activity_summary: response += f" | 7d: {', '.join(activity_summary)}" return response 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" # Shortened for LoRa transmission current = status['current_contacts'] limit = status['estimated_limit'] usage = status['usage_percentage'] companions = status['companion_count'] repeaters = status['repeater_count'] stale = status['stale_contacts_count'] if status['is_at_limit']: return f"๐Ÿ“Š {current}/{limit} ({usage:.0f}%) | ๐Ÿ‘ฅ{companions} ๐Ÿ“ก{repeaters} โฐ{stale} | ๐Ÿšจ FULL!" elif status['is_near_limit']: return f"๐Ÿ“Š {current}/{limit} ({usage:.0f}%) | ๐Ÿ‘ฅ{companions} ๐Ÿ“ก{repeaters} โฐ{stale} | โš ๏ธ NEAR" else: return f"๐Ÿ“Š {current}/{limit} ({usage:.0f}%) | ๐Ÿ‘ฅ{companions} ๐Ÿ“ก{repeaters} โฐ{stale} | โœ… OK" 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_stats(self) -> str: """Show statistics about the complete repeater tracking database""" if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." try: stats = await self.bot.repeater_manager.get_contact_statistics() response = "๐Ÿ“Š **Contact Tracking Statistics:**\n\n" response += f"โ€ข **Total Contacts Ever Heard:** {stats.get('total_heard', 0)}\n" response += f"โ€ข **Currently Tracked by Device:** {stats.get('currently_tracked', 0)}\n" response += f"โ€ข **Recent Activity (24h):** {stats.get('recent_activity', 0)}\n\n" if stats.get('by_role'): response += "**By MeshCore Role:**\n" # Display roles in logical order role_order = ['repeater', 'roomserver', 'companion', 'sensor', 'gateway', 'bot'] for role in role_order: if role in stats['by_role']: count = stats['by_role'][role] role_display = role.title() if role == 'roomserver': role_display = 'RoomServer' response += f"โ€ข {role_display}: {count}\n" # Show any other roles not in the standard list for role, count in stats['by_role'].items(): if role not in role_order: response += f"โ€ข {role.title()}: {count}\n" response += "\n" if stats.get('by_type'): response += "**By Device Type:**\n" for device_type, count in stats['by_type'].items(): response += f"โ€ข {device_type}: {count}\n" return response except Exception as e: return f"โŒ Error getting repeater statistics: {e}" async def _handle_auto_purge(self, args: List[str]) -> str: """Handle auto-purge commands""" if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." try: if not args: # Show auto-purge status status = await self.bot.repeater_manager.get_auto_purge_status() # Shortened for LoRa transmission (130 char limit) current = status.get('current_count', 0) limit = status.get('contact_limit', 300) usage = status.get('usage_percentage', 0) enabled = status.get('enabled', False) if status.get('is_at_limit', False): response = f"๐Ÿ”„ Auto-Purge: {'ON' if enabled else 'OFF'} | {current}/{limit} ({usage:.0f}%) | ๐Ÿšจ FULL!" elif status.get('is_near_limit', False): response = f"๐Ÿ”„ Auto-Purge: {'ON' if enabled else 'OFF'} | {current}/{limit} ({usage:.0f}%) | โš ๏ธ NEAR LIMIT" else: response = f"๐Ÿ”„ Auto-Purge: {'ON' if enabled else 'OFF'} | {current}/{limit} ({usage:.0f}%) | โœ… OK" return response elif args[0].lower() == "trigger": # Manually trigger auto-purge success = await self.bot.repeater_manager.check_and_auto_purge() if success: return "โœ… Auto-purge triggered successfully" else: return "โ„น๏ธ Auto-purge check completed (no purging needed or failed)" elif args[0].lower() == "enable": # Enable auto-purge self.bot.repeater_manager.auto_purge_enabled = True return "โœ… Auto-purge enabled" elif args[0].lower() == "disable": # Disable auto-purge self.bot.repeater_manager.auto_purge_enabled = False return "โŒ Auto-purge disabled" elif args[0].lower() == "monitor": # Run periodic monitoring await self.bot.repeater_manager.periodic_contact_monitoring() return "๐Ÿ“Š Periodic contact monitoring completed" else: return "โŒ Unknown auto-purge command. Use: `!repeater auto-purge [trigger|enable|disable|monitor]`" except Exception as e: return f"โŒ Error with auto-purge command: {e}" async def _handle_purge_status(self) -> str: """Show detailed purge status and recommendations""" if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." try: status = await self.bot.repeater_manager.get_auto_purge_status() # Shortened for LoRa transmission current = status.get('current_count', 0) limit = status.get('contact_limit', 300) usage = status.get('usage_percentage', 0) threshold = status.get('threshold', 280) enabled = status.get('enabled', False) if status.get('is_at_limit', False): response = f"๐Ÿ“Š Purge: {'ON' if enabled else 'OFF'} | {current}/{limit} ({usage:.0f}%) | ๐Ÿšจ FULL! Run trigger now!" elif status.get('is_near_limit', False): response = f"๐Ÿ“Š Purge: {'ON' if enabled else 'OFF'} | {current}/{limit} ({usage:.0f}%) | โš ๏ธ Near {threshold}" else: response = f"๐Ÿ“Š Purge: {'ON' if enabled else 'OFF'} | {current}/{limit} ({usage:.0f}%) | โœ… Healthy" return response except Exception as e: return f"โŒ Error getting purge status: {e}" async def _handle_test_purge(self) -> str: """Test the improved purge system""" if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." try: result = await self.bot.repeater_manager.test_purge_system() if result.get('success', False): # Shortened for LoRa transmission contact = result.get('test_contact', 'Unknown') initial = result.get('initial_count', 0) final = result.get('final_count', 0) removed = result.get('contacts_removed', 0) method = result.get('purge_method', 'Unknown') response = f"๐Ÿงช Test: {contact} | {initial}โ†’{final} (-{removed}) | {method} | โœ… OK" else: # Shortened for LoRa transmission error = result.get('error', 'Unknown error') count = result.get('contact_count', 0) response = f"๐Ÿงช Test FAILED | {count} contacts | {error[:50]}..." return response except Exception as e: return f"โŒ Error testing purge system: {e}" async def _handle_debug_purge(self) -> str: """Debug the purge system to see what repeaters are available""" if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." try: # Get device contact info total_contacts = len(self.bot.meshcore.contacts) repeater_count = 0 repeaters_info = [] for contact_key, contact_data in self.bot.meshcore.contacts.items(): if self.bot.repeater_manager._is_repeater_device(contact_data): repeater_count += 1 name = contact_data.get('adv_name', contact_data.get('name', 'Unknown')) device_type = 'Repeater' if contact_data.get('type') == 3: device_type = 'RoomServer' last_seen = contact_data.get('last_seen', contact_data.get('last_advert', 'Unknown')) repeaters_info.append(f"โ€ข {name} ({device_type}) - Last seen: {last_seen}") # Shortened for LoRa transmission response = f"๐Ÿ” Debug: {total_contacts} total, {repeater_count} repeaters" if repeaters_info: # Show first 3 repeaters only for info in repeaters_info[:3]: response += f" | {info[:30]}..." if len(repeaters_info) > 3: response += f" | +{len(repeaters_info) - 3} more" else: response += " | โŒ No repeaters found" # Test the purge selection test_repeaters = await self.bot.repeater_manager._get_repeaters_for_purging(3) if test_repeaters: response += f" | Test: {len(test_repeaters)} available" else: response += " | Test: โŒ None available" return response except Exception as e: return f"โŒ Error debugging purge system: {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}" async def _handle_locations(self) -> str: """Show location data status for repeaters""" try: if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." # Get all active repeaters repeaters = await self.bot.repeater_manager.get_repeater_contacts(active_only=True) if not repeaters: return "No active repeaters found in database" # Analyze location data total_repeaters = len(repeaters) with_coordinates = 0 with_city = 0 with_state = 0 with_country = 0 no_location = 0 location_examples = [] for repeater in repeaters: has_coords = repeater.get('latitude') is not None and repeater.get('longitude') is not None has_city = bool(repeater.get('city')) has_state = bool(repeater.get('state')) has_country = bool(repeater.get('country')) if has_coords: with_coordinates += 1 if has_city: with_city += 1 if has_state: with_state += 1 if has_country: with_country += 1 if not (has_coords or has_city or has_state or has_country): no_location += 1 # Collect examples for display if len(location_examples) < 3: # Reduced to 3 examples to fit in message location_parts = [] if has_city: location_parts.append(repeater['city']) if has_state: location_parts.append(repeater['state']) if has_country: location_parts.append(repeater['country']) if location_parts: location_examples.append(f"โ€ข {repeater['name']}: {', '.join(location_parts)}") elif has_coords: location_examples.append(f"โ€ข {repeater['name']}: {repeater['latitude']:.4f}, {repeater['longitude']:.4f}") else: location_examples.append(f"โ€ข {repeater['name']}: No location data") # Build first message (summary) summary_lines = [ f"๐Ÿ“ Repeater Locations ({total_repeaters} total):", f"GPS: {with_coordinates} ({with_coordinates/total_repeaters*100:.0f}%)", f"City: {with_city} ({with_city/total_repeaters*100:.0f}%)", f"State: {with_state} ({with_state/total_repeaters*100:.0f}%)", f"Country: {with_country} ({with_country/total_repeaters*100:.0f}%)", f"None: {no_location} ({no_location/total_repeaters*100:.0f}%)" ] first_message = "\n".join(summary_lines) # Build second message (examples) if we have examples if location_examples: example_lines = ["Examples:"] example_lines.extend(location_examples) second_message = "\n".join(example_lines) # Return tuple for multi-message response return ("multi_message", first_message, second_message) else: return first_message except Exception as e: self.logger.error(f"Error getting repeater location status: {e}") return f"โŒ Error getting location status: {e}" async def _handle_update_geolocation(self, dry_run: bool = False, batch_size: int = 10) -> str: """Update missing geolocation data for repeaters""" try: if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." self.logger.info(f"Starting geolocation update process (dry_run={dry_run})") # Call the repeater manager method result = await self.bot.repeater_manager.populate_missing_geolocation_data(dry_run=dry_run, batch_size=batch_size) if 'error' in result: return f"โŒ Error updating geolocation data: {result['error']}" # Build response message action = "Would update" if dry_run else "Updated" response_lines = [ f"๐ŸŒ Geolocation Update {'(Dry Run)' if dry_run else ''}", f"Batch size: {batch_size}", f"Found: {result['total_found']} repeaters with missing data", f"{action}: {result['updated']} repeaters", f"Errors: {result['errors']}", f"Skipped: {result['skipped']}" ] if result['total_found'] == 0: response_lines.append("โœ… All repeaters already have complete geolocation data!") elif result['updated'] > 0: if dry_run: response_lines.append("๐Ÿ’ก Run without 'dry-run' to apply these updates") else: response_lines.append("โœ… Geolocation data updated successfully!") return "\n".join(response_lines) except Exception as e: self.logger.error(f"Error updating geolocation data: {e}") return f"โŒ Error updating geolocation data: {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) โ€ข `locations` - Show location data status for repeaters โ€ข `update-geo` - Update missing geolocation data (state/country) from coordinates โ€ข `update-geo dry-run` - Preview what would be updated without making changes โ€ข `update-geo 5` - Update up to 5 repeaters (default: 10) โ€ข `update-geo dry-run 3` - Preview updates for up to 3 repeaters โ€ข `purge all` - Purge all repeaters โ€ข `purge all force` - Force purge all repeaters (uses multiple removal methods) โ€ข `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 โ€ข `auto-purge` - Show auto-purge status and controls โ€ข `auto-purge trigger` - Manually trigger auto-purge โ€ข `auto-purge enable/disable` - Enable/disable auto-purge โ€ข `purge-status` - Show detailed purge status and recommendations โ€ข `test-purge` - Test the improved purge system โ€ข `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-purge` - Check auto-purge status โ€ข `!repeater auto-purge trigger` - Manually trigger auto-purge โ€ข `!repeater purge-status` - Detailed purge status โ€ข `!repeater auto off` - Disable manual contact addition โ€ข `!repeater test` - Test meshcore-cli commands โ€ข `!repeater purge all` - Purge all repeaters โ€ข `!repeater purge all force` - Force purge all repeaters โ€ข `!repeater purge 30` - Purge repeaters older than 30 days โ€ข `!repeater stats` - Show management statistics โ€ข `!repeater geocode` - Show geocoding status โ€ข `!repeater geocode trigger` - Manually trigger geocoding **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""" async def _handle_geocode(self, args: List[str]) -> str: """Handle geocoding commands""" if not hasattr(self.bot, 'repeater_manager'): return "Repeater manager not initialized. Please check bot configuration." try: if not args: # Show geocoding status status = await self._get_geocoding_status() return status elif args[0] == "trigger": # Manually trigger background geocoding (single contact) await self.bot.repeater_manager._background_geocoding() return "๐ŸŒ Background geocoding triggered (1 contact)" elif args[0] == "bulk": # Trigger bulk geocoding for multiple contacts batch_size = 10 if len(args) > 1 and args[1].isdigit(): batch_size = int(args[1]) batch_size = min(batch_size, 50) # Cap at 50 for safety result = await self.bot.repeater_manager.populate_missing_geolocation_data( dry_run=False, batch_size=batch_size ) if 'error' in result: return f"โŒ Bulk geocoding error: {result['error']}" return (f"๐ŸŒ Bulk geocoding completed:\n" f"Found: {result['total_found']} contacts\n" f"Updated: {result['updated']} contacts\n" f"Errors: {result['errors']}\n" f"Skipped: {result['skipped']}") elif args[0] == "dry-run": # Test bulk geocoding without making changes batch_size = 10 if len(args) > 1 and args[1].isdigit(): batch_size = int(args[1]) batch_size = min(batch_size, 50) result = await self.bot.repeater_manager.populate_missing_geolocation_data( dry_run=True, batch_size=batch_size ) if 'error' in result: return f"โŒ Dry run error: {result['error']}" return (f"๐ŸŒ Dry run results:\n" f"Would update: {result['updated']} contacts\n" f"Found: {result['total_found']} contacts\n" f"Errors: {result['errors']}\n" f"Skipped: {result['skipped']}\n" f"๐Ÿ’ก Use '!repeater geocode bulk' to apply changes") elif args[0] == "status": # Show detailed geocoding status return await self._get_geocoding_status() else: return ("Usage: !repeater geocode [trigger|bulk|dry-run|status]\n" " trigger - Geocode 1 contact\n" " bulk [N] - Geocode up to N contacts (default: 10, max: 50)\n" " dry-run [N] - Test geocoding without changes\n" " status - Show geocoding status") except Exception as e: self.logger.error(f"Error in geocoding command: {e}") return f"โŒ Geocoding error: {e}" async def _get_geocoding_status(self) -> str: """Get geocoding status""" try: # Count contacts needing geocoding needing_geocoding = self.bot.repeater_manager.db_manager.execute_query(''' SELECT COUNT(*) as count FROM complete_contact_tracking WHERE latitude IS NOT NULL AND longitude IS NOT NULL AND (city IS NULL OR city = '') AND last_geocoding_attempt IS NULL ''') # Count contacts with geocoding data with_geocoding = self.bot.repeater_manager.db_manager.execute_query(''' SELECT COUNT(*) as count FROM complete_contact_tracking WHERE city IS NOT NULL AND city != '' ''') # Count total contacts with coordinates with_coords = self.bot.repeater_manager.db_manager.execute_query(''' SELECT COUNT(*) as count FROM complete_contact_tracking WHERE latitude IS NOT NULL AND longitude IS NOT NULL ''') needing = needing_geocoding[0]['count'] if needing_geocoding else 0 with_geo = with_geocoding[0]['count'] if with_geocoding else 0 total_coords = with_coords[0]['count'] if with_coords else 0 # Shortened for LoRa (130 char limit) if needing > 0: return f"๐ŸŒ Geocoding: {with_geo}/{total_coords} done, {needing} pending" else: return f"๐ŸŒ Geocoding: {with_geo}/{total_coords} complete โœ…" except Exception as e: return f"โŒ Geocoding status error: {e}"