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.
826 lines
36 KiB
Python
826 lines
36 KiB
Python
#!/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.
|
||
|
||
Provides functionality to scan, list, purge, and manage repeater and companion contacts
|
||
within the mesh network. Includes automated cleanup tools and statistics.
|
||
"""
|
||
|
||
# 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"
|
||
requires_internet = True # Requires internet access for geocoding (Nominatim)
|
||
|
||
# LoRa message size limit (conservative to avoid overload)
|
||
# DM limit is 150 chars, public channel is 237 chars
|
||
MAX_LORA_MESSAGE_SIZE = 140 # characters, leaves buffer for protocol overhead
|
||
|
||
def __init__(self, bot):
|
||
super().__init__(bot)
|
||
self.repeater_enabled = self.get_config_value('Repeater_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.repeater_enabled:
|
||
return False
|
||
return super().can_execute(message)
|
||
|
||
def _truncate_for_lora(self, message: str, max_size: int = None) -> str:
|
||
"""Truncate message to fit within LoRa size limits.
|
||
|
||
Args:
|
||
message: The message to truncate
|
||
max_size: Maximum size (defaults to MAX_LORA_MESSAGE_SIZE)
|
||
|
||
Returns:
|
||
Truncated message with indicator if truncated
|
||
"""
|
||
max_size = max_size or self.MAX_LORA_MESSAGE_SIZE
|
||
if len(message) <= max_size:
|
||
return message
|
||
|
||
truncate_suffix = "...(use web viewer)"
|
||
available_size = max_size - len(truncate_suffix)
|
||
return message[:available_size] + truncate_suffix
|
||
|
||
def _get_deprecation_warning(self, web_viewer_url: str = None) -> str:
|
||
"""Get deprecation warning message for commands replaced by web viewer.
|
||
|
||
Args:
|
||
web_viewer_url: Optional custom web viewer URL
|
||
|
||
Returns:
|
||
Deprecation warning message
|
||
"""
|
||
if web_viewer_url:
|
||
return f"⚠️ DEPRECATED: Use web viewer at {web_viewer_url}/contacts"
|
||
return "⚠️ DEPRECATED: Use web viewer for this feature (check bot config for URL)"
|
||
|
||
def matches_keyword(self, message: MeshMessage) -> bool:
|
||
"""Check if message starts with 'repeater' keyword.
|
||
|
||
Args:
|
||
message: The message to check for the keyword.
|
||
|
||
Returns:
|
||
bool: True if the message starts with any of the command keywords.
|
||
"""
|
||
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.
|
||
|
||
Parses subcommands (scan, list, purge, etc.) and routes to the appropriate handler.
|
||
|
||
Args:
|
||
message: The message triggering the command.
|
||
|
||
Returns:
|
||
bool: True if executed successfully, False otherwise.
|
||
"""
|
||
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 (DEPRECATED - automatic in backend).
|
||
|
||
Triggers a scan of the device's contact list to identify and catalog repeaters.
|
||
|
||
Returns:
|
||
str: Result message describing the scan outcome.
|
||
"""
|
||
# Return deprecation warning
|
||
return self._get_deprecation_warning() + "\nScanning happens automatically in the backend."
|
||
|
||
async def _handle_list(self, args: List[str]) -> str:
|
||
"""List repeater contacts (DEPRECATED - use web viewer or prefix command).
|
||
|
||
Args:
|
||
args: Command arguments (ignored).
|
||
|
||
Returns:
|
||
str: Deprecation warning.
|
||
"""
|
||
return self._get_deprecation_warning() + "\nUse 'prefix <name>' to find specific repeaters or web viewer to browse all."
|
||
|
||
async def _handle_purge(self, args: List[str]) -> str:
|
||
"""Purge repeater or companion contacts.
|
||
|
||
Supports purging by name, age (days), or 'all'.
|
||
|
||
Args:
|
||
args: Command arguments specifying what to purge.
|
||
|
||
Returns:
|
||
str: Result message describing the purge outcome.
|
||
"""
|
||
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.
|
||
|
||
Args:
|
||
args: Command arguments (optional days threshold).
|
||
|
||
Returns:
|
||
str: Result message describing the purge outcome.
|
||
"""
|
||
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 (DEPRECATED - use web viewer).
|
||
|
||
Args:
|
||
args: Command arguments (name pattern to restore).
|
||
|
||
Returns:
|
||
str: Deprecation warning.
|
||
"""
|
||
return self._get_deprecation_warning()
|
||
|
||
async def _handle_stats(self) -> str:
|
||
"""Show statistics (DEPRECATED - use web viewer).
|
||
|
||
Returns:
|
||
str: Deprecation warning.
|
||
"""
|
||
return self._get_deprecation_warning() + "\nView detailed statistics in the web viewer."
|
||
|
||
async def _handle_status(self) -> str:
|
||
"""Show contact list status and limits.
|
||
|
||
Returns:
|
||
str: Formatted status message showing usage vs 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 (DEPRECATED - use web viewer).
|
||
|
||
Args:
|
||
args: Command arguments (e.g., '--dry-run').
|
||
|
||
Returns:
|
||
str: Deprecation warning.
|
||
"""
|
||
return self._get_deprecation_warning()
|
||
|
||
async def _handle_add(self, args: List[str]) -> str:
|
||
"""Add a discovered contact (DEPRECATED - use web viewer).
|
||
|
||
Args:
|
||
args: Command arguments (name, public_key, reason).
|
||
|
||
Returns:
|
||
str: Deprecation warning.
|
||
"""
|
||
return self._get_deprecation_warning()
|
||
|
||
async def _handle_discover(self) -> str:
|
||
"""Discover companion contacts (DEPRECATED - automatic in backend).
|
||
|
||
Returns:
|
||
str: Deprecation warning.
|
||
"""
|
||
return self._get_deprecation_warning() + "\nDiscovery happens automatically in the backend."
|
||
|
||
async def _handle_contact_stats(self) -> str:
|
||
"""Show statistics about the complete repeater tracking database.
|
||
|
||
Returns:
|
||
str: Formatted statistics summary.
|
||
"""
|
||
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.
|
||
|
||
Args:
|
||
args: Command arguments (trigger, enable, disable, monitor).
|
||
|
||
Returns:
|
||
str: Result message.
|
||
"""
|
||
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.
|
||
|
||
Returns:
|
||
str: Formatted status message.
|
||
"""
|
||
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.
|
||
|
||
Runs a test purge operation without permanently removing valid contacts,
|
||
useful for verifying system functionality.
|
||
|
||
Returns:
|
||
str: Test result message.
|
||
"""
|
||
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 purge system (DEPRECATED - debug feature).
|
||
|
||
Returns:
|
||
str: Deprecation warning.
|
||
"""
|
||
return "⚠️ DEPRECATED: Debug feature - check logs for purge system status."
|
||
|
||
async def _handle_auto(self, args: List[str]) -> str:
|
||
"""Toggle auto settings (DEPRECATED - use config file).
|
||
|
||
Returns:
|
||
str: Deprecation warning.
|
||
"""
|
||
return "⚠️ DEPRECATED: Edit config file to change auto-management settings."
|
||
|
||
async def _handle_test(self, args: List[str]) -> str:
|
||
"""Test commands (DEPRECATED - debug feature).
|
||
|
||
Returns:
|
||
str: Deprecation warning.
|
||
"""
|
||
return "⚠️ DEPRECATED: Debug feature - check logs for system status."
|
||
|
||
async def _handle_locations(self) -> str:
|
||
"""Show location data (DEPRECATED - use web viewer map).
|
||
|
||
Returns:
|
||
str: Deprecation warning.
|
||
"""
|
||
return self._get_deprecation_warning() + "\nView locations on the interactive map in the web viewer."
|
||
|
||
async def _handle_update_geolocation(self, dry_run: bool = False, batch_size: int = 10) -> str:
|
||
"""Update geolocation data (DEPRECATED - automatic in backend).
|
||
|
||
Returns:
|
||
str: Deprecation warning.
|
||
"""
|
||
return self._get_deprecation_warning() + "\nGeocoding happens automatically in the backend."
|
||
|
||
def get_help(self) -> str:
|
||
"""Get help text for the repeater command (essential commands only)"""
|
||
# Ultra-compact help for 150 char DM limit
|
||
return "status|purge all|purge companions [days]|auto-purge\n⚠️ Use web viewer or 'prefix' cmd to browse"
|
||
|
||
async def _handle_geocode(self, args: List[str]) -> str:
|
||
"""Handle geocoding (DEPRECATED - automatic in backend).
|
||
|
||
Returns:
|
||
str: Deprecation warning.
|
||
"""
|
||
return self._get_deprecation_warning() + "\nGeocoding happens automatically in the backend."
|
||
|
||
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}"
|