mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-04-25 16:52:06 +00:00
486 lines
23 KiB
Python
486 lines
23 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Repeater Contact Management System
|
|
Manages a database of repeater contacts and provides purging functionality
|
|
"""
|
|
|
|
import sqlite3
|
|
import asyncio
|
|
import json
|
|
from datetime import datetime, timedelta
|
|
from typing import Dict, List, Optional, Tuple
|
|
from pathlib import Path
|
|
|
|
|
|
class RepeaterManager:
|
|
"""Manages repeater contacts database and purging operations"""
|
|
|
|
def __init__(self, bot, db_path: str = "repeater_contacts.db"):
|
|
self.bot = bot
|
|
self.logger = bot.logger
|
|
self.db_path = db_path
|
|
self._init_database()
|
|
|
|
def _init_database(self):
|
|
"""Initialize the SQLite database with required tables"""
|
|
try:
|
|
with sqlite3.connect(self.db_path) as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Create repeater_contacts table
|
|
cursor.execute('''
|
|
CREATE TABLE IF NOT EXISTS repeater_contacts (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
public_key TEXT UNIQUE NOT NULL,
|
|
name TEXT NOT NULL,
|
|
device_type TEXT NOT NULL, -- 'Repeater' or 'RoomServer'
|
|
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
contact_data TEXT, -- JSON string of full contact data
|
|
is_active BOOLEAN DEFAULT 1,
|
|
purge_count INTEGER DEFAULT 0
|
|
)
|
|
''')
|
|
|
|
# Create purging_log table for audit trail
|
|
cursor.execute('''
|
|
CREATE TABLE IF NOT EXISTS purging_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
action TEXT NOT NULL, -- 'purged', 'restored', 'added'
|
|
public_key TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
reason TEXT
|
|
)
|
|
''')
|
|
|
|
# Create indexes for better performance
|
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_public_key ON repeater_contacts(public_key)')
|
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_device_type ON repeater_contacts(device_type)')
|
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_last_seen ON repeater_contacts(last_seen)')
|
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_is_active ON repeater_contacts(is_active)')
|
|
|
|
conn.commit()
|
|
self.logger.info("Repeater contacts database initialized successfully")
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Failed to initialize repeater database: {e}")
|
|
raise
|
|
|
|
def _is_repeater_device(self, contact_data: Dict) -> bool:
|
|
"""Check if a contact is a repeater or room server using available contact data"""
|
|
try:
|
|
# Primary detection: Check device type field
|
|
# Based on the actual contact data structure:
|
|
# type: 2 = repeater, type: 3 = room server
|
|
device_type = contact_data.get('type')
|
|
if device_type in [2, 3]:
|
|
return True
|
|
|
|
# Secondary detection: Check for role fields in contact data
|
|
role_fields = ['role', 'device_role', 'mode', 'device_type']
|
|
for field in role_fields:
|
|
value = contact_data.get(field, '')
|
|
if value and isinstance(value, str):
|
|
value_lower = value.lower()
|
|
if any(role in value_lower for role in ['repeater', 'roomserver', 'room_server']):
|
|
return True
|
|
|
|
# Tertiary detection: Check advertisement flags
|
|
# Some repeaters have specific flags that indicate their function
|
|
flags = contact_data.get('flags', contact_data.get('advert_flags', ''))
|
|
if flags:
|
|
if isinstance(flags, (int, str)):
|
|
flags_str = str(flags).lower()
|
|
if any(role in flags_str for role in ['repeater', 'roomserver', 'room_server']):
|
|
return True
|
|
|
|
# Quaternary detection: Check name patterns with validation
|
|
name = contact_data.get('adv_name', contact_data.get('name', '')).lower()
|
|
if name:
|
|
# Strong repeater indicators
|
|
strong_indicators = ['repeater', 'roompeater', 'room server', 'roomserver', 'relay', 'gateway']
|
|
if any(indicator in name for indicator in strong_indicators):
|
|
return True
|
|
|
|
# Room server indicators
|
|
room_indicators = ['room', 'rs ', 'rs-', 'rs_']
|
|
if any(indicator in name for indicator in room_indicators):
|
|
# Additional validation to avoid false positives
|
|
user_indicators = ['user', 'person', 'mobile', 'phone', 'device', 'pager']
|
|
if not any(user_indicator in name for user_indicator in user_indicators):
|
|
return True
|
|
|
|
# Quinary detection: Check path characteristics
|
|
# Some repeaters have specific path patterns
|
|
out_path_len = contact_data.get('out_path_len', -1)
|
|
if out_path_len == 0: # Direct connection might indicate repeater
|
|
# Additional validation with name check
|
|
if name and any(indicator in name for indicator in ['repeater', 'room', 'relay']):
|
|
return True
|
|
|
|
return False
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error checking if device is repeater: {e}")
|
|
return False
|
|
|
|
async def scan_and_catalog_repeaters(self) -> int:
|
|
"""Scan current contacts and catalog any repeaters found"""
|
|
# Wait for contacts to be loaded if they're not ready yet
|
|
if not hasattr(self.bot.meshcore, 'contacts') or not self.bot.meshcore.contacts:
|
|
self.logger.info("Contacts not loaded yet, waiting...")
|
|
# Wait up to 10 seconds for contacts to load
|
|
for i in range(20): # 20 * 0.5 = 10 seconds
|
|
await asyncio.sleep(0.5)
|
|
if hasattr(self.bot.meshcore, 'contacts') and self.bot.meshcore.contacts:
|
|
break
|
|
else:
|
|
self.logger.warning("No contacts available to scan for repeaters after waiting")
|
|
return 0
|
|
|
|
contacts = self.bot.meshcore.contacts
|
|
self.logger.info(f"Scanning {len(contacts)} contacts for repeaters...")
|
|
|
|
cataloged_count = 0
|
|
processed_count = 0
|
|
|
|
try:
|
|
with sqlite3.connect(self.db_path) as conn:
|
|
cursor = conn.cursor()
|
|
|
|
for contact_key, contact_data in self.bot.meshcore.contacts.items():
|
|
processed_count += 1
|
|
|
|
# Log progress every 20 contacts
|
|
if processed_count % 20 == 0:
|
|
self.logger.info(f"Scan progress: {processed_count}/{len(contacts)} contacts processed, {cataloged_count} repeaters found")
|
|
|
|
if self._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'))
|
|
|
|
# Determine device type based on contact data
|
|
contact_type = contact_data.get('type')
|
|
if contact_type == 3:
|
|
device_type = 'RoomServer'
|
|
elif contact_type == 2:
|
|
device_type = 'Repeater'
|
|
else:
|
|
# Fallback to name-based detection
|
|
device_type = 'Repeater'
|
|
if 'room' in name.lower() or 'server' in name.lower():
|
|
device_type = 'RoomServer'
|
|
|
|
# Check if already exists
|
|
cursor.execute(
|
|
'SELECT id, last_seen FROM repeater_contacts WHERE public_key = ?',
|
|
(public_key,)
|
|
)
|
|
existing = cursor.fetchone()
|
|
|
|
if existing:
|
|
# Update last_seen timestamp
|
|
cursor.execute(
|
|
'UPDATE repeater_contacts SET last_seen = CURRENT_TIMESTAMP, is_active = 1 WHERE public_key = ?',
|
|
(public_key,)
|
|
)
|
|
else:
|
|
# Insert new repeater
|
|
cursor.execute('''
|
|
INSERT INTO repeater_contacts
|
|
(public_key, name, device_type, contact_data)
|
|
VALUES (?, ?, ?, ?)
|
|
''', (
|
|
public_key,
|
|
name,
|
|
device_type,
|
|
json.dumps(contact_data)
|
|
))
|
|
|
|
# Log the addition
|
|
cursor.execute('''
|
|
INSERT INTO purging_log (action, public_key, name, reason)
|
|
VALUES ('added', ?, ?, 'Auto-detected during contact scan')
|
|
''', (public_key, name))
|
|
|
|
cataloged_count += 1
|
|
self.logger.info(f"Cataloged new repeater: {name} ({device_type})")
|
|
|
|
conn.commit()
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error scanning contacts for repeaters: {e}")
|
|
|
|
if cataloged_count > 0:
|
|
self.logger.info(f"Cataloged {cataloged_count} new repeaters")
|
|
|
|
self.logger.info(f"Scan completed: {cataloged_count} repeaters cataloged from {len(contacts)} contacts")
|
|
self.logger.info(f"Scan summary: {processed_count} contacts processed, {cataloged_count} repeaters found")
|
|
return cataloged_count
|
|
|
|
async def get_repeater_contacts(self, active_only: bool = True) -> List[Dict]:
|
|
"""Get list of repeater contacts from database"""
|
|
try:
|
|
with sqlite3.connect(self.db_path) as conn:
|
|
conn.row_factory = sqlite3.Row
|
|
cursor = conn.cursor()
|
|
|
|
query = 'SELECT * FROM repeater_contacts'
|
|
if active_only:
|
|
query += ' WHERE is_active = 1'
|
|
query += ' ORDER BY last_seen DESC'
|
|
|
|
cursor.execute(query)
|
|
rows = cursor.fetchall()
|
|
|
|
return [dict(row) for row in rows]
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error retrieving repeater contacts: {e}")
|
|
return []
|
|
|
|
async def purge_repeater_from_contacts(self, public_key: str, reason: str = "Manual purge") -> bool:
|
|
"""Remove a specific repeater from the device's contact list"""
|
|
self.logger.info(f"Starting purge process for public_key: {public_key}")
|
|
self.logger.debug(f"Purge reason: {reason}")
|
|
|
|
try:
|
|
# Find the contact in meshcore
|
|
contact_to_remove = None
|
|
contact_name = None
|
|
|
|
self.logger.debug(f"Searching through {len(self.bot.meshcore.contacts)} contacts...")
|
|
for contact_key, contact_data in self.bot.meshcore.contacts.items():
|
|
if contact_data.get('public_key', contact_key) == public_key:
|
|
contact_to_remove = contact_data
|
|
contact_name = contact_data.get('adv_name', contact_data.get('name', 'Unknown'))
|
|
self.logger.debug(f"Found contact: {contact_name} (key: {contact_key})")
|
|
break
|
|
|
|
if not contact_to_remove:
|
|
self.logger.warning(f"Repeater with public key {public_key} not found in current contacts")
|
|
return False
|
|
|
|
# Actually remove the contact from the device using meshcore-cli
|
|
# Add timeout and error handling for LoRa communication
|
|
try:
|
|
from meshcore_cli.meshcore_cli import next_cmd
|
|
import asyncio
|
|
|
|
self.logger.info(f"Starting removal of contact '{contact_name}' from device...")
|
|
self.logger.debug(f"Contact details: public_key={public_key}, name='{contact_name}'")
|
|
|
|
# Use asyncio.wait_for to add timeout for LoRa communication
|
|
try:
|
|
self.logger.info(f"Sending remove_contact command for '{contact_name}' (timeout: 30s)...")
|
|
start_time = asyncio.get_event_loop().time()
|
|
|
|
result = await asyncio.wait_for(
|
|
next_cmd(self.bot.meshcore, ["remove_contact", contact_name]),
|
|
timeout=30.0 # 30 second timeout for LoRa communication
|
|
)
|
|
|
|
end_time = asyncio.get_event_loop().time()
|
|
duration = end_time - start_time
|
|
self.logger.info(f"Remove command completed in {duration:.2f} seconds")
|
|
|
|
# Check if removal was successful
|
|
if result is not None:
|
|
self.logger.info(f"Successfully removed contact '{contact_name}' from device")
|
|
self.logger.debug(f"Command result: {result}")
|
|
else:
|
|
self.logger.warning(f"Contact removal command returned no result for '{contact_name}'")
|
|
|
|
except asyncio.TimeoutError:
|
|
end_time = asyncio.get_event_loop().time()
|
|
duration = end_time - start_time
|
|
self.logger.warning(f"Timeout removing contact '{contact_name}' after {duration:.2f} seconds (LoRa communication)")
|
|
# Continue with database operations even if device removal timed out
|
|
except Exception as cmd_error:
|
|
end_time = asyncio.get_event_loop().time()
|
|
duration = end_time - start_time
|
|
self.logger.error(f"Command error removing contact '{contact_name}' after {duration:.2f} seconds: {cmd_error}")
|
|
self.logger.debug(f"Error type: {type(cmd_error).__name__}")
|
|
# Continue with database operations even if device removal failed
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Failed to remove contact '{contact_name}' from device: {e}")
|
|
self.logger.debug(f"Error type: {type(e).__name__}")
|
|
# Continue with database operations even if device removal failed
|
|
|
|
# Mark as inactive in database
|
|
with sqlite3.connect(self.db_path) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
'UPDATE repeater_contacts SET is_active = 0, purge_count = purge_count + 1 WHERE public_key = ?',
|
|
(public_key,)
|
|
)
|
|
|
|
# Log the purge action
|
|
cursor.execute('''
|
|
INSERT INTO purging_log (action, public_key, name, reason)
|
|
VALUES ('purged', ?, ?, ?)
|
|
''', (public_key, contact_name, reason))
|
|
|
|
conn.commit()
|
|
|
|
self.logger.info(f"Successfully purged repeater {contact_name}: {reason}")
|
|
self.logger.debug(f"Purge process completed successfully for {contact_name}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error purging repeater {public_key}: {e}")
|
|
self.logger.debug(f"Error type: {type(e).__name__}")
|
|
return False
|
|
|
|
async def purge_old_repeaters(self, days_old: int = 30, reason: str = "Automatic purge - old contacts") -> int:
|
|
"""Purge repeaters that haven't been seen in specified days"""
|
|
try:
|
|
cutoff_date = datetime.now() - timedelta(days=days_old)
|
|
|
|
with sqlite3.connect(self.db_path) as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Find old repeaters
|
|
cursor.execute('''
|
|
SELECT public_key, name FROM repeater_contacts
|
|
WHERE is_active = 1 AND last_seen < ?
|
|
''', (cutoff_date.isoformat(),))
|
|
|
|
old_repeaters = cursor.fetchall()
|
|
purged_count = 0
|
|
|
|
# Process repeaters with delays to avoid overwhelming LoRa network
|
|
self.logger.info(f"Starting batch purge of {len(old_repeaters)} old repeaters...")
|
|
start_time = asyncio.get_event_loop().time()
|
|
|
|
for i, (public_key, name) in enumerate(old_repeaters):
|
|
self.logger.info(f"Purging repeater {i+1}/{len(old_repeaters)}: {name}")
|
|
self.logger.debug(f"Processing public_key: {public_key}")
|
|
|
|
try:
|
|
if await self.purge_repeater_from_contacts(public_key, f"{reason} (last seen: {cutoff_date.date()})"):
|
|
purged_count += 1
|
|
self.logger.info(f"Successfully purged {i+1}/{len(old_repeaters)}: {name}")
|
|
else:
|
|
self.logger.warning(f"Failed to purge {i+1}/{len(old_repeaters)}: {name}")
|
|
except Exception as e:
|
|
self.logger.error(f"Exception purging {i+1}/{len(old_repeaters)}: {name} - {e}")
|
|
|
|
# Add delay between removals to avoid overwhelming LoRa network
|
|
if i < len(old_repeaters) - 1: # Don't delay after the last one
|
|
self.logger.debug(f"Waiting 2 seconds before next removal...")
|
|
await asyncio.sleep(2) # 2 second delay between removals
|
|
|
|
end_time = asyncio.get_event_loop().time()
|
|
total_duration = end_time - start_time
|
|
self.logger.info(f"Batch purge completed in {total_duration:.2f} seconds")
|
|
|
|
self.logger.info(f"Purged {purged_count} old repeaters (older than {days_old} days)")
|
|
return purged_count
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error purging old repeaters: {e}")
|
|
return 0
|
|
|
|
async def restore_repeater(self, public_key: str, reason: str = "Manual restore") -> bool:
|
|
"""Restore a previously purged repeater"""
|
|
try:
|
|
with sqlite3.connect(self.db_path) as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Get repeater info before updating
|
|
cursor.execute('''
|
|
SELECT name, contact_data FROM repeater_contacts WHERE public_key = ?
|
|
''', (public_key,))
|
|
result = cursor.fetchone()
|
|
|
|
if not result:
|
|
self.logger.warning(f"No repeater found with public key {public_key}")
|
|
return False
|
|
|
|
name, contact_data_json = result
|
|
|
|
# Mark as active again
|
|
cursor.execute(
|
|
'UPDATE repeater_contacts SET is_active = 1 WHERE public_key = ?',
|
|
(public_key,)
|
|
)
|
|
|
|
# Log the restore action
|
|
cursor.execute('''
|
|
INSERT INTO purging_log (action, public_key, name, reason)
|
|
VALUES ('restored', ?, ?, ?)
|
|
''', (public_key, name, reason))
|
|
|
|
conn.commit()
|
|
|
|
# Note: Restoring a contact to the device would require re-adding it
|
|
# This is complex as it requires the contact's URI or public key
|
|
# For now, we just mark it as active in our database
|
|
# The contact would need to be re-discovered through normal mesh operations
|
|
|
|
self.logger.info(f"Restored repeater {name} ({public_key}) - contact will need to be re-discovered")
|
|
return True
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error restoring repeater {public_key}: {e}")
|
|
return False
|
|
|
|
async def get_purging_stats(self) -> Dict:
|
|
"""Get statistics about repeater purging operations"""
|
|
try:
|
|
with sqlite3.connect(self.db_path) as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Get total counts
|
|
cursor.execute('SELECT COUNT(*) FROM repeater_contacts')
|
|
total_repeaters = cursor.fetchone()[0]
|
|
|
|
cursor.execute('SELECT COUNT(*) FROM repeater_contacts WHERE is_active = 1')
|
|
active_repeaters = cursor.fetchone()[0]
|
|
|
|
cursor.execute('SELECT COUNT(*) FROM repeater_contacts WHERE is_active = 0')
|
|
purged_repeaters = cursor.fetchone()[0]
|
|
|
|
# Get recent purging activity
|
|
cursor.execute('''
|
|
SELECT action, COUNT(*) FROM purging_log
|
|
WHERE timestamp > datetime('now', '-7 days')
|
|
GROUP BY action
|
|
''')
|
|
recent_activity = dict(cursor.fetchall())
|
|
|
|
return {
|
|
'total_repeaters': total_repeaters,
|
|
'active_repeaters': active_repeaters,
|
|
'purged_repeaters': purged_repeaters,
|
|
'recent_activity_7_days': recent_activity
|
|
}
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error getting purging stats: {e}")
|
|
return {}
|
|
|
|
async def cleanup_database(self, days_to_keep_logs: int = 90):
|
|
"""Clean up old purging log entries"""
|
|
try:
|
|
cutoff_date = datetime.now() - timedelta(days=days_to_keep_logs)
|
|
|
|
with sqlite3.connect(self.db_path) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
'DELETE FROM purging_log WHERE timestamp < ?',
|
|
(cutoff_date.isoformat(),)
|
|
)
|
|
|
|
deleted_count = cursor.rowcount
|
|
conn.commit()
|
|
|
|
if deleted_count > 0:
|
|
self.logger.info(f"Cleaned up {deleted_count} old purging log entries")
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error cleaning up database: {e}")
|