Implement companion contact purging and configurable recency/proximity weighting

- Added configuration options for companion contact purging, including thresholds for inactivity based on direct messages and advertisements.
- Enhanced the RepeaterManager to support automatic purging of companions when contact limits are exceeded.
- Updated PathCommand to utilize configurable recency/proximity weighting for improved path routing decisions.
- Introduced new commands for purging companions and updated existing commands to handle companion purging logic.
- Added API endpoint for manual geocoding of contacts and improved web viewer functionality for geocoding contacts.
This commit is contained in:
agessaman
2025-11-06 20:58:52 -08:00
parent cc97c48bbf
commit e114de7947
7 changed files with 853 additions and 253 deletions
+26
View File
@@ -96,6 +96,24 @@ admin_pubkeys =
# These commands will only work for users in the admin_pubkeys list
admin_commands = repeater,webviewer
[Companion_Purge]
# Enable companion contact purging
# true: Purge inactive companions when contact list is full
# false: Never purge companions (default: false for safety)
companion_purge_enabled = false
# Days since last DM to consider companion inactive
# Companions who haven't DM'd the bot in this many days may be purged
companion_dm_threshold_days = 30
# Days since last advert to consider companion inactive
# Companions who haven't adverted in this many days may be purged
companion_advert_threshold_days = 30
# Minimum days since last activity (DM or advert) before purge
# Companions must be inactive for at least this many days
companion_min_inactive_days = 30
[Jokes]
# Enable or disable the joke command
# true: Joke command is available
@@ -405,6 +423,14 @@ max_proximity_range = 200
# Set to 0 to disable age filtering
max_repeater_age_days = 14
# Recency vs Proximity weighting (0.0 to 1.0)
# Controls how much recency (when last heard) vs proximity (distance) matters
# 0.0 = 100% proximity (only distance matters)
# 1.0 = 100% recency (only when last heard matters)
# 0.4 = 40% recency, 60% proximity (default - balanced for path routing)
# Lower values favor closer repeaters, higher values favor recently heard repeaters
recency_weight = 0.4
# Confidence indicator symbols for path command
# High confidence (>= 0.9): Shows when path decoding is very reliable
high_confidence_symbol = 🎯
-219
View File
@@ -1,219 +0,0 @@
# Modern Flask-SocketIO Rewrite Benefits
## 🔍 Current Issues vs Modern Solution
### **1. Protocol & Architecture Issues**
| Current Implementation | Modern Flask-SocketIO 5.x |
|------------------------|---------------------------|
| ❌ Manual background thread with complex timeout handling | ✅ Built-in event-driven architecture |
| ❌ Fighting against Flask-SocketIO's design | ✅ Leveraging Flask-SocketIO's strengths |
| ❌ Complex connection tracking and cleanup | ✅ Built-in connection management |
| ❌ Manual ping/pong implementation | ✅ Native ping/pong with proper timeouts |
### **2. Connection Management**
| Current Issues | Modern Solution |
|----------------|-----------------|
| ❌ 170+ file descriptors due to connection leaks | ✅ Automatic connection cleanup |
| ❌ Manual connection tracking with race conditions | ✅ Built-in client tracking |
| ❌ Complex stale connection cleanup | ✅ Automatic connection lifecycle management |
| ❌ Circuit breaker trips due to hanging connections | ✅ Robust connection handling |
### **3. Real-time Data Handling**
| Current Problems | Modern Approach |
|------------------|-----------------|
| ❌ Background thread hanging on SocketIO emit | ✅ Event-driven data broadcasting |
| ❌ Complex timeout mechanisms | ✅ Built-in timeout handling |
| ❌ Manual queue management | ✅ Direct event emission |
| ❌ Resource leaks from hanging threads | ✅ Clean event-driven architecture |
## 🛠️ Key Improvements in Modern Implementation
### **1. Flask-SocketIO 5.x Best Practices**
```python
# Modern SocketIO Configuration
self.socketio = SocketIO(
self.app,
cors_allowed_origins="*",
max_http_buffer_size=1000000,
ping_timeout=5, # Flask-SocketIO 5.x default
ping_interval=25, # Flask-SocketIO 5.x default
logger=True, # Proper logging
engineio_logger=True, # EngineIO logging
async_mode='threading' # Better stability
)
```
### **2. Event-Driven Architecture**
```python
# Modern event handlers
@socketio.on('connect')
def handle_connect():
# Built-in connection management
client_id = request.sid
self.connected_clients[client_id] = {
'connected_at': time.time(),
'last_activity': time.time(),
'subscribed_commands': False,
'subscribed_packets': False
}
emit('status', {'message': 'Connected'})
@socketio.on('subscribe_commands')
def handle_subscribe_commands():
# Clean subscription handling
client_id = request.sid
self.connected_clients[client_id]['subscribed_commands'] = True
emit('status', {'message': 'Subscribed to command stream'})
```
### **3. Direct Data Broadcasting**
```python
# Modern data handling - no background threads needed
def _handle_command_data(self, command_data):
"""Handle incoming command data from bot"""
subscribed_clients = [
client_id for client_id, client_info in self.connected_clients.items()
if client_info.get('subscribed_commands', False)
]
if subscribed_clients:
self.socketio.emit('command_data', command_data, room=None)
```
### **4. Modern Client-Side Implementation**
```javascript
// Modern Socket.IO client with proper error handling
class ModernBotMonitor {
constructor() {
this.socket = io({
transports: ['websocket', 'polling'],
timeout: 5000,
forceNew: true
});
this.setupSocketEvents();
this.startPingInterval();
}
setupSocketEvents() {
this.socket.on('connect', () => {
this.connected = true;
this.updateConnectionStatus('Connected', 'connected');
});
this.socket.on('command_data', (data) => {
this.addCommandEntry(data);
});
// Modern ping/pong pattern
this.socket.on('pong', () => {
this.lastActivity = new Date();
this.updateLastActivity();
});
}
}
```
## 📊 Expected Performance Improvements
### **Resource Usage**
- **File Descriptors**: 170+ → <20 (90% reduction)
- **Memory Usage**: Significant reduction due to no background threads
- **CPU Usage**: Lower due to event-driven architecture
- **Connection Stability**: Much more stable with built-in management
### **Reliability**
- **No More Hanging**: Event-driven architecture prevents thread blocking
- **Automatic Recovery**: Built-in connection management handles failures
- **Better Error Handling**: Proper SocketIO error handling
- **Circuit Breaker**: Less likely to trip due to better connection management
### **Maintainability**
- **Simpler Code**: No complex background thread management
- **Better Logging**: Proper Flask-SocketIO logging
- **Easier Debugging**: Clear event-driven flow
- **Modern Patterns**: Following Flask-SocketIO 5.x best practices
## 🚀 Migration Strategy
### **Phase 1: Parallel Implementation**
1. Deploy modern implementation alongside current one
2. Test with real bot data
3. Compare performance and stability
### **Phase 2: Gradual Migration**
1. Switch bot integration to use modern web viewer
2. Monitor for 24+ hours
3. Compare metrics with current implementation
### **Phase 3: Full Replacement**
1. Replace current web viewer with modern implementation
2. Remove old code
3. Update documentation
## 🎯 Expected Results
### **Immediate Benefits**
- ✅ No more 40-minute hanging pattern
- ✅ No more circuit breaker trips
- ✅ No more connection leaks
- ✅ Better real-time performance
### **Long-term Benefits**
- ✅ Easier maintenance and debugging
- ✅ Better scalability
- ✅ Modern, maintainable codebase
- ✅ Following Flask-SocketIO best practices
## 🔧 Implementation Notes
### **Configuration Changes**
```ini
# Modern configuration
[Web_Viewer]
host = 127.0.0.1
port = 8080
enabled = true
auto_start = true
debug = false
# Modern SocketIO settings
ping_timeout = 5
ping_interval = 25
max_clients = 10
```
### **Bot Integration Changes**
```python
# Modern bot integration - much simpler
def _handle_command_data(self, command_data):
"""Send command data to modern web viewer"""
try:
response = self.session.post(
f"{self.web_viewer_url}/api/stream_data",
json={'type': 'command', 'data': command_data},
timeout=5
)
if response.status_code == 200:
self.logger.debug("Command data sent to modern web viewer")
except Exception as e:
self.logger.debug(f"Failed to send command data: {e}")
```
## 📈 Conclusion
The modern rewrite using Flask-SocketIO 5.x best practices would:
1. **Eliminate all current issues** (hanging, connection leaks, circuit breaker trips)
2. **Provide better performance** (lower resource usage, better stability)
3. **Improve maintainability** (cleaner code, better debugging)
4. **Follow modern patterns** (event-driven architecture, proper error handling)
**Recommendation**: Proceed with the modern rewrite to eliminate the recurring issues and create a more maintainable, stable web viewer.
+12 -7
View File
@@ -38,6 +38,12 @@ class PathCommand(BaseCommand):
self.max_proximity_range = bot.config.getfloat('Path_Command', 'max_proximity_range', fallback=200.0)
self.max_repeater_age_days = bot.config.getint('Path_Command', 'max_repeater_age_days', fallback=14)
# Get recency/proximity weighting (0.0 to 1.0, where 1.0 = 100% recency, 0.0 = 100% proximity)
# Default 0.4 means 40% recency, 60% proximity (more balanced for path routing)
recency_weight = bot.config.getfloat('Path_Command', 'recency_weight', fallback=0.4)
self.recency_weight = max(0.0, min(1.0, recency_weight)) # Clamp to 0.0-1.0
self.proximity_weight = 1.0 - self.recency_weight
# Get confidence indicator symbols from config
self.high_confidence_symbol = bot.config.get('Path_Command', 'high_confidence_symbol', fallback='🎯')
self.medium_confidence_symbol = bot.config.get('Path_Command', 'medium_confidence_symbol', fallback='📍')
@@ -473,9 +479,8 @@ class PathCommand(BaseCommand):
normalized_distance = min(distance / 1000.0, 1.0)
proximity_score = 1.0 - normalized_distance # Invert so closer = higher score
# Weight recency more heavily for path decoding (70% recency, 30% proximity)
# Recent repeaters are more likely to have been involved in the message path
combined_score = (recency_score * 0.7) + (proximity_score * 0.3)
# Use configurable weighting (default: 40% recency, 60% proximity)
combined_score = (recency_score * self.recency_weight) + (proximity_score * self.proximity_weight)
combined_scores.append((combined_score, distance, repeater))
if not combined_scores:
@@ -815,8 +820,8 @@ class PathCommand(BaseCommand):
normalized_distance = min(avg_distance / 1000.0, 1.0)
proximity_score = 1.0 - normalized_distance
# Combined score: 70% recency, 30% proximity (recency more important for path decoding)
combined_score = (recency_score * 0.7) + (proximity_score * 0.3)
# Use configurable weighting (default: 40% recency, 60% proximity)
combined_score = (recency_score * self.recency_weight) + (proximity_score * self.proximity_weight)
if combined_score > best_combined_score:
best_combined_score = combined_score
@@ -865,8 +870,8 @@ class PathCommand(BaseCommand):
normalized_distance = min(distance / 1000.0, 1.0)
proximity_score = 1.0 - normalized_distance
# Combined score: 70% recency, 30% proximity (recency more important for path decoding)
combined_score = (recency_score * 0.7) + (proximity_score * 0.3)
# Use configurable weighting (default: 40% recency, 60% proximity)
combined_score = (recency_score * self.recency_weight) + (proximity_score * self.proximity_weight)
if combined_score > best_combined_score:
best_combined_score = combined_score
+91 -2
View File
@@ -205,14 +205,18 @@ class RepeaterCommand(BaseCommand):
return f"❌ Error listing repeaters: {e}"
async def _handle_purge(self, args: List[str]) -> str:
"""Purge repeater contacts"""
"""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] [reason]\nExamples:\n !repeater purge all 'Clear all repeaters'\n !repeater purge all force 'Force clear all repeaters'\n !repeater purge 30 'Auto-cleanup old repeaters'\n !repeater purge 'Hillcrest' 'Remove specific repeater'"
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'
@@ -358,6 +362,91 @@ class RepeaterCommand(BaseCommand):
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'):
+528 -18
View File
@@ -35,6 +35,12 @@ class RepeaterManager:
self.contact_limit = 300 # MeshCore device limit (will be updated from device info)
self.auto_purge_threshold = 280 # Start purging when 280+ contacts
self.auto_purge_enabled = True
# Initialize companion purge settings
self.companion_purge_enabled = bot.config.getboolean('Companion_Purge', 'companion_purge_enabled', fallback=False)
self.companion_dm_threshold_days = bot.config.getint('Companion_Purge', 'companion_dm_threshold_days', fallback=30)
self.companion_advert_threshold_days = bot.config.getint('Companion_Purge', 'companion_advert_threshold_days', fallback=30)
self.companion_min_inactive_days = bot.config.getint('Companion_Purge', 'companion_min_inactive_days', fallback=30)
def _init_repeater_tables(self):
"""Initialize repeater-specific database tables"""
@@ -552,7 +558,7 @@ class RepeaterManager:
return await self.get_complete_contact_database(role_filter='bot', include_historical=include_historical)
async def check_and_auto_purge(self) -> bool:
"""Check contact limit and auto-purge repeaters if needed"""
"""Check contact limit and auto-purge repeaters and companions if needed"""
try:
if not self.auto_purge_enabled:
return False
@@ -568,12 +574,25 @@ class RepeaterManager:
purge_count = current_count - target_count
if purge_count > 0:
success = await self._auto_purge_repeaters(purge_count)
if success:
self.logger.info(f"✅ Auto-purged {purge_count} repeaters, now at {len(self.bot.meshcore.contacts)}/{self.contact_limit} contacts")
# First try to purge repeaters
repeater_success = await self._auto_purge_repeaters(purge_count)
remaining_count = len(self.bot.meshcore.contacts)
# If still above threshold and companion purging is enabled, purge companions
if remaining_count >= self.auto_purge_threshold and self.companion_purge_enabled:
remaining_purge_count = remaining_count - target_count
self.logger.info(f"Still above threshold after repeater purge, purging {remaining_purge_count} companions...")
companion_success = await self._auto_purge_companions(remaining_purge_count)
if repeater_success or companion_success:
final_count = len(self.bot.meshcore.contacts)
self.logger.info(f"✅ Auto-purge completed, now at {final_count}/{self.contact_limit} contacts")
return True
elif repeater_success:
self.logger.info(f"✅ Auto-purged {purge_count} repeaters, now at {remaining_count}/{self.contact_limit} contacts")
return True
else:
self.logger.warning(f"❌ Auto-purge failed to remove {purge_count} repeaters")
self.logger.warning(f"❌ Auto-purge failed to remove {purge_count} contacts")
return False
return False
@@ -620,6 +639,60 @@ class RepeaterManager:
self.logger.error(f"Error in auto-purge execution: {e}")
return False
async def _auto_purge_companions(self, count: int) -> bool:
"""Automatically purge companion contacts using intelligent selection"""
try:
if not self.companion_purge_enabled:
self.logger.debug("Companion purging is disabled")
return False
# Get all companions sorted by priority (most inactive first)
companions_to_purge = await self._get_companions_for_purging(count)
if not companions_to_purge:
self.logger.warning("No companions available for auto-purge")
# Log some debugging info
total_contacts = len(self.bot.meshcore.contacts)
companion_count = sum(1 for contact_data in self.bot.meshcore.contacts.values() if self._is_companion_device(contact_data))
self.logger.debug(f"Debug: {total_contacts} total contacts, {companion_count} companions found")
return False
purged_count = 0
for i, companion in enumerate(companions_to_purge):
try:
public_key = companion['public_key']
# Get activity info (already formatted as 'never' if no activity)
last_dm = companion.get('last_dm', 'never')
last_advert = companion.get('last_advert', 'never')
days_inactive = companion.get('days_inactive', 'unknown')
success = await self.purge_companion_from_contacts(public_key, "Auto-purge - contact limit management")
if success:
purged_count += 1
self.logger.info(f"🗑️ Auto-purged companion: {companion['name']} (DM: {last_dm}, Advert: {last_advert}, Inactive: {days_inactive}d)")
else:
self.logger.warning(f"Failed to auto-purge companion: {companion['name']}")
# Add delay between removals to avoid overwhelming the radio
# Use longer delay (2 seconds) to give radio time to process
if i < len(companions_to_purge) - 1:
await asyncio.sleep(2)
except Exception as e:
self.logger.error(f"Error auto-purging companion {companion['name']}: {e}")
# Still add delay even on error
if i < len(companions_to_purge) - 1:
await asyncio.sleep(2)
continue
self.logger.info(f"✅ Auto-purge completed: {purged_count}/{count} companions removed")
return purged_count > 0
except Exception as e:
self.logger.error(f"Error in companion auto-purge execution: {e}")
return False
async def _get_repeaters_for_purging(self, count: int) -> List[Dict]:
"""Get list of repeaters to purge based on intelligent criteria from device contacts"""
try:
@@ -711,6 +784,133 @@ class RepeaterManager:
self.logger.error(f"Error getting repeaters for purging: {e}")
return []
async def _get_companions_for_purging(self, count: int) -> List[Dict]:
"""Get list of companion contacts to purge based on activity criteria"""
try:
if not self.companion_purge_enabled:
self.logger.debug("Companion purging is disabled")
return []
# Get companions directly from device contacts
device_companions = []
current_time = datetime.now()
for contact_key, contact_data in self.bot.meshcore.contacts.items():
# Check if this is a companion device
if not self._is_companion_device(contact_data):
continue
public_key = contact_data.get('public_key', contact_key)
name = contact_data.get('adv_name', contact_data.get('name', 'Unknown'))
# Skip if in ACL (never purge ACL members)
if self._is_in_acl(public_key):
self.logger.debug(f"Skipping companion {name} - in ACL")
continue
# Get last DM activity
last_dm = self._get_last_dm_activity(public_key)
# Get last advert activity
last_advert = self._get_last_advert_activity(public_key)
# Determine most recent activity (DM or advert)
last_activity = None
if last_dm and last_advert:
last_activity = max(last_dm, last_advert)
elif last_dm:
last_activity = last_dm
elif last_advert:
last_activity = last_advert
# Calculate days since last activity
days_inactive = None
if last_activity:
days_inactive = (current_time - last_activity).days
else:
# No activity found - use last_seen from device or default to very old
last_seen = contact_data.get('last_seen', contact_data.get('last_advert', contact_data.get('timestamp')))
if last_seen:
try:
if isinstance(last_seen, str):
last_seen_dt = datetime.fromisoformat(last_seen.replace('Z', '+00:00'))
elif isinstance(last_seen, (int, float)):
last_seen_dt = datetime.fromtimestamp(last_seen)
else:
last_seen_dt = last_seen
days_inactive = (current_time - last_seen_dt).days
except:
days_inactive = 999 # Very old if we can't parse
else:
days_inactive = 999 # Very old if no data
# Check if companion meets purge criteria
dm_threshold_met = True
advert_threshold_met = True
if last_dm:
days_since_dm = (current_time - last_dm).days
dm_threshold_met = days_since_dm >= self.companion_dm_threshold_days
else:
# No DM history - check if we have minimum inactive days
dm_threshold_met = days_inactive >= self.companion_min_inactive_days if days_inactive else False
if last_advert:
days_since_advert = (current_time - last_advert).days
advert_threshold_met = days_since_advert >= self.companion_advert_threshold_days
else:
# No advert history - check if we have minimum inactive days
advert_threshold_met = days_inactive >= self.companion_min_inactive_days if days_inactive else False
# Only add if both thresholds are met (hasn't DM'd AND hasn't adverted recently)
if dm_threshold_met and advert_threshold_met:
device_companions.append({
'public_key': public_key,
'name': name,
'last_dm': last_dm.isoformat() if last_dm else 'never',
'last_advert': last_advert.isoformat() if last_advert else 'never',
'last_activity': last_activity.isoformat() if last_activity else None,
'days_inactive': days_inactive,
'latitude': contact_data.get('adv_lat'),
'longitude': contact_data.get('adv_lon'),
'city': contact_data.get('city'),
'state': contact_data.get('state'),
'country': contact_data.get('country')
})
# Sort by priority (most inactive first, then without location data)
device_companions.sort(key=lambda x: (
# Priority 1: Most inactive first
-x['days_inactive'] if x['days_inactive'] else -999,
# Priority 2: Without location data first
0 if not (x.get('latitude') and x.get('longitude')) else 1
))
# Apply additional filtering
filtered_companions = []
for companion in device_companions:
# Skip very recently active (last 2 hours) - more lenient
if companion['last_activity']:
try:
last_activity_dt = datetime.fromisoformat(companion['last_activity'])
if last_activity_dt > current_time - timedelta(hours=2):
continue
except:
pass
filtered_companions.append(companion)
if len(filtered_companions) >= count:
break
self.logger.debug(f"Found {len(device_companions)} device companions, {len(filtered_companions)} available for purging")
return filtered_companions[:count]
except Exception as e:
self.logger.error(f"Error getting companions for purging: {e}")
return []
def _extract_location_data(self, contact_data: Dict, should_geocode: bool = True) -> Dict[str, Optional[str]]:
"""Extract location data from contact_data JSON"""
location_info = {
@@ -870,16 +1070,16 @@ class RepeaterManager:
should_geocode = False
updated_location_info = location_info.copy()
# If no existing data, only geocode if we have valid coordinates but no city
# If no existing data, only geocode if we have valid coordinates but missing location data
if not existing_data:
should_geocode = (
location_info['latitude'] is not None and
location_info['longitude'] is not None and
not (location_info['latitude'] == 0.0 and location_info['longitude'] == 0.0) and
not location_info['city']
not (location_info['state'] and location_info['country'])
)
if should_geocode:
self.logger.debug(f"📍 New contact {name}, will geocode coordinates")
self.logger.debug(f"📍 New contact {name}, will geocode coordinates (missing state/country)")
return should_geocode, updated_location_info
# Extract existing location data
@@ -889,27 +1089,37 @@ class RepeaterManager:
existing_state = existing_data.get('state')
existing_country = existing_data.get('country')
# Only geocode if coordinates changed or we don't have city data
# Check if we have valid coordinates in the new data
if (location_info['latitude'] is not None and
location_info['longitude'] is not None and
not (location_info['latitude'] == 0.0 and location_info['longitude'] == 0.0)):
# Use a more lenient threshold for coordinate changes (0.001 degrees ≈ 111 meters)
# This prevents geocoding for minor GPS variations in stationary repeaters
coordinates_changed = (
abs(location_info['latitude'] - existing_lat) > 0.0001 or
abs(location_info['longitude'] - existing_lon) > 0.0001
abs(location_info['latitude'] - existing_lat) > 0.001 or
abs(location_info['longitude'] - existing_lon) > 0.001
)
# Only geocode if coordinates changed or we don't have a city
should_geocode = coordinates_changed or not existing_city
# Check if we have sufficient location data (state AND country)
has_sufficient_location_data = existing_state and existing_country
if not should_geocode and existing_city:
# Use existing city data, no need to geocode
# Only geocode if:
# 1. Coordinates changed significantly (repeater moved), OR
# 2. We're missing both state and country (critical location data)
should_geocode = coordinates_changed or not has_sufficient_location_data
if not should_geocode:
# Coordinates haven't changed and we have sufficient location data
# Use existing location data, no need to geocode
updated_location_info['city'] = existing_city
updated_location_info['state'] = existing_state
updated_location_info['country'] = existing_country
self.logger.debug(f"📍 Using existing location data for {name}: {existing_city}")
elif should_geocode:
self.logger.debug(f"📍 Location changed for {name}, will geocode new coordinates")
self.logger.debug(f"📍 Using existing location data for {name} (coordinates unchanged, has state/country)")
elif coordinates_changed:
self.logger.debug(f"📍 Location changed significantly for {name} (moved >111m), will geocode new coordinates")
else:
self.logger.debug(f"📍 Missing state/country for {name}, will geocode coordinates")
else:
# No valid coordinates in new data, keep existing location
updated_location_info['latitude'] = existing_lat if existing_lat != 0.0 else None
@@ -1191,6 +1401,131 @@ class RepeaterManager:
self.logger.error(f"Error checking if device is repeater: {e}")
return False
def _is_companion_device(self, contact_data: Dict) -> bool:
"""Check if a contact is a companion (human user, not a repeater)"""
try:
# Companion is simply the inverse of repeater
return not self._is_repeater_device(contact_data)
except Exception as e:
self.logger.error(f"Error checking if device is companion: {e}")
return False
def _is_in_acl(self, public_key: str) -> bool:
"""Check if a public key is in the bot's admin ACL (should never be purged)"""
try:
if not hasattr(self.bot, 'config'):
return False
# Get admin pubkeys from config
admin_pubkeys = self.bot.config.get('Admin_ACL', 'admin_pubkeys', fallback='')
if not admin_pubkeys:
return False
# Parse admin pubkeys
admin_pubkey_list = [key.strip() for key in admin_pubkeys.split(',') if key.strip()]
if not admin_pubkey_list:
return False
# Check if public key matches any admin key (exact match required for security)
for admin_key in admin_pubkey_list:
if public_key == admin_key:
return True
return False
except Exception as e:
self.logger.error(f"Error checking ACL membership: {e}")
return False # Default to not in ACL on error (safer)
def _get_last_dm_activity(self, public_key: str, sender_id: str = None) -> Optional[datetime]:
"""Get the timestamp of the last DM from a contact"""
try:
import time
# Try to find sender_id from contact if not provided
if not sender_id:
# Try to get sender_id from device contacts
if hasattr(self.bot.meshcore, 'contacts'):
for contact_key, contact_data in self.bot.meshcore.contacts.items():
if contact_data.get('public_key', contact_key) == public_key:
sender_id = contact_data.get('name', contact_data.get('adv_name', ''))
break
if not sender_id:
# Try to get from complete_contact_tracking
tracking_data = self.db_manager.execute_query(
'SELECT name FROM complete_contact_tracking WHERE public_key = ? LIMIT 1',
(public_key,)
)
if tracking_data:
sender_id = tracking_data[0]['name']
if not sender_id:
return None
# Query message_stats for last DM
query = '''
SELECT MAX(timestamp) as last_dm_timestamp
FROM message_stats
WHERE sender_id = ? AND is_dm = 1
'''
results = self.db_manager.execute_query(query, (sender_id,))
if results and results[0]['last_dm_timestamp']:
timestamp = results[0]['last_dm_timestamp']
# Convert to datetime
if isinstance(timestamp, (int, float)):
return datetime.fromtimestamp(timestamp)
elif isinstance(timestamp, str):
try:
return datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
except:
return None
return None
except Exception as e:
self.logger.debug(f"Error getting last DM activity for {public_key}: {e}")
return None
def _get_last_advert_activity(self, public_key: str) -> Optional[datetime]:
"""Get the timestamp of the last advert from a contact"""
try:
# Query complete_contact_tracking for last advert
query = '''
SELECT last_advert_timestamp, last_heard
FROM complete_contact_tracking
WHERE public_key = ? AND role = 'companion'
LIMIT 1
'''
results = self.db_manager.execute_query(query, (public_key,))
if results:
# Prefer last_advert_timestamp, fallback to last_heard
timestamp = results[0].get('last_advert_timestamp') or results[0].get('last_heard')
if timestamp:
# Convert to datetime
if isinstance(timestamp, datetime):
return timestamp
elif isinstance(timestamp, str):
try:
return datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
except:
# Try parsing as timestamp
try:
return datetime.fromtimestamp(float(timestamp))
except:
return None
elif isinstance(timestamp, (int, float)):
return datetime.fromtimestamp(timestamp)
return None
except Exception as e:
self.logger.debug(f"Error getting last advert activity for {public_key}: {e}")
return None
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
@@ -1581,6 +1916,181 @@ class RepeaterManager:
self.logger.debug(f"Error type: {type(e).__name__}")
return False
async def purge_companion_from_contacts(self, public_key: str, reason: str = "Manual purge") -> bool:
"""Remove a companion contact from the device's contact list"""
self.logger.info(f"Starting companion purge process for public_key: {public_key}")
self.logger.debug(f"Purge reason: {reason}")
try:
# Safety check: Never purge ACL members
if self._is_in_acl(public_key):
self.logger.warning(f"❌ Attempted to purge companion in ACL - BLOCKED: {public_key[:16]}...")
return False
# Find the contact in meshcore
contact_to_remove = None
contact_name = None
contact_key = None
self.logger.debug(f"Searching through {len(self.bot.meshcore.contacts)} contacts...")
# Try to find contact using MeshCore helper methods first
try:
contact_to_remove = self.bot.meshcore.get_contact_by_key_prefix(public_key[:8])
if contact_to_remove:
contact_name = contact_to_remove.get('adv_name', contact_to_remove.get('name', 'Unknown'))
contact_key = public_key
self.logger.debug(f"Found contact using key prefix: {contact_name}")
except Exception as e:
self.logger.debug(f"Key prefix lookup failed: {e}")
# Fallback to manual search
if not contact_to_remove:
for key, contact_data in self.bot.meshcore.contacts.items():
if contact_data.get('public_key', key) == public_key:
# Verify it's a companion
if not self._is_companion_device(contact_data):
self.logger.warning(f"Contact {public_key} is not a companion - skipping")
return False
contact_to_remove = contact_data
contact_name = contact_data.get('adv_name', contact_data.get('name', 'Unknown'))
contact_key = key
self.logger.debug(f"Found companion manually: {contact_name} (key: {contact_key})")
break
if not contact_to_remove:
self.logger.warning(f"Companion with public key {public_key} not found in current contacts")
return False
# Track whether device removal was successful
device_removal_successful = False
# Verify contact still exists before attempting removal
contact_still_exists = any(
contact_data.get('public_key', key) == public_key
for key, contact_data in self.bot.meshcore.contacts.items()
)
if not contact_still_exists:
self.logger.info(f"✅ Contact '{contact_name}' not found in device contacts (already removed) - treating as success")
device_removal_successful = True
else:
# Remove the contact using the proper MeshCore API
try:
self.logger.info(f"Removing companion '{contact_name}' from device using MeshCore API...")
self.logger.debug(f"Contact details: public_key={public_key}, contact_key={contact_key}, name='{contact_name}'")
# Try removal methods in order of preference
# Method 1: Try with contact object (if supported)
if contact_to_remove:
try:
self.logger.debug(f"Trying removal with contact object...")
result = await asyncio.wait_for(
self.bot.meshcore.commands.remove_contact(contact_to_remove),
timeout=30.0
)
if result.type == EventType.OK:
device_removal_successful = True
self.logger.info(f"✅ Successfully removed companion '{contact_name}' using contact object")
except Exception as e:
self.logger.debug(f"Contact object removal failed: {e}")
# Small delay between method attempts
if not device_removal_successful:
await asyncio.sleep(0.5)
# Method 2: Try with public_key (string)
if not device_removal_successful:
try:
self.logger.debug(f"Trying removal with public_key string: {public_key[:16]}...")
result = await asyncio.wait_for(
self.bot.meshcore.commands.remove_contact(public_key),
timeout=30.0
)
if result.type == EventType.OK:
device_removal_successful = True
self.logger.info(f"✅ Successfully removed companion '{contact_name}' using public_key")
else:
error_code = result.payload.get('error_code', 'unknown') if hasattr(result, 'payload') else 'unknown'
self.logger.debug(f"Removal with public_key failed: error_code={error_code}")
except Exception as e:
self.logger.debug(f"Public key removal failed: {e}")
# Small delay between method attempts
if not device_removal_successful:
await asyncio.sleep(0.5)
# Method 3: Try with contact_key (dictionary key)
if not device_removal_successful and contact_key and contact_key != public_key:
try:
self.logger.debug(f"Trying removal with contact_key: {contact_key[:16]}...")
result = await asyncio.wait_for(
self.bot.meshcore.commands.remove_contact(contact_key),
timeout=30.0
)
if result.type == EventType.OK:
device_removal_successful = True
self.logger.info(f"✅ Successfully removed companion '{contact_name}' using contact_key")
except Exception as e:
self.logger.debug(f"Contact key removal failed: {e}")
# Small delay between method attempts
if not device_removal_successful:
await asyncio.sleep(0.5)
# Method 4: Try with public_key as bytes
if not device_removal_successful:
try:
if len(public_key) == 64: # 32 bytes in hex
public_key_bytes = bytes.fromhex(public_key)
self.logger.debug(f"Trying removal with public_key as bytes...")
result = await asyncio.wait_for(
self.bot.meshcore.commands.remove_contact(public_key_bytes),
timeout=30.0
)
if result.type == EventType.OK:
device_removal_successful = True
self.logger.info(f"✅ Successfully removed companion '{contact_name}' using public_key bytes")
except Exception as e:
self.logger.debug(f"Public key bytes removal failed: {e}")
# If all methods failed, try fallback methods
if not device_removal_successful:
self.logger.warning(f"All removal methods failed for '{contact_name}' - trying fallback methods...")
device_removal_successful = await self._try_fallback_removal_methods(public_key, contact_name, reason)
except Exception as e:
self.logger.error(f"Failed to remove companion '{contact_name}' from device: {e}")
device_removal_successful = await self._try_fallback_removal_methods(public_key, contact_name, reason)
# Update tracking database if device removal was successful
if device_removal_successful:
# Update complete_contact_tracking to mark as not currently tracked
self.db_manager.execute_update(
'UPDATE complete_contact_tracking SET is_currently_tracked = 0 WHERE public_key = ?',
(public_key,)
)
# Log the purge action
self.db_manager.execute_update('''
INSERT INTO purging_log (action, public_key, name, reason)
VALUES ('companion_purged', ?, ?, ?)
''', (public_key, contact_name, reason))
self.logger.info(f"✅ Successfully purged companion {contact_name}: {reason}")
self.logger.debug(f"Companion purge process completed successfully for {contact_name}")
return True
else:
self.logger.error(f"Failed to remove companion {contact_name} from device - not marking as purged in database")
# Log the failed attempt
self.db_manager.execute_update('''
INSERT INTO purging_log (action, public_key, name, reason)
VALUES ('companion_purge_failed', ?, ?, ?)
''', (public_key, contact_name, f"{reason} - Device removal failed"))
return False
except Exception as e:
self.logger.error(f"Error purging companion {public_key}: {e}")
self.logger.debug(f"Error type: {type(e).__name__}")
return False
async def _try_fallback_removal_methods(self, public_key: str, contact_name: str, reason: str) -> bool:
"""Try alternative methods to remove a contact when the primary MeshCore API fails"""
try:
+78 -2
View File
@@ -108,9 +108,20 @@ class BotDataViewer:
from modules.db_manager import DBManager
# Create a minimal bot object for DBManager
class MinimalBot:
def __init__(self, logger):
def __init__(self, logger, config, db_manager=None):
self.logger = logger
self.db_manager = DBManager(MinimalBot(self.logger), self.db_path)
self.config = config
self.db_manager = db_manager
# Create DBManager first
minimal_bot = MinimalBot(self.logger, self.config)
self.db_manager = DBManager(minimal_bot, self.db_path)
# Now set db_manager on the minimal bot for RepeaterManager
minimal_bot.db_manager = self.db_manager
# Initialize repeater manager for geocoding functionality
self.repeater_manager = RepeaterManager(minimal_bot)
# Store database paths for direct connection
self.db_path = self.db_path
@@ -291,6 +302,71 @@ class BotDataViewer:
except Exception as e:
self.logger.error(f"Error getting recent commands: {e}")
return jsonify({'error': str(e)}), 500
@self.app.route('/api/geocode-contact', methods=['POST'])
def api_geocode_contact():
"""Manually geocode a contact by public_key"""
try:
data = request.get_json()
if not data or 'public_key' not in data:
return jsonify({'error': 'public_key is required'}), 400
public_key = data['public_key']
# Get contact data from database
conn = self._get_db_connection()
cursor = conn.cursor()
cursor.execute('''
SELECT latitude, longitude, name, city, state, country
FROM complete_contact_tracking
WHERE public_key = ?
''', (public_key,))
contact = cursor.fetchone()
if not contact:
conn.close()
return jsonify({'error': 'Contact not found'}), 404
lat = contact['latitude']
lon = contact['longitude']
name = contact['name']
# Check if we have valid coordinates
if lat is None or lon is None or lat == 0.0 or lon == 0.0:
conn.close()
return jsonify({'error': 'Contact does not have valid coordinates'}), 400
# Perform geocoding
self.logger.info(f"Manual geocoding requested for {name} ({public_key[:16]}...)")
location_info = self.repeater_manager._get_full_location_from_coordinates(lat, lon)
# Update database with new location data
cursor.execute('''
UPDATE complete_contact_tracking
SET city = ?, state = ?, country = ?
WHERE public_key = ?
''', (
location_info.get('city'),
location_info.get('state'),
location_info.get('country'),
public_key
))
conn.commit()
conn.close()
self.logger.info(f"Successfully geocoded {name}: {location_info}")
return jsonify({
'success': True,
'location': location_info,
'message': f'Successfully geocoded {name}'
})
except Exception as e:
self.logger.error(f"Error geocoding contact: {e}")
return jsonify({'error': str(e)}), 500
def _setup_socketio_handlers(self):
"""Setup SocketIO event handlers using modern patterns"""
+118 -5
View File
@@ -418,9 +418,17 @@ class ModernContactsManager {
<td>${this.formatTimeAgo(contact.last_seen)}</td>
<td><span class="badge bg-success">${contact.advert_count || 0}</span></td>
<td>
<button class="btn btn-sm btn-outline-info" onclick="contactsManager.viewAdvertData('${contact.user_id}')" title="View Advertisement Data">
<i class="fas fa-info-circle"></i>
</button>
<div class="btn-group" role="group">
<button class="btn btn-sm btn-outline-info" onclick="contactsManager.viewAdvertData('${contact.user_id}')" title="View Advertisement Data">
<i class="fas fa-info-circle"></i>
</button>
${contact.latitude && contact.longitude && contact.latitude !== 0 && contact.longitude !== 0 ?
`<button class="btn btn-sm btn-outline-primary" onclick="contactsManager.geocodeContact('${contact.user_id.replace(/'/g, "\\'")}', this)" title="Geocode Location">
<i class="fas fa-map-marker-alt"></i>
</button>` :
''
}
</div>
</td>
</tr>
`).join('');
@@ -789,10 +797,94 @@ class ModernContactsManager {
modal.show();
}
async geocodeContact(userId, buttonElement = null) {
// Find the contact data
const contact = this.filteredData.find(c => c.user_id === userId);
if (!contact) {
this.showError('Contact data not found');
return;
}
// Check if contact has valid coordinates
if (!contact.latitude || !contact.longitude || contact.latitude === 0 || contact.longitude === 0) {
this.showError('Contact does not have valid coordinates');
return;
}
// Get the button element - use passed element or find it
const button = buttonElement || document.querySelector(`button[onclick*="${userId.substring(0, 16)}"]`);
if (!button) {
this.showError('Button not found');
return;
}
// Disable button and show loading state
const originalHTML = button.innerHTML;
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
button.classList.remove('btn-outline-primary');
button.classList.add('btn-secondary');
try {
// Call the geocoding API
const response = await fetch('/api/geocode-contact', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
public_key: userId
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Geocoding failed');
}
// Update the contact data in our local array
const contactIndex = this.filteredData.findIndex(c => c.user_id === userId);
if (contactIndex !== -1) {
this.filteredData[contactIndex].city = data.location.city;
this.filteredData[contactIndex].state = data.location.state;
this.filteredData[contactIndex].country = data.location.country;
}
// Also update in the main contacts data
const mainContactIndex = this.contactsData.tracking_data.findIndex(c => c.user_id === userId);
if (mainContactIndex !== -1) {
this.contactsData.tracking_data[mainContactIndex].city = data.location.city;
this.contactsData.tracking_data[mainContactIndex].state = data.location.state;
this.contactsData.tracking_data[mainContactIndex].country = data.location.country;
}
// Re-render the table to show updated location
this.renderContactsData();
// Show success message
this.showSuccess(data.message || 'Location geocoded successfully');
} catch (error) {
console.error('Error geocoding contact:', error);
this.showError('Failed to geocode contact: ' + error.message);
} finally {
// Restore button state
button.disabled = false;
button.innerHTML = originalHTML;
button.classList.remove('btn-secondary');
button.classList.add('btn-outline-primary');
}
}
showError(message) {
const errorDiv = document.createElement('div');
errorDiv.className = 'error';
errorDiv.textContent = message;
errorDiv.className = 'alert alert-danger alert-dismissible fade show';
errorDiv.setAttribute('role', 'alert');
errorDiv.innerHTML = `
<strong>Error:</strong> ${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
`;
const content = document.querySelector('.container-fluid');
if (content) {
@@ -805,6 +897,27 @@ class ModernContactsManager {
}, 5000);
}
}
showSuccess(message) {
const successDiv = document.createElement('div');
successDiv.className = 'alert alert-success alert-dismissible fade show';
successDiv.setAttribute('role', 'alert');
successDiv.innerHTML = `
<strong>Success:</strong> ${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
`;
const content = document.querySelector('.container-fluid');
if (content) {
content.insertBefore(successDiv, content.firstChild);
setTimeout(() => {
if (successDiv.parentNode) {
successDiv.parentNode.removeChild(successDiv);
}
}, 5000);
}
}
}
// Initialize contacts manager when page loads